Skip to content

Commit 7fe2b3b

Browse files
committed
feat: enhance SEO with site metadata, preconnect links, and sitemap generation
1 parent 9e27012 commit 7fe2b3b

7 files changed

Lines changed: 120 additions & 10 deletions

File tree

‎app.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
"web": {
3333
"output": "static",
3434
"favicon": "./assets/favicon.png"
35+
},
36+
"extra": {
37+
"router": {
38+
"sitemap": false
39+
}
3540
}
3641
}
3742
}

‎app/+html.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { entranceGuardCss, entranceGuardNoscriptCss } from '@rootnative/inertia/static-export'
2+
import { shellTags } from '@rootnative/seo/expo-router'
23
import { ScrollViewStyleReset } from 'expo-router/html'
34
import type { PropsWithChildren } from 'react'
45

@@ -38,6 +39,18 @@ const themeGuardCss = `
3839
}
3940
`
4041

42+
/**
43+
* The hosts the first paint reads from: the org avatar in the hero and the
44+
* library marks on the cards (`lib/brand-marks.ts`), and the version pills
45+
* (`lib/use-npm-version.ts`). The pills use `fetch`, which is a CORS request,
46+
* so that connection needs `crossOrigin`.
47+
*/
48+
const PRECONNECT = [
49+
{ href: 'https://avatars.githubusercontent.com' },
50+
{ href: 'https://raw.githubusercontent.com' },
51+
{ href: 'https://registry.npmjs.org', crossOrigin: true },
52+
]
53+
4154
/**
4255
* No script at all, so the client never adds `theme-ready`. Reveal the page at
4356
* once instead of holding the visitor for the 4s fallback.
@@ -51,9 +64,13 @@ export default function Root({ children }: PropsWithChildren) {
5164
return (
5265
<html lang="en">
5366
<head>
54-
<meta charSet="utf-8" />
67+
{shellTags({ preconnect: PRECONNECT })}
68+
{/* `shellTags` writes one theme colour, and this page has two. A browser
69+
uses the first `theme-color` whose media matches, so each tag names
70+
its scheme. */}
71+
<meta name="theme-color" media="(prefers-color-scheme: light)" content={LIGHT_BACKGROUND} />
72+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content={DARK_BACKGROUND} />
5573
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
56-
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
5774
<ScrollViewStyleReset />
5875
<style dangerouslySetInnerHTML={{ __html: themeGuardCss }} />
5976
<style dangerouslySetInnerHTML={{ __html: entranceGuardCss() }} />

