seo 0.13.2 → 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 +37 -8
- package/dist/cli.js +84 -80
- package/dist/cli.js.map +1 -1
- package/dist/{dist-BBszRLUG.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-Dmtr8qrP.js → esm-BmuxgZyN.js} +95 -188
- package/dist/{esm-Dmtr8qrP.js.map → esm-BmuxgZyN.js.map} +1 -1
- package/dist/index.d.ts +92 -406
- package/dist/index.js +1558 -646
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +0 -14
- package/dist/mcp.js +1130 -889
- 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-7TxFytBp.js +0 -420
- package/dist/dist-7TxFytBp.js.map +0 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import parksData from '../data/parks.json'
|
|
2
|
+
import sourcesData from '../data/sources.json'
|
|
3
|
+
|
|
4
|
+
export type Park = {
|
|
5
|
+
slug: string
|
|
6
|
+
name: string
|
|
7
|
+
region: string
|
|
8
|
+
areaHectares: number
|
|
9
|
+
opened: number
|
|
10
|
+
toilets: boolean
|
|
11
|
+
cafe: boolean
|
|
12
|
+
playground: boolean
|
|
13
|
+
dogs: 'yes' | 'on leads' | 'no'
|
|
14
|
+
// null means the register does not say. 0 means no parking.
|
|
15
|
+
parkingSpaces: number | null
|
|
16
|
+
source: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Source = {
|
|
20
|
+
id: string
|
|
21
|
+
name: string
|
|
22
|
+
url: string
|
|
23
|
+
checked: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const parks = (parksData as Park[])
|
|
27
|
+
.slice()
|
|
28
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
29
|
+
const sources = sourcesData as Source[]
|
|
30
|
+
|
|
31
|
+
export function slugify(text: string): string {
|
|
32
|
+
return text
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
35
|
+
.replace(/^-|-$/g, '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sourceFor(park: Park): Source {
|
|
39
|
+
const source = sources.find((entry) => entry.id === park.source)
|
|
40
|
+
if (!source) throw new Error(`${park.slug}: unknown source ${park.source}`)
|
|
41
|
+
return source
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type Region = { name: string; slug: string; parks: Park[] }
|
|
45
|
+
|
|
46
|
+
export const regions: Region[] = [...new Set(parks.map((park) => park.region))]
|
|
47
|
+
.sort((a, b) => a.localeCompare(b))
|
|
48
|
+
.map((name) => ({
|
|
49
|
+
name,
|
|
50
|
+
slug: slugify(name),
|
|
51
|
+
parks: parks.filter((park) => park.region === name),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
export function regionFor(park: Park): Region {
|
|
55
|
+
const region = regions.find((entry) => entry.name === park.region)
|
|
56
|
+
if (!region) throw new Error(`${park.slug}: unknown region`)
|
|
57
|
+
return region
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatHectares(value: number): string {
|
|
61
|
+
return `${value.toLocaleString('en-GB', { maximumFractionDigits: 1 })} hectares`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatDate(iso: string): string {
|
|
65
|
+
return new Date(`${iso}T00:00:00Z`).toLocaleDateString('en-GB', {
|
|
66
|
+
day: 'numeric',
|
|
67
|
+
month: 'long',
|
|
68
|
+
year: 'numeric',
|
|
69
|
+
timeZone: 'UTC',
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function facilities(park: Park): string[] {
|
|
74
|
+
return [
|
|
75
|
+
park.toilets ? 'toilets' : '',
|
|
76
|
+
park.cafe ? 'a cafe' : '',
|
|
77
|
+
park.playground ? 'a playground' : '',
|
|
78
|
+
].filter(Boolean)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function listText(items: string[]): string {
|
|
82
|
+
if (items.length <= 1) return items.join('')
|
|
83
|
+
return `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`
|
|
84
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assembleGraph,
|
|
3
|
+
buildBreadcrumbList,
|
|
4
|
+
buildPiece,
|
|
5
|
+
buildWebPage,
|
|
6
|
+
buildWebSite,
|
|
7
|
+
type GraphEntity,
|
|
8
|
+
makeIds,
|
|
9
|
+
} from '@iannuttall/seo-graph-core'
|
|
10
|
+
import { absoluteUrl, site } from '../site'
|
|
11
|
+
|
|
12
|
+
export type Breadcrumb = { name: string; path: string }
|
|
13
|
+
export type Faq = { question: string; answer: string }
|
|
14
|
+
|
|
15
|
+
const ids = makeIds({ siteUrl: site.url })
|
|
16
|
+
const organisationId = ids.organization('site')
|
|
17
|
+
|
|
18
|
+
// One JSON-LD graph per page. Mark up only what the page shows.
|
|
19
|
+
export function pageGraph(input: {
|
|
20
|
+
name: string
|
|
21
|
+
description: string
|
|
22
|
+
path: string
|
|
23
|
+
breadcrumbs: Breadcrumb[]
|
|
24
|
+
faqs?: Faq[]
|
|
25
|
+
}) {
|
|
26
|
+
const url = absoluteUrl(input.path)
|
|
27
|
+
const faqId = input.faqs?.length ? `${url}#faq` : null
|
|
28
|
+
// A breadcrumb trail needs two steps. The home page has none.
|
|
29
|
+
const crumbs = input.breadcrumbs.length > 1
|
|
30
|
+
const pieces: GraphEntity[] = [
|
|
31
|
+
buildPiece({
|
|
32
|
+
'@type': 'Organization',
|
|
33
|
+
'@id': organisationId,
|
|
34
|
+
name: site.name,
|
|
35
|
+
url: absoluteUrl('/'),
|
|
36
|
+
}),
|
|
37
|
+
buildWebSite(
|
|
38
|
+
{
|
|
39
|
+
url: absoluteUrl('/'),
|
|
40
|
+
name: site.name,
|
|
41
|
+
description: site.description,
|
|
42
|
+
publisher: { '@id': organisationId },
|
|
43
|
+
inLanguage: site.language,
|
|
44
|
+
},
|
|
45
|
+
ids,
|
|
46
|
+
),
|
|
47
|
+
buildWebPage(
|
|
48
|
+
{
|
|
49
|
+
url,
|
|
50
|
+
name: input.name,
|
|
51
|
+
description: input.description,
|
|
52
|
+
isPartOf: { '@id': ids.website },
|
|
53
|
+
...(crumbs ? { breadcrumb: { '@id': ids.breadcrumb(url) } } : {}),
|
|
54
|
+
inLanguage: site.language,
|
|
55
|
+
...(faqId ? { mainEntity: [{ '@id': faqId }] } : {}),
|
|
56
|
+
},
|
|
57
|
+
ids,
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
if (crumbs) {
|
|
61
|
+
pieces.push(
|
|
62
|
+
buildBreadcrumbList(
|
|
63
|
+
{
|
|
64
|
+
url,
|
|
65
|
+
items: input.breadcrumbs.map((item) => ({
|
|
66
|
+
name: item.name,
|
|
67
|
+
url: absoluteUrl(item.path),
|
|
68
|
+
})),
|
|
69
|
+
},
|
|
70
|
+
ids,
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
if (faqId && input.faqs) {
|
|
75
|
+
pieces.push(
|
|
76
|
+
buildPiece({
|
|
77
|
+
'@type': 'FAQPage',
|
|
78
|
+
'@id': faqId,
|
|
79
|
+
isPartOf: { '@id': ids.webPage(url) },
|
|
80
|
+
mainEntity: input.faqs.map((faq) => ({
|
|
81
|
+
'@type': 'Question',
|
|
82
|
+
name: faq.question,
|
|
83
|
+
acceptedAnswer: { '@type': 'Answer', text: faq.answer },
|
|
84
|
+
})),
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
return assembleGraph(pieces)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Safe inside an inline script element.
|
|
92
|
+
export function serializeGraph(graph: unknown): string {
|
|
93
|
+
return JSON.stringify(graph)
|
|
94
|
+
.replace(/</g, '\\u003c')
|
|
95
|
+
.replace(/\u2028/g, '\\u2028')
|
|
96
|
+
.replace(/\u2029/g, '\\u2029')
|
|
97
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../styles/globals.css'
|
|
3
|
+
import Button from '../components/Button.astro'
|
|
4
|
+
import Footer from '../components/Footer.astro'
|
|
5
|
+
import Header from '../components/Header.astro'
|
|
6
|
+
import PageHeader from '../components/PageHeader.astro'
|
|
7
|
+
import { site } from '../site'
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<!doctype html>
|
|
11
|
+
<html lang={site.language}>
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="utf-8" />
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
15
|
+
<title>Page not found | {site.name}</title>
|
|
16
|
+
<meta name="robots" content="noindex" />
|
|
17
|
+
<meta name="color-scheme" content="light dark" />
|
|
18
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
19
|
+
</head>
|
|
20
|
+
<body class="min-h-screen bg-background text-foreground antialiased">
|
|
21
|
+
<div class="flex min-h-screen flex-col">
|
|
22
|
+
<Header />
|
|
23
|
+
<main id="main" class="site-frame flex-1 py-10">
|
|
24
|
+
<PageHeader title="Page not found" description="This page does not exist. Every park is listed on the home page." />
|
|
25
|
+
<Button href="/">Find a park</Button>
|
|
26
|
+
</main>
|
|
27
|
+
<Footer />
|
|
28
|
+
</div>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The methodology page. Say where each number comes from and how it is
|
|
3
|
+
// worked out, so people can trust and cite the site.
|
|
4
|
+
import sources from '../data/sources.json'
|
|
5
|
+
import PageHeader from '../components/PageHeader.astro'
|
|
6
|
+
import Base from '../layouts/Base.astro'
|
|
7
|
+
import { formatDate } from '../lib/parks'
|
|
8
|
+
import { site } from '../site'
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<Base
|
|
12
|
+
title="Where the data comes from"
|
|
13
|
+
description={`The sources behind ${site.name}, when each one was last checked, and how the comparisons on every park page are worked out.`}
|
|
14
|
+
breadcrumbs={[{ name: 'About the data', path: '/about' }]}
|
|
15
|
+
>
|
|
16
|
+
<PageHeader
|
|
17
|
+
title="Where the data comes from"
|
|
18
|
+
description={`Every fact on ${site.name} comes from a public register. Each page shows the source and the date it was last checked, and the site is rebuilt when a source changes.`}
|
|
19
|
+
/>
|
|
20
|
+
|
|
21
|
+
<article class="site-prose">
|
|
22
|
+
<h2>Sources</h2>
|
|
23
|
+
<ul>
|
|
24
|
+
{
|
|
25
|
+
sources.map((source) => (
|
|
26
|
+
<li>
|
|
27
|
+
<a href={source.url} rel="noreferrer" target="_blank">{source.name}</a>, checked {formatDate(source.checked)}
|
|
28
|
+
</li>
|
|
29
|
+
))
|
|
30
|
+
}
|
|
31
|
+
</ul>
|
|
32
|
+
<h2>How the comparisons are worked out</h2>
|
|
33
|
+
<p>
|
|
34
|
+
Sizes are compared with the other parks in the same region. A park is called larger or smaller only when the
|
|
35
|
+
difference is at least half again, and the page leaves the comparison out when the gap is smaller.
|
|
36
|
+
</p>
|
|
37
|
+
<p>When the register does not list a fact, such as parking, the page says so and never guesses a value.</p>
|
|
38
|
+
</article>
|
|
39
|
+
</Base>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Location01Icon, Tree02Icon } from '@hugeicons/core-free-icons'
|
|
3
|
+
import Card from '../components/Card.astro'
|
|
4
|
+
import PageHeader from '../components/PageHeader.astro'
|
|
5
|
+
import SectionHeading from '../components/SectionHeading.astro'
|
|
6
|
+
import Base from '../layouts/Base.astro'
|
|
7
|
+
import { formatHectares, parks, regions } from '../lib/parks'
|
|
8
|
+
import { site } from '../site'
|
|
9
|
+
|
|
10
|
+
const largest = [...parks].sort((a, b) => b.areaHectares - a.areaHectares).slice(0, 4)
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<Base title={site.name} description={site.description}>
|
|
14
|
+
<PageHeader
|
|
15
|
+
title="Parks in the example parks register"
|
|
16
|
+
description={`The register lists ${parks.length} parks across ${regions.length} regions. Each page gives the size, the year the park opened, its toilets, cafe and playground, the dog rules and the car park, with the source and the date it was checked.`}
|
|
17
|
+
/>
|
|
18
|
+
|
|
19
|
+
<section class="mt-16" aria-labelledby="regions">
|
|
20
|
+
<SectionHeading id="regions" title="Find a park by region" text="Every region has its own page with all of its parks side by side." />
|
|
21
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
22
|
+
{
|
|
23
|
+
regions.map((region) => (
|
|
24
|
+
<Card
|
|
25
|
+
icon={Location01Icon}
|
|
26
|
+
title={region.name}
|
|
27
|
+
text={`${region.parks.length} parks, ${formatHectares(region.parks.reduce((sum, park) => sum + park.areaHectares, 0))} in total.`}
|
|
28
|
+
href={`/regions/${region.slug}`}
|
|
29
|
+
link={`Parks in ${region.name}`}
|
|
30
|
+
/>
|
|
31
|
+
))
|
|
32
|
+
}
|
|
33
|
+
</div>
|
|
34
|
+
</section>
|
|
35
|
+
|
|
36
|
+
<section class="mt-16" aria-labelledby="largest">
|
|
37
|
+
<SectionHeading id="largest" title="The largest parks" text="The biggest parks in the register by area." />
|
|
38
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
39
|
+
{
|
|
40
|
+
largest.map((park) => (
|
|
41
|
+
<Card
|
|
42
|
+
icon={Tree02Icon}
|
|
43
|
+
title={park.name}
|
|
44
|
+
text={`${formatHectares(park.areaHectares)} in ${park.region}, opened in ${park.opened}.`}
|
|
45
|
+
href={`/parks/${park.slug}`}
|
|
46
|
+
link={`${park.name} facts`}
|
|
47
|
+
/>
|
|
48
|
+
))
|
|
49
|
+
}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
</Base>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The golden page. Every park page comes from this one template, and each
|
|
3
|
+
// block shows only when the data has something to say.
|
|
4
|
+
import { Tree02Icon } from '@hugeicons/core-free-icons'
|
|
5
|
+
import Card from '../../components/Card.astro'
|
|
6
|
+
import FactTable from '../../components/FactTable.astro'
|
|
7
|
+
import FaqAccordion from '../../components/FaqAccordion.astro'
|
|
8
|
+
import PageHeader from '../../components/PageHeader.astro'
|
|
9
|
+
import Base from '../../layouts/Base.astro'
|
|
10
|
+
import { answer, faqs, story } from '../../lib/narrative'
|
|
11
|
+
import { formatHectares, type Park, parks, regionFor, sourceFor } from '../../lib/parks'
|
|
12
|
+
|
|
13
|
+
export function getStaticPaths() {
|
|
14
|
+
return parks.map((park) => ({ params: { slug: park.slug }, props: { park } }))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { park } = Astro.props as { park: Park }
|
|
18
|
+
const region = regionFor(park)
|
|
19
|
+
const source = sourceFor(park)
|
|
20
|
+
const questions = faqs(park)
|
|
21
|
+
const nearby = region.parks.filter((entry) => entry.slug !== park.slug).slice(0, 4)
|
|
22
|
+
const yesNo = (value: boolean) => (value ? 'Yes' : 'No')
|
|
23
|
+
const rows = [
|
|
24
|
+
{ label: 'Size', value: formatHectares(park.areaHectares) },
|
|
25
|
+
{ label: 'Opened', value: String(park.opened) },
|
|
26
|
+
{ label: 'Toilets', value: yesNo(park.toilets) },
|
|
27
|
+
{ label: 'Cafe', value: yesNo(park.cafe) },
|
|
28
|
+
{ label: 'Playground', value: yesNo(park.playground) },
|
|
29
|
+
{
|
|
30
|
+
label: 'Dogs',
|
|
31
|
+
value: park.dogs === 'yes' ? 'Allowed' : park.dogs === 'on leads' ? 'On leads' : 'Not allowed',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: 'Car park',
|
|
35
|
+
value:
|
|
36
|
+
park.parkingSpaces === null
|
|
37
|
+
? 'Not listed'
|
|
38
|
+
: park.parkingSpaces === 0
|
|
39
|
+
? 'None'
|
|
40
|
+
: `${park.parkingSpaces} spaces`,
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
const title = `${park.name}, ${park.region}`
|
|
44
|
+
const description = `Size, opening year, toilets, parking and dog rules for ${park.name} in ${park.region}, from the parks register with the date it was checked.`
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<Base
|
|
48
|
+
title={title}
|
|
49
|
+
description={description}
|
|
50
|
+
breadcrumbs={[
|
|
51
|
+
{ name: region.name, path: `/regions/${region.slug}` },
|
|
52
|
+
{ name: park.name, path: `/parks/${park.slug}` },
|
|
53
|
+
]}
|
|
54
|
+
faqs={questions}
|
|
55
|
+
>
|
|
56
|
+
<PageHeader title={park.name} description={answer(park)} />
|
|
57
|
+
|
|
58
|
+
<FactTable heading={`${park.name} at a glance`} rows={rows} source={source} />
|
|
59
|
+
|
|
60
|
+
<article class="site-prose mt-12">
|
|
61
|
+
<h2>How {park.name} compares</h2>
|
|
62
|
+
{story(park).map((sentence) => <p>{sentence}</p>)}
|
|
63
|
+
</article>
|
|
64
|
+
|
|
65
|
+
<section class="mt-12" aria-labelledby="visiting">
|
|
66
|
+
<h2 id="visiting" class="mb-4 text-xl/7 font-medium text-foreground">Visiting {park.name}</h2>
|
|
67
|
+
<FaqAccordion items={questions} />
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
nearby.length > 0 && (
|
|
72
|
+
<section class="mt-12" aria-labelledby="nearby">
|
|
73
|
+
<h2 id="nearby" class="mb-4 text-xl/7 font-medium text-foreground">Other parks in {region.name}</h2>
|
|
74
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
75
|
+
{nearby.map((entry) => (
|
|
76
|
+
<Card
|
|
77
|
+
icon={Tree02Icon}
|
|
78
|
+
title={entry.name}
|
|
79
|
+
text={`${formatHectares(entry.areaHectares)}, opened in ${entry.opened}.`}
|
|
80
|
+
href={`/parks/${entry.slug}`}
|
|
81
|
+
link={`${entry.name} facts`}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
<p class="mt-4 text-base text-foreground-muted">
|
|
86
|
+
See{' '}
|
|
87
|
+
<a class="text-primary underline underline-offset-4 hover:text-primary-hover" href={`/regions/${region.slug}`}>
|
|
88
|
+
every park in {region.name}
|
|
89
|
+
</a>
|
|
90
|
+
.
|
|
91
|
+
</p>
|
|
92
|
+
</section>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
</Base>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A hub page: a real page with its own data, linking to every child.
|
|
3
|
+
import PageHeader from '../../components/PageHeader.astro'
|
|
4
|
+
import Base from '../../layouts/Base.astro'
|
|
5
|
+
import { facilities, formatHectares, listText, type Region, regions } from '../../lib/parks'
|
|
6
|
+
|
|
7
|
+
export function getStaticPaths() {
|
|
8
|
+
return regions.map((region) => ({ params: { region: region.slug }, props: { region } }))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { region } = Astro.props as { region: Region }
|
|
12
|
+
const total = region.parks.reduce((sum, park) => sum + park.areaHectares, 0)
|
|
13
|
+
const largest = [...region.parks].sort((a, b) => b.areaHectares - a.areaHectares)[0]
|
|
14
|
+
const withToilets = region.parks.filter((park) => park.toilets).length
|
|
15
|
+
const title = `Parks in ${region.name}`
|
|
16
|
+
const description = `The ${region.parks.length} parks in ${region.name}, with their size, opening year, facilities and dog rules, from the parks register.`
|
|
17
|
+
const lead = [
|
|
18
|
+
`${region.name} has ${region.parks.length} parks covering ${formatHectares(total)} in total.`,
|
|
19
|
+
largest ? `The largest is ${largest.name} at ${formatHectares(largest.areaHectares)}.` : '',
|
|
20
|
+
`${withToilets} of them ${withToilets === 1 ? 'has' : 'have'} public toilets.`,
|
|
21
|
+
]
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.join(' ')
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<Base title={title} description={description} breadcrumbs={[{ name: region.name, path: `/regions/${region.slug}` }]}>
|
|
27
|
+
<PageHeader title={title} description={lead} />
|
|
28
|
+
|
|
29
|
+
<article class="site-prose">
|
|
30
|
+
<h2>Every park in {region.name}</h2>
|
|
31
|
+
<div class="table-frame">
|
|
32
|
+
<table>
|
|
33
|
+
<thead>
|
|
34
|
+
<tr>
|
|
35
|
+
<th scope="col">Park</th>
|
|
36
|
+
<th scope="col">Size</th>
|
|
37
|
+
<th scope="col">Opened</th>
|
|
38
|
+
<th scope="col">Facilities</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody>
|
|
42
|
+
{
|
|
43
|
+
region.parks.map((park) => (
|
|
44
|
+
<tr>
|
|
45
|
+
<td>
|
|
46
|
+
<a href={`/parks/${park.slug}`}>{park.name}</a>
|
|
47
|
+
</td>
|
|
48
|
+
<td>{formatHectares(park.areaHectares)}</td>
|
|
49
|
+
<td>{park.opened}</td>
|
|
50
|
+
<td>{listText(facilities(park)) || 'None listed'}</td>
|
|
51
|
+
</tr>
|
|
52
|
+
))
|
|
53
|
+
}
|
|
54
|
+
</tbody>
|
|
55
|
+
</table>
|
|
56
|
+
</div>
|
|
57
|
+
</article>
|
|
58
|
+
</Base>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { absoluteUrl, site } from '../site'
|
|
2
|
+
|
|
3
|
+
// Open to every crawler. Content-Signal says what the content may be used
|
|
4
|
+
// for: search and AI answers always, AI training only when the site owner
|
|
5
|
+
// chose it in site.config.json.
|
|
6
|
+
export function GET() {
|
|
7
|
+
const body = [
|
|
8
|
+
'User-agent: *',
|
|
9
|
+
'Allow: /',
|
|
10
|
+
`Content-Signal: search=yes, ai-input=yes, ai-train=${site.aiTraining ? 'yes' : 'no'}`,
|
|
11
|
+
'',
|
|
12
|
+
`Sitemap: ${absoluteUrl('/sitemap.xml')}`,
|
|
13
|
+
'',
|
|
14
|
+
].join('\n')
|
|
15
|
+
return new Response(body, { headers: { 'Content-Type': 'text/plain' } })
|
|
16
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import config from '../site.config.json'
|
|
2
|
+
|
|
3
|
+
export const site = {
|
|
4
|
+
...config,
|
|
5
|
+
url: `https://${config.domain}`,
|
|
6
|
+
footerNote:
|
|
7
|
+
'Facts from public records, each with its source and the date it was checked.',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The header and footer links. Every page they name must exist.
|
|
11
|
+
export const nav = [
|
|
12
|
+
{ label: 'All parks', href: '/' },
|
|
13
|
+
{ label: 'About the data', href: '/about' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export const footerLinks = [
|
|
17
|
+
{ label: 'Home', href: '/' },
|
|
18
|
+
{ label: 'About the data', href: '/about' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
// The title tag adds the site name only when the whole title still fits.
|
|
22
|
+
export function pageTitle(title: string): string {
|
|
23
|
+
const full = `${title} | ${site.name}`
|
|
24
|
+
return title === site.name || full.length > 60 ? title : full
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function absoluteUrl(path: string): string {
|
|
28
|
+
return new URL(path, site.url).toString()
|
|
29
|
+
}
|