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.
Files changed (88) hide show
  1. package/PRIVACY.md +37 -8
  2. package/dist/cli.js +84 -80
  3. package/dist/cli.js.map +1 -1
  4. package/dist/{dist-BBszRLUG.js → dist-4Yam8ps7.js} +1 -1
  5. package/dist/dist-B_2zww72.js +427 -0
  6. package/dist/dist-B_2zww72.js.map +1 -0
  7. package/dist/{esm-Dmtr8qrP.js → esm-BmuxgZyN.js} +95 -188
  8. package/dist/{esm-Dmtr8qrP.js.map → esm-BmuxgZyN.js.map} +1 -1
  9. package/dist/index.d.ts +92 -406
  10. package/dist/index.js +1558 -646
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp.d.ts +0 -14
  13. package/dist/mcp.js +1130 -889
  14. package/dist/mcp.js.map +1 -1
  15. package/dist/provider-sdk.d.ts +0 -2
  16. package/dist/provider-sdk.js +1 -2
  17. package/package.json +6 -5
  18. package/site-kit/README.md +13 -0
  19. package/site-kit/guide/build.md +70 -0
  20. package/site-kit/guide/data.md +49 -0
  21. package/site-kit/guide/golden-page.md +50 -0
  22. package/site-kit/guide/grow.md +58 -0
  23. package/site-kit/guide/launch.md +55 -0
  24. package/site-kit/guide/market.md +57 -0
  25. package/site-kit/guide/plan.md +49 -0
  26. package/site-kit/guide/scale.md +41 -0
  27. package/site-kit/guide/setup.md +59 -0
  28. package/site-kit/guide/writing.md +55 -0
  29. package/site-kit/starter/.github/workflows/refresh-data.yml +88 -0
  30. package/site-kit/starter/.node-version +1 -0
  31. package/site-kit/starter/AGENTS.md +65 -0
  32. package/site-kit/starter/README.md +11 -0
  33. package/site-kit/starter/astro.config.mjs +31 -0
  34. package/site-kit/starter/gitignore +7 -0
  35. package/site-kit/starter/llms.config.mjs +38 -0
  36. package/site-kit/starter/package.json +31 -0
  37. package/site-kit/starter/pnpm-workspace.yaml +5 -0
  38. package/site-kit/starter/public/_headers +5 -0
  39. package/site-kit/starter/public/favicon.svg +1 -0
  40. package/site-kit/starter/public/fonts/InterLatinVF.woff2 +0 -0
  41. package/site-kit/starter/public/fonts/JetBrainsMonoLatinVF.woff2 +0 -0
  42. package/site-kit/starter/public/fonts/LICENSE-inter.txt +93 -0
  43. package/site-kit/starter/public/fonts/LICENSE-jetbrains-mono.txt +93 -0
  44. package/site-kit/starter/scripts/deploy.mjs +60 -0
  45. package/site-kit/starter/scripts/markdown-rewrites.mjs +33 -0
  46. package/site-kit/starter/scripts/refresh-data.mjs +60 -0
  47. package/site-kit/starter/scripts/validate-data.mjs +47 -0
  48. package/site-kit/starter/site.config.json +9 -0
  49. package/site-kit/starter/src/components/Breadcrumbs.astro +27 -0
  50. package/site-kit/starter/src/components/Button.astro +46 -0
  51. package/site-kit/starter/src/components/Card.astro +54 -0
  52. package/site-kit/starter/src/components/CodeBlock.astro +62 -0
  53. package/site-kit/starter/src/components/FactTable.astro +45 -0
  54. package/site-kit/starter/src/components/FaqAccordion.astro +143 -0
  55. package/site-kit/starter/src/components/Footer.astro +34 -0
  56. package/site-kit/starter/src/components/FormInput.astro +18 -0
  57. package/site-kit/starter/src/components/FormSelect.astro +34 -0
  58. package/site-kit/starter/src/components/Header.astro +17 -0
  59. package/site-kit/starter/src/components/HugeIcon.astro +36 -0
  60. package/site-kit/starter/src/components/Logo.astro +21 -0
  61. package/site-kit/starter/src/components/PageHeader.astro +33 -0
  62. package/site-kit/starter/src/components/SectionHeading.astro +18 -0
  63. package/site-kit/starter/src/components/ui/button.ts +51 -0
  64. package/site-kit/starter/src/data/parks.json +106 -0
  65. package/site-kit/starter/src/data/sources.json +11 -0
  66. package/site-kit/starter/src/integrations/og-images.ts +49 -0
  67. package/site-kit/starter/src/integrations/sitemaps.ts +104 -0
  68. package/site-kit/starter/src/layouts/Base.astro +69 -0
  69. package/site-kit/starter/src/lib/cn.ts +5 -0
  70. package/site-kit/starter/src/lib/narrative.ts +115 -0
  71. package/site-kit/starter/src/lib/og/card.ts +85 -0
  72. package/site-kit/starter/src/lib/og/path.ts +14 -0
  73. package/site-kit/starter/src/lib/og/render.ts +30 -0
  74. package/site-kit/starter/src/lib/parks.ts +84 -0
  75. package/site-kit/starter/src/lib/seo.ts +97 -0
  76. package/site-kit/starter/src/pages/404.astro +30 -0
  77. package/site-kit/starter/src/pages/about.astro +39 -0
  78. package/site-kit/starter/src/pages/index.astro +52 -0
  79. package/site-kit/starter/src/pages/parks/[slug].astro +95 -0
  80. package/site-kit/starter/src/pages/regions/[region].astro +58 -0
  81. package/site-kit/starter/src/pages/robots.txt.ts +16 -0
  82. package/site-kit/starter/src/site.ts +29 -0
  83. package/site-kit/starter/src/styles/globals.css +561 -0
  84. package/site-kit/starter/tsconfig.json +5 -0
  85. package/site-kit/starter/wrangler.jsonc +16 -0
  86. package/skills/seo/SKILL.md +19 -1
  87. package/dist/dist-7TxFytBp.js +0 -420
  88. package/dist/dist-7TxFytBp.js.map +0 -1