‎app/+not-found.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default function NotFoundScreen() {
1212
{/* GitHub Pages serves this page as `404.html` at every unknown path, so
1313
it has no canonical URL. No `url` means no canonical link and no share
1414
card. */}
15-
<PageHead title="Page not found — RootNative" noindex />
15+
<PageHead title="Page not found — Root Native" noindex />
1616
<Column
1717
align="center"
1818
justify="center"
@@ -34,7 +34,7 @@ export default function NotFoundScreen() {
3434
<Row justify="center">
3535
<Link href="/" replace asChild>
3636
<Button variant="filled" leadingIcon="arrow-left">
37-
Back to rootnative
37+
Back to Root Native
3838
</Button>
3939
</Link>
4040
</Row>

‎app/_layout.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { mdiResolver } from '@rootnative/components/mdi'
22
import { ThemeProvider } from '@rootnative/core'
33
import { MotionConfig } from '@rootnative/inertia'
4+
import { SeoProvider } from '@rootnative/seo/react'
45
import { Stack } from 'expo-router'
56
import { StatusBar } from 'expo-status-bar'
67

78
import { ThemeReadySignal } from '../components/theme-ready-signal'
89
import { TRANSITIONS } from '../lib/motion'
10+
import { site } from '../lib/site'
911
import { darkTheme, lightTheme } from '../lib/theme'
1012

1113
const THEMES = { light: lightTheme, dark: darkTheme }
@@ -18,7 +20,9 @@ export default function RootLayout() {
1820
site animates on load, and a visitor who asks the OS for less motion
1921
must get the page with no cascade at all. */}
2022
<MotionConfig reducedMotion="user" transitions={TRANSITIONS}>
21-
<Stack screenOptions={{ headerShown: false }} />
23+
<SeoProvider site={site}>
24+
<Stack screenOptions={{ headerShown: false }} />
25+
</SeoProvider>
2226
</MotionConfig>
2327
<StatusBar style="auto" />
2428
</ThemeProvider>

‎app/index.tsx‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import { ScrollAway } from '../components/scroll-away'
1414
import { MARK_ORG } from '../lib/brand-marks'
1515
import { LIBRARIES, LINKS } from '../lib/libraries'
1616
import { ENTRANCE_MARKER, IN_VIEW, STAGGER_INTERVAL } from '../lib/motion'
17-
import { PAGE_DESCRIPTION, PAGE_TITLE, site } from '../lib/site'
17+
import { PAGE_DESCRIPTION, PAGE_TITLE, SHARE_CARD, site } from '../lib/site'
1818
import { useScrollAway } from '../lib/use-scroll-away'
1919

2020
const JSON_LD = [
2121
organization({
2222
name: site.name,
2323
url: site.url,
24+
logo: SHARE_CARD.image,
2425
sameAs: [LINKS.org],
2526
}),
2627
webSite({ name: site.name, url: site.url }),
@@ -86,7 +87,13 @@ export default function HomeScreen() {
8687

8788
return (
8889
<>
89-
<PageHead title={PAGE_TITLE} description={PAGE_DESCRIPTION} url="/" jsonLd={JSON_LD} />
90+
<PageHead
91+
title={PAGE_TITLE}
92+
description={PAGE_DESCRIPTION}
93+
url="/"
94+
{...SHARE_CARD}
95+
jsonLd={JSON_LD}
96+
/>
9097
<Motion.ScrollView
9198
style={{ backgroundColor: theme.colors.background }}
9299
contentContainerStyle={styles.scroll}
@@ -142,16 +149,16 @@ export default function HomeScreen() {
142149
{/* The organisation's own mark, read from its GitHub org
143150
avatar. It is an opaque square, so unlike every library
144151
mark it takes a radius — see `BrandMark`. */}
145-
<BrandMark uri={MARK_ORG} size={72} radius={18} label="rootnative logo" />
152+
<BrandMark uri={MARK_ORG} size={72} radius={18} label="Root Native logo" />
146153
</Motion.View>
147154
</ScrollAway>
148155
</Rise>
149156

150157
<Rise style={styles.stretch}>
151158
<ScrollAway progress={heroProgress} drift={HERO_DRIFT.title} style={styles.stretch}>
152159
<Typography level={1} variant={titleVariant} style={styles.title}>
153-
<Text style={{ color: theme.colors.primary }}>root</Text>
154-
<Text style={{ color: theme.colors.onBackground }}>native</Text>
160+
<Text style={{ color: theme.colors.primary }}>Root</Text>{' '}
161+
<Text style={{ color: theme.colors.onBackground }}>Native</Text>
155162
</Typography>
156163
</ScrollAway>
157164
</Rise>

‎lib/site.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { defineSite, type PageMeta } from '@rootnative/seo'
2+
3+
/**
4+
* The page title is a sentence, not a page name, so the template keeps it as
5+
* written. The site name still reaches the share card through `og:site_name`.
6+
*/
7+
export const site = defineSite({
8+
name: 'Root Native',
9+
url: 'https://rootnative.github.io',
10+
locale: 'en_US',
11+
titleTemplate: (title) => title,
12+
})
13+
14+
export const PAGE_TITLE = 'Root Native — libraries that power React Native & Expo apps'
15+
16+
/**
17+
* The share card and the Organization logo: the org avatar at 460 px, the
18+
* largest size GitHub serves. The avatar is square, so the Twitter card stays
19+
* `summary`; `summary_large_image` crops the image to 2:1.
20+
*/
21+
export const SHARE_CARD = {
22+
image: 'https://avatars.githubusercontent.com/rootnative?size=460',
23+
imageSize: { width: 460, height: 460 },
24+
imageAlt: 'Root Native logo',
25+
twitterCard: 'summary',
26+
} satisfies Pick<PageMeta, 'image' | 'imageSize' | 'imageAlt' | 'twitterCard'>
27+
28+
export const PAGE_DESCRIPTION =
29+
'Root Native builds open-source libraries that power React Native & Expo apps — UI components, animations without the boilerplate, and a lightweight game engine.'

‎scripts/write-seo.mjs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Writes `robots.txt` and `sitemap.xml` into `dist/` after the export.
3+
*
4+
* The sitemap routes come from the exported HTML files, so the sitemap cannot
5+
* drift from the app. `+not-found` is the 404 page and stays out.
6+
*
7+
* This site is the origin root of rootnative.github.io, and a crawler reads
8+
* `robots.txt` at the root only. So this file lists every sitemap of the
9+
* origin: this page, and the docs and demo sites the other repositories
10+
* deploy under their own paths. A new docs site adds one line here.
11+
*/
12+
import { existsSync, readdirSync } from 'node:fs'
13+
import { dirname, resolve } from 'node:path'
14+
import { fileURLToPath } from 'node:url'
15+
import { writeRobots, writeSitemap } from '@rootnative/seo/node'
16+
17+
const here = dirname(fileURLToPath(import.meta.url))
18+
const dist = resolve(here, '..', 'dist')
19+
20+
const SITE_URL = 'https://rootnative.github.io'
21+
const EXCLUDED = new Set(['+not-found.html', '404.html', '_sitemap.html'])
22+
23+
const SITEMAPS = [
24+
`${SITE_URL}/sitemap.xml`,
25+
`${SITE_URL}/ui/sitemap.xml`,
26+
`${SITE_URL}/ui/demo/sitemap.xml`,
27+
`${SITE_URL}/inertia/sitemap.xml`,
28+
`${SITE_URL}/impulse/sitemap.xml`,
29+
]
30+
31+
if (!existsSync(dist)) {
32+
console.error('[write-seo] dist not found — run `yarn export:web` first.')
33+
process.exit(1)
34+
}
35+
36+
const urls = readdirSync(dist)
37+
.filter((file) => file.endsWith('.html') && !EXCLUDED.has(file))
38+
.sort()
39+
.map((file) => {
40+
const route = file === 'index.html' ? '/' : `/${file.replace(/\.html$/, '')}`
41+
return { loc: route, changefreq: 'weekly', priority: route === '/' ? 1 : 0.7 }
42+
})
43+
44+
const sitemap = await writeSitemap({ outDir: dist, siteUrl: SITE_URL, urls })
45+
const robots = await writeRobots({ outDir: dist, sitemapUrl: SITEMAPS })
46+
47+
console.log(`[write-seo] ${urls.length} routes → ${sitemap}`)
48+
console.log(`[write-seo] ${robots} (${SITEMAPS.length} sitemaps)`)

0 commit comments

Comments
 (0)