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,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
|
+
© {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>
|