create-website-build-kit 0.1.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 (91) hide show
  1. package/README.md +54 -0
  2. package/index.mjs +149 -0
  3. package/package.json +42 -0
  4. package/template/.dev.vars.example +3 -0
  5. package/template/.github/workflows/gates.yml +58 -0
  6. package/template/.node-version +1 -0
  7. package/template/.pa11yci.json +24 -0
  8. package/template/BUILD-STATE.md +47 -0
  9. package/template/CLAUDE.md +153 -0
  10. package/template/astro.config.mjs +150 -0
  11. package/template/docs/analytics.md +86 -0
  12. package/template/docs/content.md +138 -0
  13. package/template/docs/handover.md +182 -0
  14. package/template/docs/handover.pdf +0 -0
  15. package/template/docs/runbook.md +661 -0
  16. package/template/docs/traps.md +903 -0
  17. package/template/gitignore +31 -0
  18. package/template/package-lock.json +8159 -0
  19. package/template/package.json +53 -0
  20. package/template/public/_headers +61 -0
  21. package/template/public/_redirects +39 -0
  22. package/template/public/site.webmanifest +13 -0
  23. package/template/scripts/a11y-evidence.mjs +258 -0
  24. package/template/scripts/check-console.mjs +125 -0
  25. package/template/scripts/check-env.mjs +99 -0
  26. package/template/scripts/check-reflow.mjs +148 -0
  27. package/template/scripts/check-sitemap.mjs +113 -0
  28. package/template/scripts/dns-snapshot.mjs +267 -0
  29. package/template/scripts/extract.mjs +317 -0
  30. package/template/scripts/indexnow.mjs +154 -0
  31. package/template/scripts/lastmod.mjs +147 -0
  32. package/template/scripts/lib/inventory.mjs +104 -0
  33. package/template/scripts/lib/preserved.mjs +42 -0
  34. package/template/scripts/lib/routes.mjs +92 -0
  35. package/template/scripts/md-to-pdf.mjs +335 -0
  36. package/template/scripts/og-cards.config.mjs +114 -0
  37. package/template/scripts/og-cards.mjs +487 -0
  38. package/template/scripts/optimize-media.mjs +380 -0
  39. package/template/scripts/recon.mjs +480 -0
  40. package/template/scripts/redirects.mjs +298 -0
  41. package/template/scripts/shots.mjs +447 -0
  42. package/template/scripts/staging-headers.mjs +102 -0
  43. package/template/scripts/tells.mjs +268 -0
  44. package/template/scripts/verify.mjs +1069 -0
  45. package/template/src/components/ContactForm.astro +405 -0
  46. package/template/src/components/CtaBand.astro +82 -0
  47. package/template/src/components/EnvBadge.astro +146 -0
  48. package/template/src/components/Footer.astro +210 -0
  49. package/template/src/components/Header.astro +530 -0
  50. package/template/src/components/Icon.astro +56 -0
  51. package/template/src/components/Img.astro +129 -0
  52. package/template/src/components/PageHero.astro +88 -0
  53. package/template/src/components/Seo.astro +119 -0
  54. package/template/src/components/StructuredData.astro +173 -0
  55. package/template/src/content/blog/.gitkeep +5 -0
  56. package/template/src/content/legal/.gitkeep +0 -0
  57. package/template/src/content.config.ts +81 -0
  58. package/template/src/data/areas.ts +31 -0
  59. package/template/src/data/business.ts +121 -0
  60. package/template/src/data/categories.ts +37 -0
  61. package/template/src/data/fonts.ts +25 -0
  62. package/template/src/data/image-manifest.json +1 -0
  63. package/template/src/data/lastmod.json +1 -0
  64. package/template/src/data/nav.ts +49 -0
  65. package/template/src/data/services.ts +39 -0
  66. package/template/src/data/site.ts +136 -0
  67. package/template/src/env.d.ts +28 -0
  68. package/template/src/layouts/Base.astro +223 -0
  69. package/template/src/lib/brevo.ts +96 -0
  70. package/template/src/lib/hast-media.mjs +55 -0
  71. package/template/src/lib/lastmod.mjs +47 -0
  72. package/template/src/lib/lead.ts +92 -0
  73. package/template/src/lib/legal-routes.mjs +31 -0
  74. package/template/src/lib/legal.ts +75 -0
  75. package/template/src/lib/posts.ts +64 -0
  76. package/template/src/lib/runtime.ts +33 -0
  77. package/template/src/pages/404.astro +51 -0
  78. package/template/src/pages/[slug].astro +111 -0
  79. package/template/src/pages/accessibility.astro +128 -0
  80. package/template/src/pages/api/contact.ts +191 -0
  81. package/template/src/pages/api/leads.csv.ts +82 -0
  82. package/template/src/pages/contact.astro +112 -0
  83. package/template/src/pages/index.astro +84 -0
  84. package/template/src/pages/robots.txt.ts +38 -0
  85. package/template/src/pages/rss.xml.ts +27 -0
  86. package/template/src/styles/global.css +463 -0
  87. package/template/src/styles/project.css +14 -0
  88. package/template/src/styles/prose.css +182 -0
  89. package/template/src/styles/tokens.css +218 -0
  90. package/template/tsconfig.json +5 -0
  91. package/template/wrangler.jsonc +63 -0
