seo 0.13.3 → 0.14.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.
- package/PRIVACY.md +9 -1
- package/dist/cli.js +84 -80
- package/dist/cli.js.map +1 -1
- package/dist/{dist-BbTqJIE_.js → dist-4Yam8ps7.js} +1 -1
- package/dist/dist-B_2zww72.js +427 -0
- package/dist/dist-B_2zww72.js.map +1 -0
- package/dist/{esm-hHlvm1W0.js → esm-BmuxgZyN.js} +95 -188
- package/dist/{esm-hHlvm1W0.js.map → esm-BmuxgZyN.js.map} +1 -1
- package/dist/index.d.ts +73 -406
- package/dist/index.js +1448 -564
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +0 -14
- package/dist/mcp.js +1020 -807
- package/dist/mcp.js.map +1 -1
- package/dist/provider-sdk.d.ts +0 -2
- package/dist/provider-sdk.js +1 -2
- package/package.json +6 -5
- package/site-kit/README.md +13 -0
- package/site-kit/guide/build.md +70 -0
- package/site-kit/guide/data.md +49 -0
- package/site-kit/guide/golden-page.md +50 -0
- package/site-kit/guide/grow.md +58 -0
- package/site-kit/guide/launch.md +55 -0
- package/site-kit/guide/market.md +57 -0
- package/site-kit/guide/plan.md +49 -0
- package/site-kit/guide/scale.md +41 -0
- package/site-kit/guide/setup.md +59 -0
- package/site-kit/guide/writing.md +55 -0
- package/site-kit/starter/.github/workflows/refresh-data.yml +88 -0
- package/site-kit/starter/.node-version +1 -0
- package/site-kit/starter/AGENTS.md +65 -0
- package/site-kit/starter/README.md +11 -0
- package/site-kit/starter/astro.config.mjs +31 -0
- package/site-kit/starter/gitignore +7 -0
- package/site-kit/starter/llms.config.mjs +38 -0
- package/site-kit/starter/package.json +31 -0
- package/site-kit/starter/pnpm-workspace.yaml +5 -0
- package/site-kit/starter/public/_headers +5 -0
- package/site-kit/starter/public/favicon.svg +1 -0
- package/site-kit/starter/public/fonts/InterLatinVF.woff2 +0 -0
- package/site-kit/starter/public/fonts/JetBrainsMonoLatinVF.woff2 +0 -0
- package/site-kit/starter/public/fonts/LICENSE-inter.txt +93 -0
- package/site-kit/starter/public/fonts/LICENSE-jetbrains-mono.txt +93 -0
- package/site-kit/starter/scripts/deploy.mjs +60 -0
- package/site-kit/starter/scripts/markdown-rewrites.mjs +33 -0
- package/site-kit/starter/scripts/refresh-data.mjs +60 -0
- package/site-kit/starter/scripts/validate-data.mjs +47 -0
- package/site-kit/starter/site.config.json +9 -0
- package/site-kit/starter/src/components/Breadcrumbs.astro +27 -0
- package/site-kit/starter/src/components/Button.astro +46 -0
- package/site-kit/starter/src/components/Card.astro +54 -0
- package/site-kit/starter/src/components/CodeBlock.astro +62 -0
- package/site-kit/starter/src/components/FactTable.astro +45 -0
- package/site-kit/starter/src/components/FaqAccordion.astro +143 -0
- package/site-kit/starter/src/components/Footer.astro +34 -0
- package/site-kit/starter/src/components/FormInput.astro +18 -0
- package/site-kit/starter/src/components/FormSelect.astro +34 -0
- package/site-kit/starter/src/components/Header.astro +17 -0
- package/site-kit/starter/src/components/HugeIcon.astro +36 -0
- package/site-kit/starter/src/components/Logo.astro +21 -0
- package/site-kit/starter/src/components/PageHeader.astro +33 -0
- package/site-kit/starter/src/components/SectionHeading.astro +18 -0
- package/site-kit/starter/src/components/ui/button.ts +51 -0
- package/site-kit/starter/src/data/parks.json +106 -0
- package/site-kit/starter/src/data/sources.json +11 -0
- package/site-kit/starter/src/integrations/og-images.ts +49 -0
- package/site-kit/starter/src/integrations/sitemaps.ts +104 -0
- package/site-kit/starter/src/layouts/Base.astro +69 -0
- package/site-kit/starter/src/lib/cn.ts +5 -0
- package/site-kit/starter/src/lib/narrative.ts +115 -0
- package/site-kit/starter/src/lib/og/card.ts +85 -0
- package/site-kit/starter/src/lib/og/path.ts +14 -0
- package/site-kit/starter/src/lib/og/render.ts +30 -0
- package/site-kit/starter/src/lib/parks.ts +84 -0
- package/site-kit/starter/src/lib/seo.ts +97 -0
- package/site-kit/starter/src/pages/404.astro +30 -0
- package/site-kit/starter/src/pages/about.astro +39 -0
- package/site-kit/starter/src/pages/index.astro +52 -0
- package/site-kit/starter/src/pages/parks/[slug].astro +95 -0
- package/site-kit/starter/src/pages/regions/[region].astro +58 -0
- package/site-kit/starter/src/pages/robots.txt.ts +16 -0
- package/site-kit/starter/src/site.ts +29 -0
- package/site-kit/starter/src/styles/globals.css +561 -0
- package/site-kit/starter/tsconfig.json +5 -0
- package/site-kit/starter/wrangler.jsonc +16 -0
- package/skills/seo/SKILL.md +19 -1
- package/dist/dist-DBI-BioS.js +0 -420
- 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,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('&', '&')
|
|
18
|
+
.replaceAll('<', '<')
|
|
19
|
+
.replaceAll('>', '>')
|
|
20
|
+
.replaceAll('"', '"')
|
|
21
|
+
.replaceAll("'", ''')
|
|
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,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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|