seo 0.13.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/PRIVACY.md +244 -510
  2. package/TERMS.md +57 -72
  3. package/dist/cli.js +88 -84
  4. package/dist/cli.js.map +1 -1
  5. package/dist/{dist-BbTqJIE_.js → dist-Dik5MNDk.js} +1 -1
  6. package/dist/dist-pwGymked.js +449 -0
  7. package/dist/dist-pwGymked.js.map +1 -0
  8. package/dist/{esm-hHlvm1W0.js → esm-DieyhVZ-.js} +95 -188
  9. package/dist/{esm-hHlvm1W0.js.map → esm-DieyhVZ-.js.map} +1 -1
  10. package/dist/index.d.ts +274 -425
  11. package/dist/index.js +5295 -1345
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp.d.ts +0 -14
  14. package/dist/mcp.js +5048 -1769
  15. package/dist/mcp.js.map +1 -1
  16. package/dist/provider-sdk.d.ts +0 -2
  17. package/dist/provider-sdk.js +1 -2
  18. package/package.json +6 -5
  19. package/site-kit/README.md +13 -0
  20. package/site-kit/guide/build.md +70 -0
  21. package/site-kit/guide/data.md +49 -0
  22. package/site-kit/guide/golden-page.md +50 -0
  23. package/site-kit/guide/grow.md +58 -0
  24. package/site-kit/guide/launch.md +55 -0
  25. package/site-kit/guide/market.md +57 -0
  26. package/site-kit/guide/plan.md +49 -0
  27. package/site-kit/guide/scale.md +41 -0
  28. package/site-kit/guide/setup.md +59 -0
  29. package/site-kit/guide/writing.md +55 -0
  30. package/site-kit/starter/.github/workflows/refresh-data.yml +88 -0
  31. package/site-kit/starter/.node-version +1 -0
  32. package/site-kit/starter/AGENTS.md +65 -0
  33. package/site-kit/starter/README.md +11 -0
  34. package/site-kit/starter/astro.config.mjs +31 -0
  35. package/site-kit/starter/gitignore +7 -0
  36. package/site-kit/starter/llms.config.mjs +38 -0
  37. package/site-kit/starter/package.json +31 -0
  38. package/site-kit/starter/pnpm-workspace.yaml +5 -0
  39. package/site-kit/starter/public/_headers +5 -0
  40. package/site-kit/starter/public/favicon.svg +1 -0
  41. package/site-kit/starter/public/fonts/InterLatinVF.woff2 +0 -0
  42. package/site-kit/starter/public/fonts/JetBrainsMonoLatinVF.woff2 +0 -0
  43. package/site-kit/starter/public/fonts/LICENSE-inter.txt +93 -0
  44. package/site-kit/starter/public/fonts/LICENSE-jetbrains-mono.txt +93 -0
  45. package/site-kit/starter/scripts/deploy.mjs +60 -0
  46. package/site-kit/starter/scripts/markdown-rewrites.mjs +33 -0
  47. package/site-kit/starter/scripts/refresh-data.mjs +60 -0
  48. package/site-kit/starter/scripts/validate-data.mjs +47 -0
  49. package/site-kit/starter/site.config.json +9 -0
  50. package/site-kit/starter/src/components/Breadcrumbs.astro +27 -0
  51. package/site-kit/starter/src/components/Button.astro +46 -0
  52. package/site-kit/starter/src/components/Card.astro +54 -0
  53. package/site-kit/starter/src/components/CodeBlock.astro +62 -0
  54. package/site-kit/starter/src/components/FactTable.astro +45 -0
  55. package/site-kit/starter/src/components/FaqAccordion.astro +143 -0
  56. package/site-kit/starter/src/components/Footer.astro +34 -0
  57. package/site-kit/starter/src/components/FormInput.astro +18 -0
  58. package/site-kit/starter/src/components/FormSelect.astro +34 -0
  59. package/site-kit/starter/src/components/Header.astro +17 -0
  60. package/site-kit/starter/src/components/HugeIcon.astro +36 -0
  61. package/site-kit/starter/src/components/Logo.astro +21 -0
  62. package/site-kit/starter/src/components/PageHeader.astro +33 -0
  63. package/site-kit/starter/src/components/SectionHeading.astro +18 -0
  64. package/site-kit/starter/src/components/ui/button.ts +51 -0
  65. package/site-kit/starter/src/data/parks.json +106 -0
  66. package/site-kit/starter/src/data/sources.json +11 -0
  67. package/site-kit/starter/src/integrations/og-images.ts +49 -0
  68. package/site-kit/starter/src/integrations/sitemaps.ts +104 -0
  69. package/site-kit/starter/src/layouts/Base.astro +69 -0
  70. package/site-kit/starter/src/lib/cn.ts +5 -0
  71. package/site-kit/starter/src/lib/narrative.ts +115 -0
  72. package/site-kit/starter/src/lib/og/card.ts +85 -0
  73. package/site-kit/starter/src/lib/og/path.ts +14 -0
  74. package/site-kit/starter/src/lib/og/render.ts +30 -0
  75. package/site-kit/starter/src/lib/parks.ts +84 -0
  76. package/site-kit/starter/src/lib/seo.ts +97 -0
  77. package/site-kit/starter/src/pages/404.astro +30 -0
  78. package/site-kit/starter/src/pages/about.astro +39 -0
  79. package/site-kit/starter/src/pages/index.astro +52 -0
  80. package/site-kit/starter/src/pages/parks/[slug].astro +95 -0
  81. package/site-kit/starter/src/pages/regions/[region].astro +58 -0
  82. package/site-kit/starter/src/pages/robots.txt.ts +16 -0
  83. package/site-kit/starter/src/site.ts +29 -0
  84. package/site-kit/starter/src/styles/globals.css +561 -0
  85. package/site-kit/starter/tsconfig.json +5 -0
  86. package/site-kit/starter/wrangler.jsonc +16 -0
  87. package/skills/seo/SKILL.md +20 -1
  88. package/dist/dist-DBI-BioS.js +0 -420
  89. package/dist/dist-DBI-BioS.js.map +0 -1