@@ -0,0 +1,47 @@
1
+ // Fails when the data would make a wrong or broken page: a missing field,
2
+ // a duplicate slug, a fact with no source, or a number outside its range.
3
+ // Add a rule here for every mistake you find in your own data.
4
+ import { readFile } from 'node:fs/promises'
5
+
6
+ const parks = JSON.parse(await readFile('src/data/parks.json', 'utf8'))
7
+ const sources = JSON.parse(await readFile('src/data/sources.json', 'utf8'))
8
+ const sourceIds = new Set(sources.map((source) => source.id))
9
+ const errors = []
10
+ const slugs = new Set()
11
+
12
+ for (const source of sources) {
13
+ if (!/^https?:\/\//.test(source.url)) errors.push(`${source.id}: bad url`)
14
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(source.checked)) {
15
+ errors.push(`${source.id}: checked must be a YYYY-MM-DD date`)
16
+ }
17
+ }
18
+
19
+ for (const park of parks) {
20
+ const at = park.slug ?? JSON.stringify(park).slice(0, 40)
21
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(park.slug ?? '')) {
22
+ errors.push(`${at}: slug must be lowercase words joined by hyphens`)
23
+ }
24
+ if (slugs.has(park.slug)) errors.push(`${at}: duplicate slug`)
25
+ slugs.add(park.slug)
26
+ if (!park.name?.trim()) errors.push(`${at}: missing name`)
27
+ if (!park.region?.trim()) errors.push(`${at}: missing region`)
28
+ if (!sourceIds.has(park.source)) errors.push(`${at}: unknown source`)
29
+ if (!(park.areaHectares > 0 && park.areaHectares < 10_000)) {
30
+ errors.push(`${at}: areaHectares out of range`)
31
+ }
32
+ if (!(park.opened >= 1500 && park.opened <= new Date().getFullYear())) {
33
+ errors.push(`${at}: opened out of range`)
34
+ }
35
+ if (!['yes', 'on leads', 'no'].includes(park.dogs)) {
36
+ errors.push(`${at}: dogs must be yes, on leads or no`)
37
+ }
38
+ if (park.parkingSpaces !== null && !(park.parkingSpaces >= 0)) {
39
+ errors.push(`${at}: parkingSpaces must be a number or null`)
40
+ }
41
+ }
42
+
43
+ if (errors.length) {
44
+ console.error(errors.join('\n'))
45
+ process.exit(1)
46
+ }
47
+ console.log(`${parks.length} parks and ${sources.length} sources are valid.`)
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "Example Parks",
3
+ "domain": "example.com",
4
+ "description": "Size, opening year and facilities for every park in the example parks register, with the source and the date each fact was checked.",
5
+ "language": "en-GB",
6
+ "aiTraining": false,
7
+ "indexNowKey": "",
8
+ "allowWords": []
9
+ }
@@ -0,0 +1,27 @@
1
+ ---
2
+ // The visible path. The layout builds the BreadcrumbList from the same list.
3
+ type Props = {
4
+ items: Array<{ name: string; path: string }>
5
+ }
6
+
7
+ const { items } = Astro.props
8
+ ---
9
+
10
+ <nav aria-label="Breadcrumb" class="mb-6 text-sm text-foreground-muted">
11
+ <ol class="flex flex-wrap items-center gap-1.5">
12
+ {
13
+ items.map((item, index) => (
14
+ <li class="flex items-center gap-1.5">
15
+ {index < items.length - 1 ? (
16
+ <a class="underline decoration-border underline-offset-4 hover:decoration-foreground" href={item.path}>
17
+ {item.name}
18
+ </a>
19
+ ) : (
20
+ <span class="text-foreground" aria-current="page">{item.name}</span>
21
+ )}
22
+ {index < items.length - 1 ? <span aria-hidden="true" class="text-border">/</span> : null}
23
+ </li>
24
+ ))
25
+ }
26
+ </ol>
27
+ </nav>
@@ -0,0 +1,46 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+ import {
4
+ type ButtonSize,
5
+ type ButtonVariant,
6
+ getButtonClasses,
7
+ } from './ui/button'
8
+
9
+ type Shared = { variant?: ButtonVariant; size?: ButtonSize }
10
+ type Props =
11
+ | (HTMLAttributes<'button'> & Shared & { href?: undefined })
12
+ | (HTMLAttributes<'a'> & Shared & { href: string })
13
+
14
+ const {
15
+ class: className,
16
+ variant: requestedVariant,
17
+ size = 'default',
18
+ ...props
19
+ } = Astro.props as Props
20
+ // The main action is primary and side actions are quieter.
21
+ // Links and submit buttons are primary unless a page asks otherwise. Side
22
+ // actions use the outline button so they show on white and muted panels.
23
+ const variant: ButtonVariant =
24
+ requestedVariant ??
25
+ (typeof props.href === 'string' || props.type === 'submit'
26
+ ? 'default'
27
+ : 'outline')
28
+ const anchorProps =
29
+ typeof props.href === 'string' ? (props as HTMLAttributes<'a'>) : undefined
30
+ const buttonProps = anchorProps
31
+ ? undefined
32
+ : (props as HTMLAttributes<'button'>)
33
+ const classes = [getButtonClasses(variant, size), className]
34
+ ---
35
+
36
+ {
37
+ anchorProps ? (
38
+ <a {...anchorProps} class:list={classes}>
39
+ <slot />
40
+ </a>
41
+ ) : (
42
+ <button {...buttonProps} class:list={classes}>
43
+ <slot />
44
+ </button>
45
+ )
46
+ }
@@ -0,0 +1,54 @@
1
+ ---
2
+ // A muted panel with an optional icon, a title and text. A linked card
3
+ // ends with a label that says where it goes, such as "Parks in Northfield",
4
+ // and changes colour on hover. Each card spans its rows of the parent grid
5
+ // as a subgrid, so titles and text line up across a row: put cards in
6
+ // `grid grid-cols-1 gap-2 sm:grid-cols-2`.
7
+ import { ArrowUpRight01Icon } from '@hugeicons/core-free-icons'
8
+ import HugeIcon from './HugeIcon.astro'
9
+
10
+ type Props = {
11
+ title: string
12
+ text: string
13
+ icon?: Parameters<typeof HugeIcon>[0]['icon']
14
+ href?: string
15
+ // Where the card goes. Replaces the corner arrow.
16
+ link?: string
17
+ }
18
+
19
+ const { title, text, icon, href, link } = Astro.props
20
+ const Tag = href ? 'a' : 'div'
21
+ ---
22
+
23
+ <Tag
24
+ href={href}
25
+ class:list={[
26
+ link ? 'row-span-4' : 'row-span-3',
27
+ 'grid grid-rows-subgrid gap-y-0 rounded-lg bg-muted p-6',
28
+ href &&
29
+ 'group no-underline transition-colors hover:bg-secondary-hover focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20',
30
+ ]}
31
+ >
32
+ <div class="flex min-h-6 items-start justify-between gap-4 text-foreground-muted">
33
+ {icon ? (
34
+ <HugeIcon
35
+ icon={icon}
36
+ class:list={[
37
+ 'size-6',
38
+ href && 'transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:text-primary',
39
+ ]}
40
+ />
41
+ ) : <span></span>}
42
+ {href && !link ? (
43
+ <HugeIcon icon={ArrowUpRight01Icon} class="size-5 text-foreground-soft transition-colors group-hover:text-foreground" />
44
+ ) : null}
45
+ </div>
46
+ <h3 class="mt-8 text-base font-medium text-foreground">{title}</h3>
47
+ <p class="mt-2 text-sm leading-6 text-foreground-muted">{text}</p>
48
+ {link ? (
49
+ <span class="mt-4 inline-flex items-center gap-1 self-end text-sm font-medium text-foreground">
50
+ {link}
51
+ <HugeIcon icon={ArrowUpRight01Icon} class="size-4 text-foreground-soft transition-colors group-hover:text-foreground" />
52
+ </span>
53
+ ) : null}
54
+ </Tag>
@@ -0,0 +1,62 @@
1
+ ---
2
+ // A code block with a copy button.
3
+ import { Copy01Icon, Tick02Icon } from '@hugeicons/core-free-icons'
4
+ import HugeIcon from './HugeIcon.astro'
5
+
6
+ type Props = {
7
+ label?: string
8
+ value: string
9
+ wrap?: boolean
10
+ class?: string
11
+ }
12
+
13
+ const { label, value, wrap = false, class: className } = Astro.props
14
+ ---
15
+
16
+ <div class:list={['mt-5 min-w-0', className]}>
17
+ {label ? <p class="mb-2 text-sm/6 text-foreground-muted">{label}</p> : null}
18
+ <div class="code-frame !mt-0">
19
+ <pre
20
+ class:list={[
21
+ '!mt-0 overflow-x-auto rounded-lg bg-muted p-4 pr-13 font-mono text-sm leading-6 text-foreground',
22
+ wrap && 'whitespace-pre-wrap break-words',
23
+ ]}><code>{value}</code></pre>
24
+ <div class="code-copy-strip">
25
+ <button
26
+ type="button"
27
+ class="code-copy-button"
28
+ aria-label={label ? `Copy ${label.toLowerCase()}` : 'Copy code'}
29
+ data-code-copy
30
+ >
31
+ <HugeIcon icon={Copy01Icon} class="code-copy-icon" data-copy-icon />
32
+ <HugeIcon icon={Tick02Icon} class="code-copy-icon hidden" data-copy-check-icon />
33
+ </button>
34
+ </div>
35
+ </div>
36
+ </div>
37
+
38
+ <script>
39
+ // Astro bundles this once, however many code blocks a page has.
40
+ for (const button of document.querySelectorAll('[data-code-copy]')) {
41
+ if (!(button instanceof HTMLElement)) continue
42
+ const code = button.closest('.code-frame')?.querySelector('pre code')
43
+ button.addEventListener('click', async () => {
44
+ try {
45
+ await navigator.clipboard.writeText(code?.textContent ?? '')
46
+ } catch {
47
+ return
48
+ }
49
+ const copyIcon = button.querySelector('[data-copy-icon]')
50
+ const checkIcon = button.querySelector('[data-copy-check-icon]')
51
+ const label = button.getAttribute('aria-label') ?? 'Copy code'
52
+ copyIcon?.classList.add('hidden')
53
+ checkIcon?.classList.remove('hidden')
54
+ button.setAttribute('aria-label', 'Copied')
55
+ window.setTimeout(() => {
56
+ copyIcon?.classList.remove('hidden')
57
+ checkIcon?.classList.add('hidden')
58
+ button.setAttribute('aria-label', label)
59
+ }, 1500)
60
+ })
61
+ }
62
+ </script>
@@ -0,0 +1,45 @@
1
+ ---
2
+ // A fact block: one muted panel with a ruled row per fact, and the source
3
+ // and checked date under it. `seo site check` fails the build when a block
4
+ // has no source link (data-source) or no checked date (<time data-checked>).
5
+ import { formatDate, type Source } from '../lib/parks'
6
+
7
+ type Props = {
8
+ heading: string
9
+ rows: Array<{ label: string; value: string }>
10
+ source: Source
11
+ }
12
+
13
+ const { heading, rows, source } = Astro.props
14
+ ---
15
+
16
+ <section class="mt-12" data-facts={heading}>
17
+ <h2 class="text-xl/7 font-medium text-foreground">{heading}</h2>
18
+ <div class="mt-4 overflow-hidden rounded-lg bg-muted">
19
+ <table class="w-full border-collapse text-left text-base">
20
+ <tbody>
21
+ {
22
+ rows.map((row) => (
23
+ <tr class="border-t border-border first:border-t-0">
24
+ <th scope="row" class="w-2/5 px-6 py-3.5 align-top font-normal text-foreground-muted">
25
+ {row.label}
26
+ </th>
27
+ <td class="px-6 py-3.5 align-top font-medium text-foreground">{row.value}</td>
28
+ </tr>
29
+ ))
30
+ }
31
+ </tbody>
32
+ </table>
33
+ </div>
34
+ <p class="mt-3 text-sm/6 text-foreground-soft">
35
+ Source:{' '}
36
+ <a
37
+ data-source
38
+ href={source.url}
39
+ rel="noreferrer"
40
+ target="_blank"
41
+ class="text-foreground-muted underline decoration-border underline-offset-4 hover:text-foreground hover:decoration-foreground"
42
+ >{source.name}</a
43
+ >, checked <time data-checked datetime={source.checked}>{formatDate(source.checked)}</time>.
44
+ </p>
45
+ </section>
@@ -0,0 +1,143 @@
1
+ ---
2
+ // One muted panel with ruled rows. Opening a row closes the others. Arrow
3
+ // keys, Home and End move between questions. The answers are in the HTML
4
+ // even when closed, so search engines and AI answers can read them. Pass
5
+ // the same list to the layout's faqs prop so the FAQ markup matches.
6
+ type FaqItem = {
7
+ question: string
8
+ answer?: string
9
+ values?: string[]
10
+ }
11
+
12
+ type Props = {
13
+ items: FaqItem[]
14
+ id?: string
15
+ }
16
+
17
+ const { items, id = 'faq' } = Astro.props
18
+ ---
19
+
20
+ <div class="w-full overflow-hidden rounded-lg bg-muted" data-faq-accordion>
21
+ {
22
+ items.map((item, index) => (
23
+ <div
24
+ class="border-t border-border px-6 first:border-t-0"
25
+ data-faq-item
26
+ data-state="closed"
27
+ >
28
+ <h3 class="flex">
29
+ <button
30
+ id={`${id}-trigger-${index + 1}`}
31
+ type="button"
32
+ aria-controls={`${id}-content-${index + 1}`}
33
+ aria-expanded="false"
34
+ class="group flex flex-1 items-center justify-between gap-4 rounded-md border border-transparent py-5 text-left text-base font-medium text-foreground outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20"
35
+ data-faq-trigger
36
+ >
37
+ <span>{item.question}</span>
38
+ <svg
39
+ class="size-3.5 shrink-0 text-foreground transition-transform duration-200 group-aria-expanded:rotate-180"
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ stroke-width="2"
44
+ stroke-linecap="round"
45
+ stroke-linejoin="round"
46
+ aria-hidden="true"
47
+ >
48
+ <path d="m6 9 6 6 6-6"></path>
49
+ </svg>
50
+ </button>
51
+ </h3>
52
+ <div
53
+ id={`${id}-content-${index + 1}`}
54
+ role="region"
55
+ aria-labelledby={`${id}-trigger-${index + 1}`}
56
+ aria-hidden="true"
57
+ class="faq-content grid text-sm"
58
+ data-faq-content
59
+ data-state="closed"
60
+ >
61
+ <div class="overflow-hidden">
62
+ <div class="pb-5 text-base leading-7 text-foreground-muted">
63
+ {item.answer ? <p>{item.answer}</p> : null}
64
+ {
65
+ item.values ? (
66
+ <ul class="grid gap-x-4 gap-y-2 sm:grid-cols-2">
67
+ {item.values.map((value) => (
68
+ <li>
69
+ <code class="block break-words rounded-md bg-background px-2 py-1 font-mono text-xs leading-5 text-foreground">
70
+ {value}
71
+ </code>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ ) : null
76
+ }
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ ))
82
+ }
83
+ </div>
84
+
85
+ <script>
86
+ for (const root of document.querySelectorAll('[data-faq-accordion]')) {
87
+ if (!(root instanceof HTMLElement) || root.dataset.faqReady === 'true') continue
88
+ root.dataset.faqReady = 'true'
89
+
90
+ const items = Array.from(root.querySelectorAll('[data-faq-item]')).filter(
91
+ (item): item is HTMLElement => item instanceof HTMLElement,
92
+ )
93
+ const triggers = items
94
+ .map((item) => item.querySelector('[data-faq-trigger]'))
95
+ .filter((trigger): trigger is HTMLButtonElement => trigger instanceof HTMLButtonElement)
96
+
97
+ const setOpen = (item: HTMLElement, open: boolean) => {
98
+ const trigger = item.querySelector('[data-faq-trigger]')
99
+ const content = item.querySelector('[data-faq-content]')
100
+ if (!(trigger instanceof HTMLButtonElement) || !(content instanceof HTMLElement)) return
101
+
102
+ const state = open ? 'open' : 'closed'
103
+ item.dataset.state = state
104
+ content.dataset.state = state
105
+ trigger.setAttribute('aria-expanded', String(open))
106
+ content.setAttribute('aria-hidden', String(!open))
107
+ }
108
+
109
+ for (const item of items) {
110
+ const trigger = item.querySelector('[data-faq-trigger]')
111
+ if (!(trigger instanceof HTMLButtonElement)) continue
112
+
113
+ trigger.addEventListener('click', (event) => {
114
+ event.preventDefault()
115
+ const shouldOpen = item.dataset.state !== 'open'
116
+
117
+ if (shouldOpen) {
118
+ for (const other of items) {
119
+ if (other === item) continue
120
+ setOpen(other, false)
121
+ }
122
+ }
123
+
124
+ setOpen(item, shouldOpen)
125
+ })
126
+
127
+ trigger.addEventListener('keydown', (event) => {
128
+ const currentIndex = triggers.indexOf(trigger)
129
+ let nextIndex = currentIndex
130
+
131
+ if (event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % triggers.length
132
+ else if (event.key === 'ArrowUp') {
133
+ nextIndex = (currentIndex - 1 + triggers.length) % triggers.length
134
+ } else if (event.key === 'Home') nextIndex = 0
135
+ else if (event.key === 'End') nextIndex = triggers.length - 1
136
+ else return
137
+
138
+ event.preventDefault()
139
+ triggers[nextIndex]?.focus()
140
+ })
141
+ }
142
+ }
143
+ </script>
@@ -0,0 +1,34 @@
1
+ ---
2
+ import { footerLinks, site } from '../site'
3
+ import Logo from './Logo.astro'
4
+
5
+ const linkClass =
6
+ 'text-sm/6 text-foreground-muted underline-offset-4 transition-colors hover:text-foreground hover:underline'
7
+ const year = new Date().getFullYear()
8
+ ---
9
+
10
+ <footer class="mt-20 py-14">
11
+ <div class="site-frame flex flex-col items-center gap-8 text-center">
12
+ <a href="/" class="text-foreground no-underline" aria-label={`${site.name} home`}>
13
+ <Logo class="size-9" />
14
+ </a>
15
+
16
+ <nav aria-label="Footer">
17
+ <ul class="flex flex-wrap items-center justify-center gap-x-7 gap-y-3">
18
+ {
19
+ footerLinks.map((link) => (
20
+ <li>
21
+ <a class={linkClass} href={link.href}>
22
+ {link.label}
23
+ </a>
24
+ </li>
25
+ ))
26
+ }
27
+ </ul>
28
+ </nav>
29
+
30
+ <p class="max-w-md text-sm/6 text-foreground-soft">
31
+ &copy; {year} {site.name}. {site.footerNote}
32
+ </p>
33
+ </div>
34
+ </footer>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ type Props = HTMLAttributes<'input'>
5
+
6
+ const { class: className, ...attrs } = Astro.props
7
+ ---
8
+
9
+ <input
10
+ class:list={[
11
+ 'flex h-10 w-full min-w-0 rounded-lg border border-input bg-card px-3 py-1 text-base text-foreground outline-none transition-[color,box-shadow,background-color] placeholder:text-foreground-soft md:text-sm',
12
+ 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20',
13
+ 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
14
+ 'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
15
+ className,
16
+ ]}
17
+ {...attrs}
18
+ />
@@ -0,0 +1,34 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ type Props = HTMLAttributes<'select'>
5
+
6
+ const { class: className, ...attrs } = Astro.props
7
+ ---
8
+
9
+ <div class="group/select relative w-full has-[select:disabled]:opacity-50">
10
+ <select
11
+ class:list={[
12
+ 'h-10 w-full min-w-0 appearance-none rounded-lg border border-input bg-card py-2 pl-3 pr-9 text-sm text-foreground outline-none transition-[color,box-shadow]',
13
+ 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20',
14
+ 'disabled:pointer-events-none disabled:cursor-not-allowed',
15
+ className,
16
+ ]}
17
+ {...attrs}
18
+ >
19
+ <slot />
20
+ </select>
21
+ <svg
22
+ class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-foreground-muted opacity-60"
23
+ xmlns="http://www.w3.org/2000/svg"
24
+ viewBox="0 0 24 24"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ stroke-width="2"
28
+ stroke-linecap="round"
29
+ stroke-linejoin="round"
30
+ aria-hidden="true"
31
+ >
32
+ <path d="m6 9 6 6 6-6"></path>
33
+ </svg>
34
+ </div>
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { nav, site } from '../site'
3
+ import Logo from './Logo.astro'
4
+
5
+ const navClass =
6
+ 'text-sm text-foreground-muted underline-offset-4 hover:text-foreground hover:underline'
7
+ ---
8
+
9
+ <header class="site-frame flex items-center justify-between gap-4 py-4 sm:gap-6">
10
+ <a href="/" class="flex items-center gap-2 text-foreground no-underline">
11
+ <Logo class="size-7" />
12
+ <span class="text-base font-medium">{site.name}</span>
13
+ </a>
14
+ <nav class="flex items-center gap-3.5 sm:gap-5" aria-label="Main navigation">
15
+ {nav.map((link) => <a class={navClass} href={link.href}>{link.label}</a>)}
16
+ </nav>
17
+ </header>
@@ -0,0 +1,36 @@
1
+ ---
2
+ // Hugeicons drawn as static SVG at a stroke width of 1.7. Pass an icon
3
+ // from @hugeicons/core-free-icons. The SVG is in the HTML, so no icon
4
+ // script or font loads.
5
+ type IconElement = readonly [string, { readonly [key: string]: string | number }]
6
+
7
+ type Props = {
8
+ icon: readonly IconElement[]
9
+ class?: string
10
+ [attribute: `data-${string}`]: string | boolean | undefined
11
+ }
12
+
13
+ const { icon, class: className, ...attributes } = Astro.props
14
+
15
+ // The data uses React prop names (strokeWidth); SVG wants stroke-width.
16
+ const toAttributes = (attrs: { readonly [key: string]: string | number }) =>
17
+ Object.fromEntries(
18
+ Object.entries(attrs)
19
+ .filter(([name]) => name !== 'key')
20
+ .map(([name, value]) => [
21
+ name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`),
22
+ name === 'strokeWidth' ? 1.7 : value,
23
+ ]),
24
+ )
25
+ ---
26
+
27
+ <svg
28
+ {...attributes}
29
+ class={className}
30
+ viewBox="0 0 24 24"
31
+ fill="none"
32
+ aria-hidden="true"
33
+ xmlns="http://www.w3.org/2000/svg"
34
+ >
35
+ {icon.map(([Tag, attrs]) => <Tag {...toAttributes(attrs)} />)}
36
+ </svg>
@@ -0,0 +1,21 @@
1
+ ---
2
+ // The site mark, drawn in one colour from currentColor, so it is dark in
3
+ // light mode and light in dark mode. Replace the path with the site's own
4
+ // mark, and the same path in public/favicon.svg and src/lib/og/card.ts.
5
+ type Props = { class?: string; title?: string }
6
+
7
+ const { class: className = 'size-8', title } = Astro.props
8
+ ---
9
+
10
+ <svg
11
+ xmlns="http://www.w3.org/2000/svg"
12
+ viewBox="0 0 32 32"
13
+ fill="currentColor"
14
+ class={className}
15
+ role={title ? 'img' : undefined}
16
+ aria-label={title}
17
+ aria-hidden={title ? undefined : 'true'}
18
+ >
19
+ {title ? <title>{title}</title> : null}
20
+ <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"></path>
21
+ </svg>
@@ -0,0 +1,33 @@
1
+ ---
2
+ type Props = {
3
+ title: string
4
+ description?: string
5
+ bordered?: boolean
6
+ class?: string
7
+ }
8
+
9
+ const { title, description, bordered = false, class: className } = Astro.props
10
+ const hasActions = Astro.slots.has('actions')
11
+ ---
12
+
13
+ <header
14
+ class:list={[
15
+ 'mb-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between',
16
+ bordered && 'border-b border-border pb-6',
17
+ className,
18
+ ]}
19
+ >
20
+ <div class="flex max-w-2xl flex-col gap-3">
21
+ <h1 class="text-pretty text-2xl font-medium tracking-normal text-foreground xl:text-3xl">
22
+ {title}
23
+ </h1>
24
+ {
25
+ description ? (
26
+ <p class="text-pretty text-lg/7 font-medium tracking-normal text-foreground-muted xl:text-xl/8">
27
+ {description}
28
+ </p>
29
+ ) : null
30
+ }
31
+ </div>
32
+ {hasActions ? <div class="flex shrink-0 items-center gap-3"><slot name="actions" /></div> : null}
33
+ </header>
@@ -0,0 +1,18 @@
1
+ ---
2
+ type Props = {
3
+ title: string
4
+ text: string
5
+ id?: string
6
+ }
7
+
8
+ const { title, text, id } = Astro.props
9
+ ---
10
+
11
+ <div class="mb-10 flex max-w-2xl flex-col gap-3">
12
+ <h2 id={id} class="text-pretty text-2xl font-medium -tracking-[0.4px] text-foreground xl:text-3xl xl:-tracking-[0.6px]">
13
+ {title}
14
+ </h2>
15
+ <p class="text-pretty text-xl/7 font-medium -tracking-[0.15px] text-foreground-muted xl:text-2xl/8 xl:-tracking-[0.3px]">
16
+ {text}
17
+ </p>
18
+ </div>