@@ -0,0 +1,530 @@
1
+ ---
2
+ import { business } from '../data/business';
3
+ import { nav, primaryAction } from '../data/nav';
4
+
5
+ /**
6
+ * Site header and mobile menu.
7
+ *
8
+ * This component is here for its BEHAVIOUR, not its appearance. The script
9
+ * below is the part worth keeping: the focus trap, the persisted-header
10
+ * listener guard, the sticky close control, `dvh` instead of `vh`, and
11
+ * `margin: auto` instead of `justify-content: center` — each one is a bug that
12
+ * shipped before it was a rule (see docs/traps.md).
13
+ *
14
+ * The styles are the minimum that makes it usable and reachable. There is
15
+ * deliberately no visual treatment: no backdrop blur, no underline animation,
16
+ * no transparent-over-hero behaviour. Style it per project.
17
+ *
18
+ * `data-scrolled` is set on the header past 24px of scroll and carries no
19
+ * styling of its own — hook the design's scrolled state onto it.
20
+ */
21
+
22
+ const path = Astro.url.pathname;
23
+ const isCurrent = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
24
+ ---
25
+
26
+ <header class="header no-print" data-header transition:persist="site-header" transition:name="site-header">
27
+ <div class="header__inner container container--wide">
28
+ <a class="header__brand" href="/" aria-label={`${business.name} — home`}>
29
+ {/* Text until there is a logo. A brand mark referenced before the file
30
+ exists renders as a broken image on every page, which is worse than
31
+ plain type — swap this for <img> once the artwork is in place, and
32
+ look at it on both a light and a dark background before shipping. */}
33
+ {business.name}
34
+ </a>
35
+
36
+ <nav class="header__nav" aria-label="Primary">
37
+ <ul role="list" class="header__list">
38
+ {
39
+ nav.map((item) => (
40
+ <li>
41
+ <a
42
+ href={item.href}
43
+ class="header__link"
44
+ aria-current={isCurrent(item.href) ? 'page' : undefined}
45
+ >
46
+ {item.label}
47
+ </a>
48
+ </li>
49
+ ))
50
+ }
51
+ </ul>
52
+ </nav>
53
+
54
+ <div class="header__actions">
55
+ <a class="header__phone" href={business.phone.href}>{business.phone.display}</a>
56
+ <a class="btn header__cta" href={primaryAction.href}>{primaryAction.label}</a>
57
+ <button
58
+ type="button"
59
+ class="header__toggle"
60
+ aria-expanded="false"
61
+ aria-controls="mobile-menu"
62
+ aria-label="Open menu"
63
+ data-menu-toggle
64
+ >
65
+ <span class="header__bars" aria-hidden="true">
66
+ <span></span>
67
+ <span></span>
68
+ </span>
69
+ </button>
70
+ </div>
71
+ </div>
72
+ </header>
73
+
74
+ <!--
75
+ A full-screen panel that replaces the header rather than sitting under it.
76
+ The bar is hidden while this is open, so the panel owns the close control —
77
+ otherwise the fixed header covers the top of the menu on short screens.
78
+
79
+ `hidden` in the markup, so with JavaScript off it never appears — and every
80
+ link inside it is duplicated in the footer, so nothing is stranded.
81
+ -->
82
+ <div class="menu" id="mobile-menu" data-menu-panel hidden>
83
+ <div class="menu__panel" role="dialog" aria-modal="true" aria-label="Menu">
84
+ <!--
85
+ Sticky, so the close control stays reachable when the links scroll on a
86
+ short screen.
87
+ -->
88
+ <div class="menu__bar">
89
+ <a class="menu__brand" href="/" aria-label={`${business.name} — home`}>{business.name}</a>
90
+ <button type="button" class="menu__close" data-menu-close aria-label="Close menu">
91
+ <span class="menu__close-bars" aria-hidden="true"><span></span><span></span></span>
92
+ </button>
93
+ </div>
94
+
95
+ <div class="menu__inner">
96
+ <nav>
97
+ <ul role="list" class="menu__list">
98
+ {
99
+ nav.map((item) => (
100
+ <li>
101
+ <a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
102
+ {item.label}
103
+ </a>
104
+ </li>
105
+ ))
106
+ }
107
+ </ul>
108
+ </nav>
109
+
110
+ <div class="menu__foot">
111
+ <a class="btn" href={business.phone.href}>Call {business.phone.display}</a>
112
+ <a class="btn btn--ghost" href={primaryAction.href}>{primaryAction.label}</a>
113
+ <p class="menu__hours">{business.hours.display}</p>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </div>
118
+
119
+ <style>
120
+ /*
121
+ * Fixed rather than sticky, so a full-bleed opening section can run under
122
+ * it. Every page's first section reserves --header-h with `.under-header`.
123
+ */
124
+ .header {
125
+ position: fixed;
126
+ inset-block-start: 0;
127
+ inset-inline: 0;
128
+ z-index: var(--z-header);
129
+ background: var(--bg);
130
+ border-block-end: 1px solid var(--border);
131
+ }
132
+
133
+ /*
134
+ * Hidden while the menu is open. The bar is fixed and would otherwise sit
135
+ * over the top of the panel, covering the first nav item on short screens.
136
+ * The panel carries its own close button, so nothing is lost.
137
+ */
138
+ .header[data-menu-open] {
139
+ opacity: 0;
140
+ visibility: hidden;
141
+ pointer-events: none;
142
+ }
143
+
144
+ .header__inner {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: var(--space-l);
148
+ min-block-size: var(--header-h);
149
+ }
150
+
151
+ .header__brand {
152
+ flex: none;
153
+ font-family: var(--font-display);
154
+ font-size: var(--step-1);
155
+ font-weight: var(--weight-semibold);
156
+ letter-spacing: var(--tracking-tight);
157
+ text-decoration: none;
158
+ }
159
+
160
+ .header__nav {
161
+ margin-inline-start: auto;
162
+ }
163
+
164
+ .header__list {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: clamp(var(--space-xs), 1.6vw, var(--space-l));
168
+ margin: 0;
169
+ }
170
+
171
+ .header__link {
172
+ display: block;
173
+ padding-block: var(--space-2xs);
174
+ font-size: var(--step--1);
175
+ color: var(--text-muted);
176
+ text-decoration: none;
177
+ }
178
+
179
+ .header__link:hover,
180
+ .header__link[aria-current='page'] {
181
+ color: var(--text);
182
+ }
183
+
184
+ .header__actions {
185
+ display: flex;
186
+ align-items: center;
187
+ gap: var(--space-s);
188
+ flex: none;
189
+ }
190
+
191
+ .header__phone {
192
+ font-size: var(--step--1);
193
+ font-variant-numeric: tabular-nums;
194
+ text-decoration: none;
195
+ white-space: nowrap;
196
+ }
197
+
198
+ .header__cta {
199
+ font-size: var(--step--1);
200
+ }
201
+
202
+ /* ── Menu button ───────────────────────────────────────────────────────── */
203
+ .header__toggle {
204
+ display: none;
205
+ place-items: center;
206
+ /* 44px minimum tap target. */
207
+ inline-size: 2.75rem;
208
+ block-size: 2.75rem;
209
+ margin-inline-end: calc(var(--space-2xs) * -1);
210
+ }
211
+
212
+ /* Two bars that rotate into an X. No icon swap, so there is no second glyph
213
+ to leak through if a style fails to match. */
214
+ .header__bars {
215
+ display: grid;
216
+ gap: 6px;
217
+ inline-size: 22px;
218
+ }
219
+
220
+ .header__bars span {
221
+ display: block;
222
+ block-size: 2px;
223
+ inline-size: 100%;
224
+ background: currentColor;
225
+ transition: transform var(--dur-base) var(--ease-out);
226
+ }
227
+
228
+ .header__toggle[aria-expanded='true'] .header__bars span:first-child {
229
+ transform: translateY(4px) rotate(45deg);
230
+ }
231
+
232
+ .header__toggle[aria-expanded='true'] .header__bars span:last-child {
233
+ transform: translateY(-4px) rotate(-45deg);
234
+ }
235
+
236
+ @media (max-width: 60rem) {
237
+ .header__nav,
238
+ .header__phone {
239
+ display: none;
240
+ }
241
+
242
+ .header__actions {
243
+ margin-inline-start: auto;
244
+ }
245
+
246
+ .header__toggle {
247
+ display: grid;
248
+ }
249
+ }
250
+
251
+ /* ── Mobile menu ───────────────────────────────────────────────────────── */
252
+ .menu {
253
+ position: fixed;
254
+ inset: 0;
255
+ z-index: var(--z-menu);
256
+ }
257
+
258
+ .menu[hidden] {
259
+ display: none;
260
+ }
261
+
262
+ .menu__panel {
263
+ position: absolute;
264
+ inset: 0;
265
+ display: flex;
266
+ flex-direction: column;
267
+ /*
268
+ * `dvh`, not `vh`. On mobile browsers `vh` is the height *without* the
269
+ * collapsing address bar, so a 100vh panel is taller than the visible
270
+ * area and the bottom call-to-action sits below the fold.
271
+ */
272
+ block-size: 100dvh;
273
+ padding: 0 var(--gutter) var(--space-xl);
274
+ background: var(--bg);
275
+ overflow-y: auto;
276
+ overscroll-behavior: contain;
277
+ opacity: 0;
278
+ transition: opacity var(--dur-base) var(--ease-out);
279
+ }
280
+
281
+ .menu[data-open] .menu__panel {
282
+ opacity: 1;
283
+ }
284
+
285
+ /*
286
+ * Centred by `margin: auto`, NOT `justify-content: center`.
287
+ * On a short screen — a landscape phone, or a small device with the browser
288
+ * chrome expanded — centred flex content overflows equally in both
289
+ * directions and the top becomes unreachable, because scrolling cannot go
290
+ * past the container's start edge. Auto margins collapse to zero once the
291
+ * content is taller than the box, so it stays scrollable from the top.
292
+ */
293
+ .menu__inner {
294
+ inline-size: 100%;
295
+ max-inline-size: 26rem;
296
+ margin: auto;
297
+ display: grid;
298
+ gap: var(--space-xl);
299
+ }
300
+
301
+ /*
302
+ * The panel's own bar. Sticky rather than absolute: absolute inside a
303
+ * scrolling container scrolls away with the content, which took the close
304
+ * button off-screen on a landscape phone.
305
+ */
306
+ .menu__bar {
307
+ position: sticky;
308
+ inset-block-start: 0;
309
+ z-index: 1;
310
+ flex: none;
311
+ display: flex;
312
+ align-items: center;
313
+ justify-content: space-between;
314
+ gap: var(--space-m);
315
+ min-block-size: var(--header-h);
316
+ margin-inline: calc(var(--gutter) * -1);
317
+ padding-inline: var(--gutter);
318
+ background: var(--bg);
319
+ }
320
+
321
+ .menu__brand {
322
+ font-family: var(--font-display);
323
+ font-size: var(--step-1);
324
+ font-weight: var(--weight-semibold);
325
+ text-decoration: none;
326
+ }
327
+
328
+ .menu__close {
329
+ display: grid;
330
+ margin-inline-end: calc(var(--space-2xs) * -1);
331
+ place-items: center;
332
+ inline-size: 2.75rem;
333
+ block-size: 2.75rem;
334
+ }
335
+
336
+ .menu__close-bars {
337
+ display: grid;
338
+ inline-size: 22px;
339
+ block-size: 22px;
340
+ place-items: center;
341
+ }
342
+
343
+ .menu__close-bars span {
344
+ grid-area: 1 / 1;
345
+ inline-size: 100%;
346
+ block-size: 2px;
347
+ background: currentColor;
348
+ }
349
+
350
+ .menu__close-bars span:first-child {
351
+ transform: rotate(45deg);
352
+ }
353
+
354
+ .menu__close-bars span:last-child {
355
+ transform: rotate(-45deg);
356
+ }
357
+
358
+ .menu__list {
359
+ display: grid;
360
+ margin: 0;
361
+ padding: 0;
362
+ }
363
+
364
+ .menu__list a {
365
+ display: flex;
366
+ align-items: center;
367
+ min-block-size: 2.75rem;
368
+ padding: var(--space-s) 0;
369
+ font-family: var(--font-display);
370
+ font-size: var(--step-2);
371
+ text-decoration: none;
372
+ border-block-end: 1px solid var(--border);
373
+ }
374
+
375
+ .menu__foot {
376
+ display: grid;
377
+ gap: var(--space-2xs);
378
+ }
379
+
380
+ .menu__hours {
381
+ margin-block-start: var(--space-2xs);
382
+ font-size: var(--step--1);
383
+ color: var(--text-muted);
384
+ }
385
+ </style>
386
+
387
+ <script>
388
+ /**
389
+ * Header state and the mobile menu.
390
+ *
391
+ * Two things make this trickier than it looks:
392
+ *
393
+ * 1. With `<ClientRouter />` a module script runs on first load only, so
394
+ * everything binds from `astro:page-load`.
395
+ * 2. The header is `transition:persist`, so it survives navigation and must
396
+ * not accumulate a new set of listeners each time — hence the `bound`
397
+ * guard. But the MENU is re-rendered on every navigation, so a handler
398
+ * that captured it in a closure would go on mutating the previous page's
399
+ * detached element while the live one stayed shut. Every handler
400
+ * therefore looks the menu up at call time.
401
+ */
402
+ const menuEl = () => document.querySelector<HTMLElement>('[data-menu-panel]');
403
+
404
+ function setupHeader() {
405
+ const header = document.querySelector<HTMLElement>('[data-header]');
406
+ const toggle = document.querySelector<HTMLButtonElement>('[data-menu-toggle]');
407
+ if (!header || !toggle) return;
408
+ if (header.dataset.bound === '1') return;
409
+ header.dataset.bound = '1';
410
+
411
+ let open = false;
412
+ let lastFocus: HTMLElement | null = null;
413
+
414
+ // Carries no styling of its own. Hook the design's scrolled state onto it.
415
+ const scrolled = () => header.toggleAttribute('data-scrolled', open || window.scrollY > 24);
416
+
417
+ const setOpen = (next: boolean) => {
418
+ const menu = menuEl();
419
+ if (!menu || next === open) return;
420
+ open = next;
421
+
422
+ toggle.setAttribute('aria-expanded', String(open));
423
+ toggle.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
424
+ // Hide the fixed bar so it cannot cover the panel. Set before any focus
425
+ // move, or focus would land on a visibility:hidden element.
426
+ header.toggleAttribute('data-menu-open', open);
427
+ document.documentElement.style.overflow = open ? 'hidden' : '';
428
+
429
+ if (open) {
430
+ lastFocus = document.activeElement as HTMLElement;
431
+ menu.hidden = false;
432
+ // One frame before flipping the attribute, so the browser has a start
433
+ // value to animate from.
434
+ requestAnimationFrame(() => {
435
+ menu.setAttribute('data-open', '');
436
+ // Focus the close button, not the first link — a keyboard or
437
+ // screen-reader user's first option should be "get out of here".
438
+ menu.querySelector<HTMLElement>('[data-menu-close]')?.focus();
439
+ });
440
+ } else {
441
+ menu.removeAttribute('data-open');
442
+ const panel = menu.querySelector<HTMLElement>('.menu__panel');
443
+ const finish = () => {
444
+ if (!open) menu.hidden = true;
445
+ };
446
+ if (panel && window.matchMedia('(prefers-reduced-motion: no-preference)').matches) {
447
+ panel.addEventListener('transitionend', finish, { once: true });
448
+ // Belt and braces: if the transition never fires, hide anyway.
449
+ setTimeout(finish, 600);
450
+ } else {
451
+ finish();
452
+ }
453
+ lastFocus?.focus();
454
+ }
455
+ scrolled();
456
+ };
457
+
458
+ toggle.addEventListener('click', () => setOpen(!open));
459
+
460
+ // Delegated, because the menu is a different element on every page.
461
+ document.addEventListener('click', (event) => {
462
+ const target = event.target as HTMLElement;
463
+ if (!target.closest('[data-menu-panel]')) return;
464
+ if (target.closest('[data-menu-close]')) {
465
+ setOpen(false);
466
+ toggle.focus();
467
+ return;
468
+ }
469
+ if (target.closest('a')) setOpen(false);
470
+ });
471
+
472
+ document.addEventListener('keydown', (event) => {
473
+ if (!open) return;
474
+ const menu = menuEl();
475
+ if (!menu) return;
476
+
477
+ if (event.key === 'Escape') {
478
+ setOpen(false);
479
+ toggle.focus();
480
+ return;
481
+ }
482
+ // Keep Tab inside the open dialog.
483
+ if (event.key !== 'Tab') return;
484
+ const focusable = menu.querySelectorAll<HTMLElement>('a[href], button:not([disabled])');
485
+ if (!focusable.length) return;
486
+ const first = focusable[0];
487
+ const last = focusable[focusable.length - 1];
488
+ if (event.shiftKey && document.activeElement === first) {
489
+ event.preventDefault();
490
+ last.focus();
491
+ } else if (!event.shiftKey && document.activeElement === last) {
492
+ event.preventDefault();
493
+ first.focus();
494
+ }
495
+ });
496
+
497
+ window.matchMedia('(min-width: 60.0625rem)').addEventListener('change', (event) => {
498
+ if (event.matches) setOpen(false);
499
+ });
500
+
501
+ let ticking = false;
502
+ window.addEventListener(
503
+ 'scroll',
504
+ () => {
505
+ if (ticking) return;
506
+ ticking = true;
507
+ requestAnimationFrame(() => {
508
+ scrolled();
509
+ ticking = false;
510
+ });
511
+ },
512
+ { passive: true },
513
+ );
514
+ scrolled();
515
+
516
+ // Leaving the page closes the menu, so the next one never inherits a
517
+ // locked <html> or a hidden header.
518
+ document.addEventListener('astro:before-preparation', () => setOpen(false));
519
+ }
520
+
521
+ document.addEventListener('astro:page-load', () => {
522
+ setupHeader();
523
+ // A persisted header keeps its scrolled state from the previous page even
524
+ // though the new one starts at the top. Re-derive it.
525
+ const header = document.querySelector<HTMLElement>('[data-header]');
526
+ header?.removeAttribute('data-menu-open');
527
+ header?.toggleAttribute('data-scrolled', window.scrollY > 24);
528
+ document.documentElement.style.overflow = '';
529
+ });
530
+ </script>
@@ -0,0 +1,56 @@
1
+ ---
2
+ /**
3
+ * Inline SVG icons. Stroke-based, 24px grid, currentColor — so an icon always
4
+ * matches the text it sits beside and needs no per-use colour token.
5
+ * `aria-hidden` and `focusable="false"` because an icon beside a label is
6
+ * decoration; an icon that is the only content needs a label on the control.
7
+ *
8
+ * The set below is UI furniture only — arrows, chevrons, contact details.
9
+ * Anything that depicts what the business does is a design decision and
10
+ * belongs in the project, drawn or licensed to match the direction. A stock
11
+ * stroke set used for subject matter is one of the things that makes a site
12
+ * read as templated.
13
+ */
14
+ /*
15
+ * `paths` is declared BEFORE Props, which references `keyof typeof paths`.
16
+ * Declared after, the key type resolves to `any`, `name` stops being checked
17
+ * against the set, and `paths[name]` becomes an implicit-any index — so a
18
+ * typo in an icon name renders nothing instead of failing the build, which is
19
+ * the entire reason the type is written this way.
20
+ */
21
+ const paths = {
22
+ phone: '<path d="M6.5 3h3l1.5 4-2 1.5a12 12 0 0 0 6.5 6.5L17 13l4 1.5v3a2 2 0 0 1-2.2 2A17 17 0 0 1 4 5.2 2 2 0 0 1 6 3Z"/>',
23
+ mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3.5 7 7.4 5.3a2 2 0 0 0 2.2 0L20.5 7"/>',
24
+ pin: '<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z"/><circle cx="12" cy="10" r="2.6"/>',
25
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.2 2"/>',
26
+ arrowRight: '<path d="M4 12h15"/><path d="m13 6 6 6-6 6"/>',
27
+ arrowUpRight: '<path d="M7 17 17 7"/><path d="M8 7h9v9"/>',
28
+ chevronDown: '<path d="m6 9 6 6 6-6"/>',
29
+ menu: '<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/>',
30
+ close: '<path d="m6 6 12 12"/><path d="m18 6-12 12"/>',
31
+ } as const;
32
+
33
+ interface Props {
34
+ name: keyof typeof paths;
35
+ size?: number | string;
36
+ class?: string;
37
+ strokeWidth?: number;
38
+ }
39
+
40
+ const { name, size = 24, class: className, strokeWidth = 1.6 } = Astro.props;
41
+ ---
42
+
43
+ <svg
44
+ class={className}
45
+ width={size}
46
+ height={size}
47
+ viewBox="0 0 24 24"
48
+ fill="none"
49
+ stroke="currentColor"
50
+ stroke-width={strokeWidth}
51
+ stroke-linecap="round"
52
+ stroke-linejoin="round"
53
+ aria-hidden="true"
54
+ focusable="false"
55
+ set:html={paths[name]}
56
+ />