@jtakeit/astro 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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/bin/jtk.mjs +41 -0
  4. package/docs/booking.md +164 -0
  5. package/docs/catalogue.md +459 -0
  6. package/docs/collections.md +249 -0
  7. package/docs/css.md +86 -0
  8. package/docs/gallery.md +127 -0
  9. package/docs/hero-motion.md +189 -0
  10. package/docs/kit.md +454 -0
  11. package/docs/languages.md +182 -0
  12. package/docs/lead-form.md +109 -0
  13. package/docs/pages.md +193 -0
  14. package/docs/photos.md +314 -0
  15. package/docs/scaffold.md +75 -0
  16. package/docs/shapes.md +140 -0
  17. package/docs/surface.md +187 -0
  18. package/lib/catalogue.mjs +1678 -0
  19. package/lib/codes.mjs +171 -0
  20. package/lib/create.mjs +282 -0
  21. package/package.json +16 -0
  22. package/template/astro.config.mjs +84 -0
  23. package/template/figures.mjs +122 -0
  24. package/template/gitignore +16 -0
  25. package/template/jtakeit-meta.mjs +112 -0
  26. package/template/jtk/content/index.json +38 -0
  27. package/template/jtk/design.json +24 -0
  28. package/template/markdown.mjs +36 -0
  29. package/template/package-lock.json +5320 -0
  30. package/template/package.json +26 -0
  31. package/template/specimens.mjs +46 -0
  32. package/template/src/components/Blocks.astro +151 -0
  33. package/template/src/components/BookingForm.astro +506 -0
  34. package/template/src/components/Clip.astro +155 -0
  35. package/template/src/components/Hero.astro +66 -0
  36. package/template/src/components/LeadForm.astro +347 -0
  37. package/template/src/components/OpeningHours.astro +69 -0
  38. package/template/src/components/Pile.astro +185 -0
  39. package/template/src/components/Shot.astro +472 -0
  40. package/template/src/components/gallery/Gallery.astro +381 -0
  41. package/template/src/components/gallery/galleries.ts +139 -0
  42. package/template/src/components/motion/HeroField.astro +520 -0
  43. package/template/src/components/motion/fields.ts +430 -0
  44. package/template/src/components/surface/Pattern.astro +278 -0
  45. package/template/src/components/surface/patterns.ts +187 -0
  46. package/template/src/content/blocks.ts +758 -0
  47. package/template/src/content.config.ts +19 -0
  48. package/template/src/copy/LOCALE.ts +324 -0
  49. package/template/src/data/site.ts +137 -0
  50. package/template/src/layouts/Layout.astro +282 -0
  51. package/template/src/lib/alive.ts +49 -0
  52. package/template/src/lib/entries.ts +106 -0
  53. package/template/src/lib/entryLoader.ts +315 -0
  54. package/template/src/lib/noise.ts +26 -0
  55. package/template/src/lib/page.ts +287 -0
  56. package/template/src/lib/photos.ts +168 -0
  57. package/template/src/lib/under.ts +32 -0
  58. package/template/src/lib/uploads.ts +85 -0
  59. package/template/src/pages/[...entry].astro +207 -0
  60. package/template/src/pages/[...feed].xml.ts +64 -0
  61. package/template/src/pages/index.astro +90 -0
  62. package/template/src/pages/llms.txt.ts +50 -0
  63. package/template/src/pages/privacy.astro +59 -0
  64. package/template/src/pages/robots.txt.ts +21 -0
  65. package/template/src/pages/sitemap.xml.ts +50 -0
  66. package/template/src/styles/global.css +411 -0
  67. package/template/src/styles/surface.css +375 -0
  68. package/template/tsconfig.json +5 -0