@@ -0,0 +1,51 @@
1
+ import { cn } from '../../lib/cn'
2
+
3
+ // The button styles. Button.astro uses them, and so can a link that
4
+ // should look like a button.
5
+ export const buttonVariants = {
6
+ variant: {
7
+ default: 'bg-primary text-primary-foreground hover:bg-primary-hover',
8
+ destructive:
9
+ 'bg-destructive text-destructive-foreground hover:bg-destructive-hover',
10
+ outline:
11
+ 'border border-border bg-card hover:bg-secondary hover:text-foreground',
12
+ secondary:
13
+ 'bg-secondary text-secondary-foreground hover:bg-secondary-hover',
14
+ ghost: 'text-foreground-muted hover:bg-foreground/5 hover:text-foreground',
15
+ link: 'text-primary underline-offset-4 hover:underline',
16
+ 'link-foreground': 'text-foreground underline-offset-4 hover:underline',
17
+ 'link-muted':
18
+ 'text-foreground-muted underline-offset-4 hover:underline hover:text-foreground',
19
+ },
20
+ size: {
21
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
22
+ xs: 'h-7 rounded-md gap-1 px-2.5 text-xs has-[>svg]:px-2 [&_svg:not([class*=size-])]:size-3.5',
23
+ sm: 'h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5',
24
+ lg: 'h-10 rounded-lg px-6 has-[>svg]:px-4',
25
+ icon: 'size-9',
26
+ iconSm: 'size-8',
27
+ iconXs: 'size-7',
28
+ },
29
+ }
30
+
31
+ export type ButtonVariant = keyof typeof buttonVariants.variant
32
+ export type ButtonSize = keyof typeof buttonVariants.size
33
+
34
+ export const buttonBaseStyles = cn(
35
+ 'inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-medium no-underline',
36
+ 'transition-all outline-none',
37
+ 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20',
38
+ 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
39
+ 'disabled:pointer-events-none disabled:opacity-50',
40
+ '[&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 shrink-0 [&_svg]:shrink-0',
41
+ )
42
+
43
+ export const getButtonClasses = (
44
+ variant: ButtonVariant = 'default',
45
+ size: ButtonSize = 'default',
46
+ ) =>
47
+ cn(
48
+ buttonBaseStyles,
49
+ buttonVariants.variant[variant],
50
+ buttonVariants.size[size],
51
+ )
@@ -0,0 +1,106 @@
1
+ [
2
+ {
3
+ "slug": "alder-common",
4
+ "name": "Alder Common",
5
+ "region": "Northvale",
6
+ "areaHectares": 42.5,
7
+ "opened": 1891,
8
+ "toilets": true,
9
+ "cafe": true,
10
+ "playground": true,
11
+ "dogs": "yes",
12
+ "parkingSpaces": 120,
13
+ "source": "parks-register"
14
+ },
15
+ {
16
+ "slug": "beacon-gardens",
17
+ "name": "Beacon Gardens",
18
+ "region": "Northvale",
19
+ "areaHectares": 6.2,
20
+ "opened": 1934,
21
+ "toilets": true,
22
+ "cafe": false,
23
+ "playground": false,
24
+ "dogs": "on leads",
25
+ "parkingSpaces": null,
26
+ "source": "parks-register"
27
+ },
28
+ {
29
+ "slug": "cherry-lane-park",
30
+ "name": "Cherry Lane Park",
31
+ "region": "Northvale",
32
+ "areaHectares": 11.8,
33
+ "opened": 1972,
34
+ "toilets": false,
35
+ "cafe": false,
36
+ "playground": true,
37
+ "dogs": "yes",
38
+ "parkingSpaces": 30,
39
+ "source": "parks-register"
40
+ },
41
+ {
42
+ "slug": "dockside-green",
43
+ "name": "Dockside Green",
44
+ "region": "Riverside",
45
+ "areaHectares": 3.4,
46
+ "opened": 2009,
47
+ "toilets": true,
48
+ "cafe": true,
49
+ "playground": true,
50
+ "dogs": "no",
51
+ "parkingSpaces": 0,
52
+ "source": "parks-register"
53
+ },
54
+ {
55
+ "slug": "ferry-meadows",
56
+ "name": "Ferry Meadows",
57
+ "region": "Riverside",
58
+ "areaHectares": 58.0,
59
+ "opened": 1958,
60
+ "toilets": true,
61
+ "cafe": true,
62
+ "playground": true,
63
+ "dogs": "yes",
64
+ "parkingSpaces": 260,
65
+ "source": "parks-register"
66
+ },
67
+ {
68
+ "slug": "mill-pond-park",
69
+ "name": "Mill Pond Park",
70
+ "region": "Riverside",
71
+ "areaHectares": 9.1,
72
+ "opened": 1903,
73
+ "toilets": false,
74
+ "cafe": true,
75
+ "playground": false,
76
+ "dogs": "on leads",
77
+ "parkingSpaces": null,
78
+ "source": "parks-register"
79
+ },
80
+ {
81
+ "slug": "heath-top",
82
+ "name": "Heath Top",
83
+ "region": "Eastmoor",
84
+ "areaHectares": 74.3,
85
+ "opened": 1886,
86
+ "toilets": true,
87
+ "cafe": false,
88
+ "playground": false,
89
+ "dogs": "yes",
90
+ "parkingSpaces": 85,
91
+ "source": "parks-register"
92
+ },
93
+ {
94
+ "slug": "quarry-wood",
95
+ "name": "Quarry Wood",
96
+ "region": "Eastmoor",
97
+ "areaHectares": 21.6,
98
+ "opened": 1995,
99
+ "toilets": false,
100
+ "cafe": false,
101
+ "playground": true,
102
+ "dogs": "yes",
103
+ "parkingSpaces": 40,
104
+ "source": "parks-register"
105
+ }
106
+ ]
@@ -0,0 +1,11 @@
1
+ [
2
+ {
3
+ "id": "parks-register",
4
+ "name": "Example parks register",
5
+ "url": "https://example.org/data/parks.csv",
6
+ "checked": "2026-10-01",
7
+ "etag": null,
8
+ "lastModified": null,
9
+ "sha256": null
10
+ }
11
+ ]
@@ -0,0 +1,49 @@
1
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { dirname, join, relative } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import type { AstroIntegration } from 'astro'
5
+ import config from '../../site.config.json'
6
+ import { readH1 } from '../lib/og/card'
7
+ import { ogImagePath, pagePathname } from '../lib/og/path'
8
+ import { ogRenderer } from '../lib/og/render'
9
+
10
+ // Draws one sharing image per page at build time, from the page's real H1,
11
+ // into dist/og/<path>.png. The layout points og:image at the same path, and
12
+ // `seo site check` fails the build when an image is missing.
13
+ export default function ogImages(): AstroIntegration {
14
+ return {
15
+ name: 'og-images',
16
+ hooks: {
17
+ 'astro:build:done': async ({ dir, logger }) => {
18
+ const out = fileURLToPath(dir)
19
+ const started = performance.now()
20
+ const card = await ogRenderer(config)
21
+ const entries = await readdir(out, {
22
+ withFileTypes: true,
23
+ recursive: true,
24
+ })
25
+ let drawn = 0
26
+ for (const entry of entries) {
27
+ if (!entry.isFile() || !entry.name.endsWith('.html')) continue
28
+ const file = join(entry.parentPath, entry.name)
29
+ const path = pagePathname(relative(out, file))
30
+ if (path === '/404') continue
31
+ const title = readH1(await readFile(file, 'utf8'))
32
+ if (!title) {
33
+ logger.warn(
34
+ `${relative(out, file)} has no H1, so it gets no image.`,
35
+ )
36
+ continue
37
+ }
38
+ const target = join(out, ogImagePath(path))
39
+ await mkdir(dirname(target), { recursive: true })
40
+ await writeFile(target, await card(title, path))
41
+ drawn += 1
42
+ }
43
+ logger.info(
44
+ `${drawn} sharing images in ${Math.round(performance.now() - started)} ms`,
45
+ )
46
+ },
47
+ },
48
+ }
49
+ }
@@ -0,0 +1,104 @@
1
+ import { readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { join, relative } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import {
5
+ buildUrlManifest,
6
+ hashContent,
7
+ serializeManifest,
8
+ } from '@iannuttall/seo-graph-core'
9
+ import type { AstroIntegration } from 'astro'
10
+ import { pagePathname } from '../lib/og/path'
11
+ import { absoluteUrl } from '../site'
12
+
13
+ export const SITEMAP_URL_LIMIT = 5_000
14
+
15
+ const escapeXml = (value: string) =>
16
+ value
17
+ .replaceAll('&', '&amp;')
18
+ .replaceAll('<', '&lt;')
19
+ .replaceAll('>', '&gt;')
20
+ .replaceAll('"', '&quot;')
21
+ .replaceAll("'", '&apos;')
22
+
23
+ const xml = (tag: string, entries: string[]) =>
24
+ `<?xml version="1.0" encoding="UTF-8"?>\n<${tag} xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join('\n')}\n</${tag}>\n`
25
+
26
+ // Writes a sitemap index with one file per section, split at 5,000 URLs,
27
+ // from the pages that were really built. No lastmod: a fake date is worse
28
+ // than none. Also writes indexnow.json, the page hashes that
29
+ // scripts/deploy.mjs compares to find the URLs that changed.
30
+ export default function sitemaps(): AstroIntegration {
31
+ return {
32
+ name: 'sitemaps',
33
+ hooks: {
34
+ 'astro:build:done': async ({ dir, logger }) => {
35
+ const out = fileURLToPath(dir)
36
+ const entries = await readdir(out, {
37
+ withFileTypes: true,
38
+ recursive: true,
39
+ })
40
+ const pages: Array<{ url: string; content: string }> = []
41
+ for (const entry of entries) {
42
+ if (!entry.isFile()) continue
43
+ if (/^sitemap.*\.xml$/.test(entry.name)) {
44
+ await rm(join(entry.parentPath, entry.name))
45
+ continue
46
+ }
47
+ if (!entry.name.endsWith('.html')) continue
48
+ const file = join(entry.parentPath, entry.name)
49
+ const path = pagePathname(relative(out, file))
50
+ if (path === '/404') continue
51
+ const html = await readFile(file, 'utf8')
52
+ if (/<meta[^>]+name="robots"[^>]+noindex/i.test(html)) continue
53
+ pages.push({ url: absoluteUrl(path), content: html })
54
+ }
55
+ pages.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0))
56
+
57
+ const groups = new Map<string, string[]>()
58
+ for (const { url } of pages) {
59
+ const parts = new URL(url).pathname.split('/').filter(Boolean)
60
+ const section = parts.length > 1 ? (parts[0] as string) : 'pages'
61
+ const group = groups.get(section)
62
+ if (group) group.push(url)
63
+ else groups.set(section, [url])
64
+ }
65
+ const files: string[] = []
66
+ for (const [section, urls] of [...groups].sort()) {
67
+ for (let at = 0; at < urls.length; at += SITEMAP_URL_LIMIT) {
68
+ const name = `sitemap-${section}-${at / SITEMAP_URL_LIMIT + 1}.xml`
69
+ const chunk = urls.slice(at, at + SITEMAP_URL_LIMIT)
70
+ await writeFile(
71
+ join(out, name),
72
+ xml(
73
+ 'urlset',
74
+ chunk.map((url) => `<url><loc>${escapeXml(url)}</loc></url>`),
75
+ ),
76
+ )
77
+ files.push(name)
78
+ }
79
+ }
80
+ await writeFile(
81
+ join(out, 'sitemap.xml'),
82
+ xml(
83
+ 'sitemapindex',
84
+ files.map(
85
+ (name) =>
86
+ `<sitemap><loc>${absoluteUrl(`/${name}`)}</loc></sitemap>`,
87
+ ),
88
+ ),
89
+ )
90
+ await writeFile(
91
+ join(out, 'indexnow.json'),
92
+ // Hash the page content only, so a new stylesheet hash does not
93
+ // mark every page as changed.
94
+ serializeManifest(
95
+ buildUrlManifest(pages, (html) =>
96
+ hashContent(/<main\b[\s\S]*<\/main>/i.exec(html)?.[0] ?? html),
97
+ ),
98
+ ),
99
+ )
100
+ logger.info(`${pages.length} URLs in ${files.length} sitemap files`)
101
+ },
102
+ },
103
+ }
104
+ }
@@ -0,0 +1,69 @@
1
+ ---
2
+ // The one root layout. It owns the title, description, canonical, sharing
3
+ // tags and JSON-LD for every page, so no page can forget one.
4
+ import '../styles/globals.css'
5
+ import Breadcrumbs from '../components/Breadcrumbs.astro'
6
+ import Footer from '../components/Footer.astro'
7
+ import Header from '../components/Header.astro'
8
+ import { ogImagePath } from '../lib/og/path'
9
+ import { type Breadcrumb, type Faq, pageGraph, serializeGraph } from '../lib/seo'
10
+ import { absoluteUrl, pageTitle, site } from '../site'
11
+
12
+ interface Props {
13
+ title: string
14
+ description: string
15
+ breadcrumbs?: Breadcrumb[]
16
+ faqs?: Faq[]
17
+ }
18
+
19
+ const { title, description, breadcrumbs = [], faqs } = Astro.props
20
+ // One URL form: `/`, `/about`, `/parks/alder-common`.
21
+ const path = Astro.url.pathname.replace(/(?:\/index)?(?:\.html)?\/?$/, '') || '/'
22
+ const canonical = absoluteUrl(path)
23
+ const ogImage = absoluteUrl(ogImagePath(path))
24
+ const crumbs = [{ name: 'Home', path: '/' }, ...breadcrumbs]
25
+ const graph = pageGraph({ name: title, description, path, breadcrumbs: crumbs, faqs })
26
+ const fullTitle = pageTitle(title)
27
+ ---
28
+
29
+ <!doctype html>
30
+ <html lang={site.language}>
31
+ <head>
32
+ <meta charset="utf-8" />
33
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
34
+ <title>{fullTitle}</title>
35
+ <meta name="description" content={description} />
36
+ <link rel="canonical" href={canonical} />
37
+ <meta name="color-scheme" content="light dark" />
38
+ {/* Start both font downloads before the stylesheet is parsed. */}
39
+ <link rel="preload" href="/fonts/InterLatinVF.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
40
+ <link rel="preload" href="/fonts/JetBrainsMonoLatinVF.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
41
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
42
+ <link rel="sitemap" href="/sitemap.xml" />
43
+ <meta property="og:site_name" content={site.name} />
44
+ <meta property="og:type" content="website" />
45
+ <meta property="og:title" content={title} />
46
+ <meta property="og:description" content={description} />
47
+ <meta property="og:url" content={canonical} />
48
+ <meta property="og:image" content={ogImage} />
49
+ <meta property="og:image:width" content="1200" />
50
+ <meta property="og:image:height" content="630" />
51
+ <meta name="twitter:card" content="summary_large_image" />
52
+ <meta name="twitter:image" content={ogImage} />
53
+ <script type="application/ld+json" set:html={serializeGraph(graph)} />
54
+ </head>
55
+ <body class="min-h-screen bg-background text-foreground antialiased">
56
+ <a
57
+ class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:rounded-lg focus:bg-muted focus:px-3 focus:py-2"
58
+ href="#main">Skip to content</a
59
+ >
60
+ <div class="flex min-h-screen flex-col">
61
+ <Header />
62
+ <main id="main" class="site-frame flex-1 py-10">
63
+ {breadcrumbs.length > 0 && <Breadcrumbs items={crumbs} />}
64
+ <slot />
65
+ </main>
66
+ <Footer />
67
+ </div>
68
+ </body>
69
+ </html>
@@ -0,0 +1,5 @@
1
+ // Joins class names and drops empty values. There is no class merging
2
+ // library, so later classes do not remove earlier ones.
3
+ export function cn(...values: Array<string | false | null | undefined>) {
4
+ return values.filter(Boolean).join(' ')
5
+ }
@@ -0,0 +1,115 @@
1
+ // Page prose built from the data. Every sentence states a fact from the
2
+ // dataset or a number worked out from it, and a sentence is left out when
3
+ // its fact is missing. Never add what the data cannot show, such as how
4
+ // busy a car park gets or how to travel there. Change the wording here,
5
+ // never page by page.
6
+ import {
7
+ facilities,
8
+ formatHectares,
9
+ listText,
10
+ type Park,
11
+ regionFor,
12
+ } from './parks'
13
+
14
+ // Ages are worked out from the year of the build. The site rebuilds when
15
+ // the data refreshes.
16
+ const YEAR = new Date().getFullYear()
17
+
18
+ export function answer(park: Park): string {
19
+ const has = facilities(park)
20
+ const offer = has.length
21
+ ? `It has ${listText(has)}`
22
+ : 'It has no toilets, cafe or playground'
23
+ return `${park.name} is a park in ${park.region} that covers ${formatHectares(park.areaHectares)} and opened in ${park.opened}. ${offer}, and ${dogsSentence(park)}.`
24
+ }
25
+
26
+ function dogsSentence(park: Park): string {
27
+ if (park.dogs === 'yes') return 'dogs are allowed'
28
+ if (park.dogs === 'on leads') return 'dogs must stay on a lead'
29
+ return 'dogs are not allowed'
30
+ }
31
+
32
+ export function story(park: Park): string[] {
33
+ const region = regionFor(park)
34
+ const others = region.parks.filter((entry) => entry.slug !== park.slug)
35
+ const sentences: string[] = []
36
+
37
+ const sizes = [...region.parks].sort(
38
+ (a, b) => b.areaHectares - a.areaHectares,
39
+ )
40
+ const rank = sizes.findIndex((entry) => entry.slug === park.slug) + 1
41
+ if (region.parks.length > 1) {
42
+ sentences.push(
43
+ rank === 1
44
+ ? `It is the largest of the ${region.parks.length} parks in ${region.name}.`
45
+ : rank === region.parks.length
46
+ ? `It is the smallest of the ${region.parks.length} parks in ${region.name}.`
47
+ : `It is number ${rank} of ${region.parks.length} by size in ${region.name}.`,
48
+ )
49
+ }
50
+
51
+ // Compare with the average of the other parks only at either end of the
52
+ // range, where the comparison says something a reader can use.
53
+ const average =
54
+ others.reduce((sum, entry) => sum + entry.areaHectares, 0) /
55
+ Math.max(1, others.length)
56
+ const ratio = average > 0 ? park.areaHectares / average : 1
57
+ const other =
58
+ others.length === 1 ? 'the other park there' : 'an average park there'
59
+ if (others.length && rank === 1 && ratio >= 1.5) {
60
+ sentences.push(
61
+ `That makes it about ${Math.round(ratio)} times the size of ${other}.`,
62
+ )
63
+ } else if (others.length && rank === region.parks.length && ratio <= 0.67) {
64
+ sentences.push(
65
+ `${other.charAt(0).toUpperCase()}${other.slice(1)} is about ${Math.round(1 / ratio)} times its size.`,
66
+ )
67
+ }
68
+
69
+ const age = YEAR - park.opened
70
+ if (age >= 100) {
71
+ sentences.push(`The park opened ${age} years ago, in ${park.opened}.`)
72
+ }
73
+
74
+ if (park.parkingSpaces === null) {
75
+ sentences.push('The register does not list parking for this park.')
76
+ } else if (park.parkingSpaces === 0) {
77
+ sentences.push('The register lists no car park for this park.')
78
+ } else {
79
+ sentences.push(`The car park has ${park.parkingSpaces} spaces.`)
80
+ }
81
+
82
+ return sentences
83
+ }
84
+
85
+ export function faqs(park: Park): Array<{ question: string; answer: string }> {
86
+ const list = [
87
+ {
88
+ question: `Are dogs allowed in ${park.name}?`,
89
+ answer: `${capitalise(dogsSentence(park))} in ${park.name}.`,
90
+ },
91
+ {
92
+ question: `Does ${park.name} have toilets?`,
93
+ answer: park.toilets
94
+ ? `Yes, ${park.name} has public toilets.`
95
+ : `No, the register lists no public toilets at ${park.name}.`,
96
+ },
97
+ {
98
+ question: `How big is ${park.name}?`,
99
+ answer: `${park.name} covers ${formatHectares(park.areaHectares)}.`,
100
+ },
101
+ ]
102
+ if (park.parkingSpaces !== null) {
103
+ list.push({
104
+ question: `Can you park at ${park.name}?`,
105
+ answer: park.parkingSpaces
106
+ ? `Yes, the car park has ${park.parkingSpaces} spaces.`
107
+ : `No, ${park.name} has no car park.`,
108
+ })
109
+ }
110
+ return list
111
+ }
112
+
113
+ function capitalise(text: string): string {
114
+ return text.charAt(0).toUpperCase() + text.slice(1)
115
+ }
@@ -0,0 +1,85 @@
1
+ // The sharing image for one page, as HTML for Takumi: the page H1 on a
2
+ // plain background, with the site name above and the domain below.
3
+ export const OG_WIDTH = 1200
4
+ export const OG_HEIGHT = 630
5
+
6
+ const ENTITIES: Record<string, string> = {
7
+ amp: '&',
8
+ lt: '<',
9
+ gt: '>',
10
+ quot: '"',
11
+ apos: "'",
12
+ nbsp: ' ',
13
+ rsquo: '’',
14
+ lsquo: '‘',
15
+ rdquo: '”',
16
+ ldquo: '“',
17
+ hellip: '…',
18
+ }
19
+
20
+ function decode(text: string): string {
21
+ return text.replace(
22
+ /&(#x[0-9a-f]+|#\d+|[a-z]+);/gi,
23
+ (match, code: string) => {
24
+ if (code.startsWith('#')) {
25
+ const hex = code[1]?.toLowerCase() === 'x'
26
+ return String.fromCodePoint(
27
+ hex ? Number.parseInt(code.slice(2), 16) : Number(code.slice(1)),
28
+ )
29
+ }
30
+ return ENTITIES[code.toLowerCase()] ?? match
31
+ },
32
+ )
33
+ }
34
+
35
+ const escapeHtml = (text: string) =>
36
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
37
+
38
+ // The first H1 of a built page as plain text. Null when there is none.
39
+ export function readH1(html: string): string | null {
40
+ const match = /<h1\b[^>]*>([\s\S]*?)<\/h1>/i.exec(html)
41
+ if (!match?.[1]) return null
42
+ const text = decode(match[1].replace(/<[^>]+>/g, ''))
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+ return text || null
46
+ }
47
+
48
+ // The site's light theme from src/styles/globals.css, in hex for the
49
+ // renderer, and the same mark as src/components/Logo.astro. Change them
50
+ // here when the theme changes.
51
+ const colors = {
52
+ surface: '#fafafa',
53
+ text: '#171717',
54
+ faint: '#808080',
55
+ primary: '#0092b8',
56
+ }
57
+
58
+ export const MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="${colors.text}"><path d="M16 3c-4 5-7 9-7 13a7 7 0 0 0 6 6.9V29h2v-6.1A7 7 0 0 0 23 16c0-4-3-8-7-13z"/></svg>`
59
+
60
+ // Longer headings step down so three lines always fit.
61
+ function titleSize(length: number): number {
62
+ if (length > 60) return 60
63
+ if (length > 36) return 70
64
+ return 80
65
+ }
66
+
67
+ // The page's own path, cut to one line.
68
+ function cardPath(path: string): string {
69
+ if (path === '/') return ''
70
+ return path.length > 48 ? `${path.slice(0, 47)}\u2026` : path
71
+ }
72
+
73
+ // The mark at the top, the page H1 in the middle and the address at the
74
+ // bottom.
75
+ export function ogCardHtml(input: {
76
+ title: string
77
+ domain: string
78
+ path: string
79
+ }): string {
80
+ return `<div style="width:${OG_WIDTH}px;height:${OG_HEIGHT}px;display:flex;flex-direction:column;justify-content:space-between;padding:72px;background-color:${colors.surface};color:${colors.text};font-family:Inter">
81
+ <img src="mark.svg" width="64" height="64" alt="" />
82
+ <h1 style="margin:0;max-width:1000px;font-size:${titleSize(input.title.length)}px;font-weight:600;line-height:1.08;letter-spacing:-2.4px;text-wrap:balance;line-clamp:3">${escapeHtml(input.title)}</h1>
83
+ <div style="display:flex;font-family:JetBrains Mono;font-size:26px;color:${colors.faint}"><span style="color:${colors.primary};font-weight:700">${escapeHtml(input.domain)}</span>${escapeHtml(cardPath(input.path))}</div>
84
+ </div>`
85
+ }
@@ -0,0 +1,14 @@
1
+ // Where a page's sharing image lives: `/` is `/og/index.png` and
2
+ // `/parks/alder-common` is `/og/parks/alder-common.png`.
3
+ export function ogImagePath(pathname: string): string {
4
+ const slug = pathname.replace(/^\/+|\/+$/g, '').replace(/\.html$/, '')
5
+ return `/og/${slug || 'index'}.png`
6
+ }
7
+
8
+ // The URL path a built file serves: `index.html` is `/`, `parks/a.html` is
9
+ // `/parks/a`.
10
+ export function pagePathname(htmlFile: string): string {
11
+ const file = htmlFile.replace(/\\/g, '/')
12
+ const path = `/${file}`.replace(/(?:\/index)?\.html$/, '')
13
+ return path || '/'
14
+ }
@@ -0,0 +1,30 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { render } from 'takumi-js'
3
+ import { Renderer } from 'takumi-js/node'
4
+ import { MARK_SVG, OG_HEIGHT, OG_WIDTH, ogCardHtml } from './card'
5
+
6
+ const fonts = new URL('../../../public/fonts/', import.meta.url)
7
+
8
+ // One Takumi renderer with the page fonts loaded once and reused for every
9
+ // card. Nothing is fetched over the network.
10
+ export async function ogRenderer(site: {
11
+ domain: string
12
+ }): Promise<(title: string, path: string) => Promise<Uint8Array>> {
13
+ const renderer = new Renderer()
14
+ await renderer.registerFont({
15
+ name: 'Inter',
16
+ data: await readFile(new URL('InterLatinVF.woff2', fonts)),
17
+ })
18
+ await renderer.registerFont({
19
+ name: 'JetBrains Mono',
20
+ data: await readFile(new URL('JetBrainsMonoLatinVF.woff2', fonts)),
21
+ })
22
+ const images = [{ src: 'mark.svg', data: Buffer.from(MARK_SVG) }]
23
+ return (title, path) =>
24
+ render(ogCardHtml({ title, domain: site.domain, path }), {
25
+ renderer,
26
+ images,
27
+ width: OG_WIDTH,
28
+ height: OG_HEIGHT,
29
+ })
30
+ }