@@ -0,0 +1,282 @@
1
+ ---
2
+ import DESIGN from '../../jtk/design.json';
3
+ /**
4
+ * The document. Written once, derived from data, never repeated per page.
5
+ *
6
+ * Everything SEO-shaped here comes out of src/data/site.ts and src/copy — the
7
+ * host from the build, the facts from one object, the indexability from one
8
+ * flag. There is deliberately no second place to change any of them.
9
+ */
10
+ import { BUSINESS, INDEXABLE, SCHEMA_TYPE, canonicalFor } from '../data/site';
11
+ import { under } from '../lib/under';
12
+ import { META } from '../copy/{{LOCALE}}';
13
+ import '../styles/global.css';
14
+
15
+ interface Props {
16
+ title: string;
17
+ description: string;
18
+ /** Set false on the home page, whose title is already complete. */
19
+ suffix?: boolean;
20
+ /** Extra JSON-LD for this page — a Service, an Offer, an FAQPage. */
21
+ schema?: unknown;
22
+ bodyClass?: string;
23
+ /**
24
+ * Every language this page exists in — `readPage('/prices').alternates`.
25
+ *
26
+ * Without it two language versions of one page compete with each other in
27
+ * search, which is the only part of being multilingual that costs money. A
28
+ * page in one language passes nothing and nothing is emitted; a `hreflang`
29
+ * set of one is noise.
30
+ */
31
+ alternates?: { locale: string; path: string }[];
32
+ }
33
+
34
+ const { title, description, suffix = true, schema, bodyClass, alternates = [] } = Astro.props;
35
+
36
+ const canonical = canonicalFor(Astro.url.pathname);
37
+ // `under` and not a leading slash: `new URL('/og.jpg', …)` throws away the
38
+ // path this build is served under, which under the studio's preview is the
39
+ // whole of the site's address. src/lib/under.ts has the argument.
40
+ const ogImage = new URL(under('/og.jpg'), Astro.site).href;
41
+ const fullTitle = suffix ? `${title}${META.titleSuffix}` : title;
42
+
43
+ /**
44
+ * One business, described once, on every page. The `@id` is stable across the
45
+ * site so the pages describe the same entity rather than a dozen similar ones.
46
+ *
47
+ * Only confirmed fields are emitted: an empty value is dropped rather than
48
+ * asserted as an empty string. geo and openingHours are absent on purpose until
49
+ * the client confirms them — see src/data/site.ts.
50
+ */
51
+ const sameAs = [BUSINESS.instagram, BUSINESS.facebook].filter(Boolean);
52
+
53
+ /**
54
+ * The palette, as CSS custom properties on :root.
55
+ *
56
+ * Written here rather than into a stylesheet so that it stays a *document* —
57
+ * `jtk/design.json`, beside the content — and can therefore be changed
58
+ * without rebuilding anybody's opinion into a file. `HeroField` reads its
59
+ * colours as custom properties and watches the root element for changes, which
60
+ * is also how the studio bar's live accent picker drives a variant.
61
+ *
62
+ * Empty values are left out entirely, so a scaffold nobody has designed keeps
63
+ * the kit's own tokens instead of inheriting a page of blanks.
64
+ */
65
+ const palette: Record<string, string> = {};
66
+
67
+ /*
68
+ * Which way up the page is.
69
+ *
70
+ * A photograph's shadows are dark on any page — but `--ink` and `--paper` swap
71
+ * over on a dark one, so the treatment tokens derived from them come out
72
+ * inverted and `duotone` folds a photograph into a flat rectangle. It looked
73
+ * like a broken blend mode and it was a palette read upside down.
74
+ *
75
+ * The document has both colours as literals, so the page can be asked rather
76
+ * than assumed. No palette chosen yet means the kit's own light tokens, which
77
+ * is what global.css already declares.
78
+ */
79
+ const luminance = (hex: string): number | null => {
80
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
81
+ if (!m) return null;
82
+ const n = parseInt(m[1], 16);
83
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
84
+ .map((v) => {
85
+ const c = v / 255;
86
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
87
+ })
88
+ .reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
89
+ };
90
+
91
+ const paperLuma = DESIGN.paper ? luminance(DESIGN.paper) : null;
92
+ const inkLuma = DESIGN.ink ? luminance(DESIGN.ink) : null;
93
+ const invertedPage = paperLuma !== null && inkLuma !== null && paperLuma < inkLuma;
94
+
95
+ if (DESIGN.paper) palette['--paper'] = DESIGN.paper;
96
+ if (DESIGN.paper) palette['--panel'] = DESIGN.paper;
97
+ if (DESIGN.ink) palette['--ink'] = DESIGN.ink;
98
+ if (DESIGN.accent) palette['--accent'] = DESIGN.accent;
99
+ if (DESIGN.accent) palette['--accent-ink'] = DESIGN.accent;
100
+ if (DESIGN.ground) palette['--fl-ground'] = DESIGN.ground;
101
+ (DESIGN.colors ?? []).forEach((colour: string, i: number) => {
102
+ if (colour) palette[`--fl-c${i + 1}`] = colour;
103
+ });
104
+
105
+ /*
106
+ * On a dark page the two ends of a photograph's tonal range take the same two
107
+ * tokens the other way round: the page's ground is what its shadows are tinted
108
+ * with, and the ink is what its highlights are. A variant can still override
109
+ * either — this only stops the default from being wrong.
110
+ */
111
+ if (invertedPage) {
112
+ palette['--photo-shadow'] = 'color-mix(in srgb, var(--paper) 78%, var(--accent))';
113
+ palette['--photo-light'] = 'var(--ink)';
114
+ }
115
+
116
+ const rootStyle = Object.entries(palette)
117
+ .map(([name, value]) => `${name}:${value}`)
118
+ .join(';');
119
+
120
+ const businessSchema = {
121
+ '@context': 'https://schema.org',
122
+ '@type': SCHEMA_TYPE,
123
+ '@id': `${canonicalFor('/')}#business`,
124
+ name: BUSINESS.name,
125
+ description: BUSINESS.description || description,
126
+ url: canonicalFor('/'),
127
+ image: ogImage,
128
+ inLanguage: META.lang,
129
+ ...(BUSINESS.phoneDial ? { telephone: BUSINESS.phoneDial } : {}),
130
+ ...(BUSINESS.email ? { email: BUSINESS.email } : {}),
131
+ ...(BUSINESS.priceRange ? { priceRange: BUSINESS.priceRange } : {}),
132
+ ...(BUSINESS.mapsUrl ? { hasMap: BUSINESS.mapsUrl } : {}),
133
+ ...(sameAs.length ? { sameAs } : {}),
134
+ ...(BUSINESS.city
135
+ ? {
136
+ address: {
137
+ '@type': 'PostalAddress',
138
+ streetAddress: BUSINESS.street,
139
+ postalCode: BUSINESS.postalCode,
140
+ addressLocality: BUSINESS.city,
141
+ addressRegion: BUSINESS.region,
142
+ addressCountry: BUSINESS.country,
143
+ },
144
+ }
145
+ : {}),
146
+ };
147
+ ---
148
+
149
+ <!doctype html>
150
+ <html lang={META.lang} style={rootStyle || undefined}>
151
+ <head>
152
+ <meta charset="utf-8" />
153
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
154
+
155
+ <title>{fullTitle}</title>
156
+ <meta name="description" content={description} />
157
+ <link rel="canonical" href={canonical} />
158
+
159
+ {/*
160
+ The other languages of this page.
161
+
162
+ `x-default` is the site's own language: it is what a search engine shows
163
+ somebody whose language is none of these, and leaving it out means the
164
+ engine picks for itself. The locale of the site's own pages is empty in
165
+ the content, and here it becomes the site's `lang` — the value is the
166
+ same fact written two ways, so it is written once in META.
167
+ */}
168
+ {alternates.map((other) => (
169
+ <link
170
+ rel="alternate"
171
+ hreflang={other.locale === '' ? META.lang : other.locale}
172
+ href={canonicalFor(other.path)}
173
+ />
174
+ ))}
175
+ {alternates.length > 0 && (
176
+ <link rel="alternate" hreflang="x-default" href={canonicalFor(alternates.find((one) => one.locale === '')?.path ?? '/')} />
177
+ )}
178
+
179
+ {/* One flag drives this, robots.txt, the sitemap and llms.txt together. */}
180
+ <meta
181
+ name="robots"
182
+ content={INDEXABLE ? 'index, follow, max-image-preview:large' : 'noindex, nofollow'}
183
+ />
184
+
185
+ {/* TODO(prod): Search Console ownership. Independent of robots — Google
186
+ fetches the page directly to verify, so noindex does not block it. */}
187
+ {/* <meta name="google-site-verification" content="" /> */}
188
+
189
+ <meta property="og:type" content="website" />
190
+ <meta property="og:site_name" content={BUSINESS.name} />
191
+ <meta property="og:locale" content={META.ogLocale} />
192
+ <meta property="og:title" content={fullTitle} />
193
+ <meta property="og:description" content={description} />
194
+ <meta property="og:url" content={canonical} />
195
+ <meta property="og:image" content={ogImage} />
196
+ <meta property="og:image:type" content="image/jpeg" />
197
+ <meta property="og:image:width" content="1200" />
198
+ <meta property="og:image:height" content="630" />
199
+ <meta property="og:image:alt" content={BUSINESS.name} />
200
+ <meta name="twitter:card" content="summary_large_image" />
201
+
202
+ {BUSINESS.city && <meta name="geo.placename" content={BUSINESS.city} />}
203
+
204
+ {/* iOS turns any number that looks like one into a call link — including
205
+ prices and dates. The real phone numbers are explicit tel: links. */}
206
+ <meta name="format-detection" content="telephone=no" />
207
+
208
+ <script is:inline type="application/ld+json" set:html={JSON.stringify(businessSchema)} />
209
+ {schema && <script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />}
210
+
211
+ {/* Under the base, like every other address written by hand here: served
212
+ from `/p/<slug>/` these are three 404s and a page with no icon. */}
213
+ <link rel="icon" href={under('/favicon.svg')} type="image/svg+xml" />
214
+ <link rel="icon" href={under('/favicon-32.png')} sizes="32x32" type="image/png" />
215
+ <link rel="apple-touch-icon" href={under('/favicon.png')} />
216
+
217
+ {/*
218
+ Marks the document as scripted before first paint, so every hidden start
219
+ state can hang on `html.js` and a visitor without JavaScript is never left
220
+ staring at opacity: 0. Wrapped in nothing on purpose — it cannot throw.
221
+ */}
222
+ <script is:inline>document.documentElement.classList.add('js');</script>
223
+ </head>
224
+
225
+ <body class={bodyClass}>
226
+ <slot />
227
+
228
+ <script>
229
+ /*
230
+ * What turns an entrance on.
231
+ *
232
+ * The hidden start states in surface.css all hang on `html.js`, which is
233
+ * set before first paint above; this adds `.is-in` when the block reaches
234
+ * the screen, and then stops watching it. A block never re-enters: an
235
+ * animation that replays on the way back up is the thing clients report
236
+ * as "it keeps flashing".
237
+ *
238
+ * `data-edge="trace"` is watched by the same observer for the same
239
+ * reason — a border that draws itself is an entrance wearing a different
240
+ * name.
241
+ *
242
+ * With no IntersectionObserver everything is simply in. The page is not
243
+ * missing anything; it is the page without the entrance.
244
+ */
245
+ const targets = document.querySelectorAll<HTMLElement>(
246
+ '[data-reveal]:not([data-reveal="none"]), [data-edge="trace"]',
247
+ );
248
+
249
+ /* The stagger's index, set here rather than written into the markup: a
250
+ list rendered from a document has nowhere to carry one, and the second
251
+ list is where somebody forgets. */
252
+ const order = (el: HTMLElement) => {
253
+ if (el.dataset.reveal !== 'stagger') return;
254
+ [...el.children].forEach((child, i) =>
255
+ (child as HTMLElement).style.setProperty('--i', String(i)),
256
+ );
257
+ };
258
+
259
+ if (!('IntersectionObserver' in window)) {
260
+ targets.forEach((el) => {
261
+ order(el);
262
+ el.classList.add('is-in');
263
+ });
264
+ } else {
265
+ const io = new IntersectionObserver(
266
+ (entries) => {
267
+ for (const entry of entries) {
268
+ if (!entry.isIntersecting) continue;
269
+ order(entry.target as HTMLElement);
270
+ entry.target.classList.add('is-in');
271
+ io.unobserve(entry.target);
272
+ }
273
+ },
274
+ /* Slightly inside the fold, so a block animates as it is arrived at
275
+ rather than a moment before. */
276
+ { rootMargin: '0px 0px -10% 0px' },
277
+ );
278
+ targets.forEach((el) => io.observe(el));
279
+ }
280
+ </script>
281
+ </body>
282
+ </html>
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Initialising something on the page, in the two places it can arrive.
3
+ *
4
+ * ── why there are two ───────────────────────────────────────────────────────
5
+ *
6
+ * Everything on a page arrives at load, and a script that queries the document
7
+ * once and attaches what it needs is correct — until something is put on the
8
+ * page afterwards. Then it is not: the new node was never seen, its listeners
9
+ * were never attached, and it sits there inert.
10
+ *
11
+ * That happens here for one reason and it is worth knowing. The studio's admin
12
+ * shows the client a *preview of this site* while they write, and when they put
13
+ * a gallery into a post the built page has no element for it. Rather than
14
+ * drawing its own — it would be guessing at this repository's markup and CSS —
15
+ * it takes the arrangement from a specimen page this build made and puts it
16
+ * where the block goes. What arrives is this site's own markup, in this site's
17
+ * own stylesheet, and everything declarative about it is already right.
18
+ *
19
+ * What is not right is anything a script was supposed to do to it. So the admin
20
+ * fires `fl:placed` on the new node, and this is how a site listens.
21
+ *
22
+ * ── the shape it forces, which is the shape you wanted anyway ───────────────
23
+ *
24
+ * An initialiser registered here is called with a root and must work on it:
25
+ * once with the document at load, and again with a subtree when one appears. A
26
+ * function written that way is also the function that survives view transitions
27
+ * and anything else that swaps part of a page — the browser's own answer,
28
+ * `connectedCallback`, has the same shape for the same reason.
29
+ *
30
+ * Idempotent by scope rather than by flag: it is never called twice with the
31
+ * same node, because the node it is called with is the new one.
32
+ */
33
+ export function onAlive(selector: string, start: (node: Element) => void): void {
34
+ const wake = (root: ParentNode): void => {
35
+ if (root instanceof Element && root.matches(selector)) start(root);
36
+ for (const node of root.querySelectorAll(selector)) start(node);
37
+ };
38
+
39
+ if (document.readyState === 'loading') {
40
+ document.addEventListener('DOMContentLoaded', () => wake(document), { once: true });
41
+ } else {
42
+ wake(document);
43
+ }
44
+
45
+ document.addEventListener('fl:placed', (event) => {
46
+ const at = event.target;
47
+ if (at instanceof Element) wake(at);
48
+ });
49
+ }
@@ -0,0 +1,106 @@
1
+ import { base } from './under';
2
+ import { getCollection, render } from 'astro:content';
3
+ import { COLLECTIONS, type Collection } from '../content/blocks';
4
+
5
+ /**
6
+ * Reading a collection: which entries there are, in the order the site lists
7
+ * them, and without the ones nobody has finished.
8
+ *
9
+ * ── two rules live here and nowhere else ────────────────────────────────────
10
+ *
11
+ * **A hidden entry is built and never listed.** It has to be built — that is
12
+ * how its author looks at it before it is out — and it must appear in no
13
+ * listing, no feed and no sitemap. The studio's edge serves such a page only to
14
+ * a session that is editing the site; everybody else gets a 404. So every place
15
+ * that enumerates entries goes through here, and the one that renders a single
16
+ * entry does not.
17
+ *
18
+ * **The order is the collection's own.** `order.by` names a field of the entry
19
+ * and the catalogue has already refused anything that cannot be sorted by —
20
+ * a date or a number, and required. No declaration means the order somebody
21
+ * arranged by hand, which for a set of works is the honest answer.
22
+ */
23
+
24
+ /**
25
+ * What this file needs of an entry, written out rather than borrowed.
26
+ *
27
+ * The borrowed version was `CollectionEntry<string>`, and it was wrong in the
28
+ * one case that matters most: a site with **no collections at all**. That is
29
+ * not a broken state, it is the state every site is scaffolded in and the state
30
+ * most of them stay in — a landing page has no blog. With `COLLECTIONS` empty,
31
+ * `astro:content` generates the union of collection names as `never`,
32
+ * `CollectionEntry<string>` collapses to `never` too, and every `entry.data`
33
+ * and `entry.id` below stops type-checking against a type that has no
34
+ * properties. Six errors, in the default project.
35
+ *
36
+ * Only `id` and `data` are ever read here, so those are what this says. The
37
+ * cast at the call site is the price, and it is paid in one place.
38
+ */
39
+ export type Entry = { id: string; data: Record<string, unknown> };
40
+
41
+ /** The declaration, by name. */
42
+ export function collectionNamed(name: string): Collection {
43
+ const found = COLLECTIONS.find((one) => one.name === name);
44
+ if (found === undefined) {
45
+ // Loud, and at build time. A listing that quietly renders nothing is a page
46
+ // that looks finished and is empty.
47
+ throw new Error(`no collection named "${name}" in src/content/blocks.ts`);
48
+ }
49
+ return found;
50
+ }
51
+
52
+ /**
53
+ * Every entry of a collection, finished or not, in no particular order.
54
+ *
55
+ * This is what the route that *renders* one entry builds its paths from, and
56
+ * it deliberately does not filter by `visible` — see the note in
57
+ * `[...entry].astro`. Everything that *lists* entries goes through `listed`.
58
+ */
59
+ export async function everyEntry(name: string): Promise<Entry[]> {
60
+ return (await getCollection(name as never)) as unknown as Entry[];
61
+ }
62
+
63
+ /**
64
+ * An entry's body, rendered.
65
+ *
66
+ * `render()` wants the `CollectionEntry` astro:content generates, and `Entry`
67
+ * above is a hand-written subset of it — the same object at runtime, a
68
+ * narrower type at build time. The cast that bridges those two lives here, in
69
+ * one place, rather than at every route that renders a body.
70
+ */
71
+ export async function rendered(entry: Entry) {
72
+ return render(entry as never);
73
+ }
74
+
75
+ /** Every entry of a collection, listable, in order. */
76
+ export async function listed(name: string): Promise<Entry[]> {
77
+ const collection = collectionNamed(name);
78
+ // `as never` for the same reason: with no collections the parameter's type is
79
+ // `never`, and a string is not assignable to it. The name has already been
80
+ // checked against COLLECTIONS by collectionNamed above.
81
+ const all = (await getCollection(name as never)) as unknown as Entry[];
82
+
83
+ const out = all.filter((entry) => entry.data.visible !== false);
84
+ const by = collection.order?.by;
85
+ if (by === undefined || by === 'manual') return out;
86
+
87
+ out.sort((a, b) => compare(a.data[by], b.data[by]));
88
+ return collection.order?.desc === true ? out.reverse() : out;
89
+ }
90
+
91
+ /** Every entry of every collection, listable. Feeds the sitemap and llms.txt. */
92
+ export async function allListed(): Promise<{ collection: Collection; entries: Entry[] }[]> {
93
+ return Promise.all(
94
+ COLLECTIONS.map(async (collection) => ({ collection, entries: await listed(collection.name) })),
95
+ );
96
+ }
97
+
98
+ /** Where one entry lives. `id` is the file's name under the collection. */
99
+ export function href(collection: Collection, entry: Entry): string {
100
+ return `${base()}${collection.prefix.replace(/^\//, '')}/${entry.id}/`;
101
+ }
102
+
103
+ function compare(a: unknown, b: unknown): number {
104
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
105
+ return String(a ?? '').localeCompare(String(b ?? ''));
106
+ }