@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,168 @@
1
+ import type { ImageMetadata } from 'astro';
2
+ import { isUpload, uploaded } from './uploads';
3
+
4
+ /**
5
+ * The site's photographs.
6
+ *
7
+ * The file name in src/assets is the slot: replacing a photo is dropping a file
8
+ * with the same name and rebuilding — no code change, no page edit.
9
+ *
10
+ * Every slot is registered here with alt text in the site's language, because a
11
+ * photo with no alt text is a photo a screen reader announces as its file name,
12
+ * and these sites are mostly photographs.
13
+ *
14
+ * **A missing file is never a build error.** `photo()` returns undefined and
15
+ * `<Shot>` renders a labelled placeholder that keeps the frame's proportions.
16
+ * That is what lets stage 1 proceed while the client is still finding their
17
+ * photographs.
18
+ */
19
+
20
+ export interface Photo {
21
+ /** File base name in src/assets, without the extension. */
22
+ readonly name: string;
23
+ readonly alt: string;
24
+ /** Portrait-shaped files can be given a taller cell by a gallery. */
25
+ readonly tall?: boolean;
26
+ /**
27
+ * This is the person the business *is* — their face, not their hands, their
28
+ * room or their work.
29
+ *
30
+ * It is registered because it changes what may be done to the picture. A
31
+ * treatment that replaces skin colour — `duotone`, `press` — makes a face
32
+ * look like stock art, and on a one-person business that face is the thing
33
+ * being chosen. `<Shot>` refuses to apply one to a photo marked here, and
34
+ * fails the build rather than shipping it. The gentle treatments still work.
35
+ */
36
+ readonly person?: boolean;
37
+ }
38
+
39
+ /**
40
+ * What may be done to a photograph, beyond framing it.
41
+ *
42
+ * Client photographs arrive as a feed: twenty pictures in twenty lights, shot
43
+ * on three phones over two years, with white balance disagreeing between every
44
+ * pair. Cropping them well leaves them still disagreeing, and a page of
45
+ * photographs that disagree reads as a page nobody art-directed — which is
46
+ * exactly what it is.
47
+ *
48
+ * A treatment is what makes them one set. It is CSS over the picture, in the
49
+ * variant's own tokens: no build step, no second copy of any file, nothing to
50
+ * re-run when a photo is replaced, and it degrades to the untouched photograph
51
+ * anywhere the blend modes do not land.
52
+ *
53
+ * The table of what each one is for is in `docs/photos.md` of @jtakeit/astro. Two rules travel with them: **one treatment per site**,
54
+ * because two is the look of a demo rather than a design, and **never a strong
55
+ * one on their face**.
56
+ */
57
+ export type Treatment = 'none' | 'grade' | 'duotone' | 'film' | 'press' | 'recede';
58
+
59
+ export const TREATMENTS: readonly Treatment[] = [
60
+ 'none',
61
+ 'grade',
62
+ 'duotone',
63
+ 'film',
64
+ 'press',
65
+ 'recede',
66
+ ];
67
+
68
+ /** Treatments that replace skin colour, and so may not touch a person. */
69
+ export const STRONG: readonly Treatment[] = ['duotone', 'press'];
70
+
71
+ export const PHOTOS: readonly Photo[] = [
72
+ // { name: 'hero', alt: '' },
73
+ ];
74
+
75
+ // Resolved by Vite at build time: files that do not exist simply never appear.
76
+ const modules = import.meta.glob<{ default: ImageMetadata }>(
77
+ '/src/assets/*.{jpg,jpeg,JPG,JPEG,png,PNG,webp,WEBP,avif,AVIF}',
78
+ { eager: true },
79
+ );
80
+
81
+ const byName = new Map<string, ImageMetadata>();
82
+ const metaByName = new Map<string, Photo>();
83
+
84
+ for (const [path, mod] of Object.entries(modules)) {
85
+ const base = path
86
+ .split('/')
87
+ .pop()!
88
+ .replace(/\.[^.]+$/, '')
89
+ .toLowerCase();
90
+ // First file wins, so hero.jpg and hero.webp cannot fight over one slot.
91
+ if (!byName.has(base)) byName.set(base, mod.default);
92
+ }
93
+
94
+ for (const p of PHOTOS) metaByName.set(p.name, p);
95
+
96
+ /**
97
+ * The picture behind a name.
98
+ *
99
+ * A name is one of two things and this resolves both: a slot in `src/assets`,
100
+ * which is the developer's, or a key like `media/<site>/<hash>.jpg`, which is
101
+ * the owner's and was downloaded into `src/assets/media/` before the build. A
102
+ * component never has to know which it was given — that is the whole point,
103
+ * because a photograph the owner replaced in the admin has to land in exactly
104
+ * the frame the developer's one was in.
105
+ */
106
+ export function photo(name: string): ImageMetadata | undefined {
107
+ if (isUpload(name)) return uploaded(name);
108
+ return byName.get(name.toLowerCase());
109
+ }
110
+
111
+ /** What the photograph actually is, before a frame is chosen for it. */
112
+ export interface Shape {
113
+ readonly width: number;
114
+ readonly height: number;
115
+ /** width / height. 0.8 for a 4:5 phone portrait, 1.78 for 16:9. */
116
+ readonly ratio: number;
117
+ readonly orientation: 'portrait' | 'landscape' | 'square';
118
+ }
119
+
120
+ /**
121
+ * Measure before placing.
122
+ *
123
+ * A frame is a decision about which part of a photograph nobody will ever see,
124
+ * and made by feel it is made wrong: client photos arrive in every orientation,
125
+ * and the 4:5 portrait that carries the whole business loses more than half of
126
+ * itself to a 16:9 band. A page that asks this first can pick the frame from
127
+ * the picture instead of cropping the picture to the frame.
128
+ *
129
+ * const owner = shape('owner');
130
+ * <Shot name="owner" ratio={owner?.orientation === 'portrait' ? '4 / 5' : '3 / 2'} />
131
+ */
132
+ export function shape(name: string): Shape | undefined {
133
+ const src = photo(name);
134
+ if (!src) return undefined;
135
+ const ratio = src.width / src.height;
136
+ return {
137
+ width: src.width,
138
+ height: src.height,
139
+ ratio,
140
+ // A little tolerance: a phone crop is rarely exactly square.
141
+ orientation: ratio > 1.05 ? 'landscape' : ratio < 0.95 ? 'portrait' : 'square',
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Whether this slot holds the face of the person the business is.
147
+ *
148
+ * Unregistered means no — a photo nobody described is not asserted to be
149
+ * anybody. The check is deliberately one-way: it can stop a treatment reaching
150
+ * a face somebody registered, and it cannot know about one nobody did.
151
+ */
152
+ export function isPerson(name: string): boolean {
153
+ return metaByName.get(name)?.person === true;
154
+ }
155
+
156
+ /**
157
+ * Whether this picture came from the admin rather than from the repository.
158
+ *
159
+ * Worth asking in one place: a treatment is a decision about the site's own
160
+ * photographs, and an owner who swapped one in has not agreed to have it
161
+ * duotoned. Nothing enforces that — it is here for a component that wants to.
162
+ */
163
+ export { isUpload } from './uploads';
164
+
165
+ /** Registered alt text, or a neutral fallback so alt is never empty. */
166
+ export function altFor(name: string): string {
167
+ return metaByName.get(name)?.alt ?? '';
168
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Where this build is served from, and how to write an address inside it.
3
+ *
4
+ * ── the bug this exists to make impossible ──────────────────────────────────
5
+ *
6
+ * A site is served two ways and the difference is a path. On its own host it
7
+ * is at the root, so `/preise/` and `/favicon.png` mean what they say. In the
8
+ * studio's preview the same build is served under `https://preview…/p/<slug>/`,
9
+ * where the root is not the site: every one of those addresses leaves it. What
10
+ * a person sees is a page with no stylesheet, no pictures and navigation that
11
+ * 404s — which reads as a broken build rather than as a wrong prefix, and has
12
+ * cost three separate afternoons to diagnose from that symptom.
13
+ *
14
+ * Astro's `base` fixes what *it* emits — the bundled CSS, the optimised
15
+ * images — and it cannot fix a string somebody typed. So every internal
16
+ * address written by hand goes through `under()`, and `fl-catalogue` fails a
17
+ * repository that writes one straight.
18
+ *
19
+ * A build for a real host has a base of `/`, where `under()` changes nothing.
20
+ * That is the point: one way to write an address, correct in both places.
21
+ */
22
+
23
+ /** The path this build is served under, always with its trailing slash. */
24
+ export function base(): string {
25
+ const at = import.meta.env.BASE_URL ?? '/';
26
+ return at.endsWith('/') ? at : `${at}/`;
27
+ }
28
+
29
+ /** A site-absolute path — `/preise/` — served under wherever this build is. */
30
+ export function under(path: string): string {
31
+ return `${base()}${path.replace(/^\//, '')}`;
32
+ }
@@ -0,0 +1,85 @@
1
+ import type { ImageMetadata } from 'astro';
2
+
3
+ /**
4
+ * The pictures the owner uploaded, as local files.
5
+ *
6
+ * ── the two places a picture can be ─────────────────────────────────────────
7
+ *
8
+ * `src/assets/hero.jpg` is the developer's: it is in the repository, it is
9
+ * chosen by whoever built the site, and `photos.ts` resolves it by slot name.
10
+ * This is the other half — a photograph somebody put in through the admin,
11
+ * which arrives as a key like `media/<site>/<hash>.jpg` inside the content
12
+ * document.
13
+ *
14
+ * **They are both local files by the time this runs.** The build's second step
15
+ * downloads every key the content names into `src/assets/`, before `npm run
16
+ * build`, precisely so that a photograph from the admin goes through
17
+ * `astro:assets` exactly as one from the repository does: variants, `srcset`,
18
+ * width hints, the lot. Nothing here fetches anything.
19
+ *
20
+ * That matters more than it sounds. A gallery of thirty-seven pictures served
21
+ * as uploaded is fifteen megabytes; the same gallery through the image pipeline
22
+ * is under two. The difference is not visible to anybody building the site — it
23
+ * is visible to somebody on a phone, on a train, deciding whether to wait.
24
+ *
25
+ * ── on a laptop, before any of that ─────────────────────────────────────────
26
+ *
27
+ * There are no downloaded files while a developer is building the site, so
28
+ * every lookup here answers undefined and `<Shot>` draws its labelled
29
+ * placeholder. That is the same behaviour as a missing slot, and it is what lets
30
+ * a site be built, checked and pushed before it has ever been attached.
31
+ */
32
+
33
+ /**
34
+ * Anything under `src/assets/media/` — which is only ever what the build put
35
+ * there, keyed by the key itself.
36
+ *
37
+ * `import.meta.glob` needs a literal pattern, so this cannot be narrowed by the
38
+ * site id; the map is small (a site's own pictures) and built once.
39
+ */
40
+ const pictures = import.meta.glob<{ default: ImageMetadata }>(
41
+ '/src/assets/media/**/*.{jpg,jpeg,JPG,JPEG,png,PNG,webp,WEBP,avif,AVIF}',
42
+ { eager: true },
43
+ );
44
+
45
+ /**
46
+ * Clips, as URLs rather than as metadata.
47
+ *
48
+ * `astro:assets` optimises pictures and has nothing to do to a video, so a clip
49
+ * takes the ordinary asset path: Vite emits the file and hands back its final
50
+ * address, hashed and cacheable. The clips are already small — `fl-clips` cuts
51
+ * them to a tenth of a megabyte — so there is nothing to gain by doing more.
52
+ */
53
+ const clips = import.meta.glob<string>('/src/assets/media/**/*.{mp4,webm}', {
54
+ eager: true,
55
+ query: '?url',
56
+ import: 'default',
57
+ });
58
+
59
+ const PREFIX = '/src/assets/';
60
+
61
+ function keyed<T>(modules: Record<string, T>): Map<string, T> {
62
+ const out = new Map<string, T>();
63
+ for (const [path, value] of Object.entries(modules)) {
64
+ out.set(path.slice(PREFIX.length), value);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ const byKey = keyed(pictures);
70
+ const clipsByKey = keyed(clips);
71
+
72
+ /** Whether a value from the content document is a key rather than a slot. */
73
+ export function isUpload(value: string): boolean {
74
+ return value.startsWith('media/');
75
+ }
76
+
77
+ /** An uploaded photograph, ready for `<Image>`. */
78
+ export function uploaded(key: string): ImageMetadata | undefined {
79
+ return byKey.get(key)?.default;
80
+ }
81
+
82
+ /** An uploaded clip's address in the built site. */
83
+ export function uploadedClip(key: string): string | undefined {
84
+ return clipsByKey.get(key);
85
+ }
@@ -0,0 +1,207 @@
1
+ ---
2
+ /**
3
+ * One entry of one collection — a blog post, a work, a job.
4
+ *
5
+ * ── one file for every collection, and why it is a starting point ───────────
6
+ *
7
+ * This route generates every entry of every collection the site declares, so a
8
+ * new collection is an entry in `COLLECTIONS` and its listing page, and never a
9
+ * second copy of this file. What it renders is deliberately plain: a heading, a
10
+ * picture where there is one, and the body. Exactly like `Blocks.astro`, it is
11
+ * the thing that makes a site buildable on the first commit, not the design.
12
+ *
13
+ * **The design pass replaces what is inside `<article>`. What must survive it
14
+ * is the annotation.** Every field the owner may edit carries its
15
+ * `data-jtk-path`, `annotation-lint.mjs` fails the build without one, and an
16
+ * element that loses its path stops opening the editor *silently*.
17
+ *
18
+ * An entry's document opens with the post — its title, its date, its cover and
19
+ * its prose, always at `blocks[0].<field>`. Everything after it is whatever the
20
+ * collection lets a post hold (the platform's wiki/30), and its paths are
21
+ * `blocks[N].<field>` exactly as on any other page.
22
+ *
23
+ * ── a hidden entry is built ─────────────────────────────────────────────────
24
+ *
25
+ * `getStaticPaths` does **not** filter by `visible`. That is on purpose: an
26
+ * entry nobody has finished still has to be looked at before it is finished,
27
+ * and the studio's edge serves such a page only to a session that is editing
28
+ * the site. Every place that *lists* entries filters — see `src/lib/entries.ts`
29
+ * — and this one renders.
30
+ */
31
+ import { everyEntry, rendered } from '../lib/entries';
32
+ import { Image } from 'astro:assets';
33
+ import Layout from '../layouts/Layout.astro';
34
+ import { COLLECTIONS } from '../content/blocks';
35
+ import { isUpload, uploaded } from '../lib/uploads';
36
+ import { photo } from '../lib/photos';
37
+
38
+ export async function getStaticPaths() {
39
+ const paths = [];
40
+
41
+ for (const collection of COLLECTIONS) {
42
+ const entries = await everyEntry(collection.name);
43
+ for (const entry of entries) {
44
+ paths.push({
45
+ params: { entry: `${collection.prefix.replace(/^\//, '')}/${entry.id}` },
46
+ props: { entry, collection },
47
+ });
48
+ }
49
+ }
50
+ return paths;
51
+ }
52
+
53
+ const { entry } = Astro.props;
54
+ const data = entry.data as Record<string, unknown>;
55
+ const { Content } = await rendered(entry);
56
+
57
+ /**
58
+ * A value from a document as a list of records.
59
+ *
60
+ * A function rather than a cast written where it is needed, because a `as
61
+ * Record<string, unknown>[]` **inside a `{ }` expression in the template** is
62
+ * not valid Astro: the compiler turns the template into TSX and the cast is
63
+ * read as a comparison, which produced nine errors starting with "Generic type
64
+ * 'Record' requires 2 type argument(s)" and cascading to the end of the file.
65
+ * Casts belong in the frontmatter; the template calls this.
66
+ *
67
+ * It also answers for a document that holds something other than a list, which
68
+ * a hand-edited one can.
69
+ */
70
+ function records(value: unknown): Record<string, unknown>[] {
71
+ return Array.isArray(value) ? (value as Record<string, unknown>[]) : [];
72
+ }
73
+
74
+ /** Everything after the post itself, in the order the owner put it in. */
75
+ const rest = records(data.rest);
76
+
77
+ const title = typeof data.title === 'string' ? data.title : '';
78
+ const excerpt = typeof data.excerpt === 'string' ? data.excerpt : '';
79
+ const cover = typeof data.cover === 'string' && data.cover !== ''
80
+ ? (isUpload(data.cover) ? uploaded(data.cover) : photo(data.cover))
81
+ : undefined;
82
+
83
+ /**
84
+ * The entry as a thing search engines and assistants can quote.
85
+ *
86
+ * `datePublished` is the field the catalogue reserves, which is why it may be
87
+ * read by name here: the five keys of an entry are the admin's vocabulary and
88
+ * not a site's to rename.
89
+ */
90
+ /**
91
+ * What a search result says, where somebody wrote it.
92
+ *
93
+ * The admin has a card for these two — the title in a result list and the
94
+ * sentence under it — and they appear nowhere on the page, which is exactly why
95
+ * they are edited in a form rather than by tapping. Empty, a post is found by
96
+ * its own heading and its announcement, so this is a fallback and never a
97
+ * second pair of fields that must be filled in.
98
+ */
99
+ const seo = (data.seo ?? {}) as Record<string, unknown>;
100
+ const said = (key: string): string =>
101
+ typeof seo[key] === 'string' ? (seo[key] as string).trim() : '';
102
+
103
+ const schema = {
104
+ '@type': 'Article',
105
+ headline: title,
106
+ datePublished: typeof data.date === 'string' ? data.date : undefined,
107
+ description: excerpt || undefined,
108
+ };
109
+ ---
110
+
111
+ <Layout title={said('title') || title} description={said('description') || excerpt} schema={schema}>
112
+ <main id="content">
113
+ <article class="entry">
114
+ <h1 data-jtk-path="blocks[0].title">{title}</h1>
115
+
116
+ {
117
+ cover && (
118
+ <Image
119
+ src={cover}
120
+ alt={title}
121
+ data-jtk-path="blocks[0].cover"
122
+ widths={[640, 1280, 1920]}
123
+ sizes="(min-width: 900px) 60rem, 92vw"
124
+ layout="none"
125
+ />
126
+ )
127
+ }
128
+
129
+ {/*
130
+ One element for the whole body, because the body is one field. The
131
+ editor opens it full screen — nine hundred words is not a popover — and
132
+ what is inside is markdown Astro rendered, pictures and all.
133
+ */}
134
+ <div class="entry-body" data-jtk-path="blocks[0].body"><Content /></div>
135
+
136
+ {/*
137
+ And the rest of the post, in the order the owner put it in.
138
+
139
+ A post is a *sequence*: its opening prose above, then runs of prose and
140
+ whatever else it holds, interleaved. A post is written rather than
141
+ designed, so the owner arranges these in the admin — but *which* types
142
+ may be in one is this repository's to say, in `COLLECTIONS[].body`, and
143
+ `text` must be among them or the writing cannot be broken by anything.
144
+
145
+ A gallery arrives with the arrangement it is in — `block.view`, always
146
+ one of the ones its type declares. Draw each one differently: that menu
147
+ is the whole reason the owner may choose, and a `view` that changes
148
+ nothing on the page is a choice this repository lied about offering.
149
+
150
+ Plain on purpose, exactly like `Blocks.astro`: what the design pass
151
+ replaces is what is inside, and what must survive it is the
152
+ `data-jtk-path` on every field the owner may edit.
153
+ */}
154
+ {
155
+ rest.map((block, offset) => {
156
+ const at = offset + 1;
157
+
158
+ /*
159
+ * A run of prose. A post is a sequence — prose, a gallery, more
160
+ * prose — and this is what the runs after the first are. Rendered by
161
+ * the loader, because Astro renders one body per entry and knows
162
+ * nothing about a second.
163
+ */
164
+ if (block.type === 'text') {
165
+ return (
166
+ <div
167
+ class="entry-body"
168
+ data-jtk-path={`blocks[${at}].body`}
169
+ set:html={String(block.html ?? '')}
170
+ />
171
+ );
172
+ }
173
+
174
+ const pictures = records(block.work ?? block.items);
175
+
176
+ return (
177
+ <div class="entry-wall" data-view={String(block.view ?? '')}>
178
+ {pictures.map((picture, index) => {
179
+ /*
180
+ * The same two answers the cover has, for the same reason: a
181
+ * value is either a key in the studio's bucket, which the build
182
+ * downloaded, or the name of a photograph this repository
183
+ * ships. Reading only the first renders nothing at all for the
184
+ * second, with no error until the build refuses the undefined.
185
+ */
186
+ const src = String(picture.src ?? '');
187
+ const file = isUpload(src) ? uploaded(src) : photo(src);
188
+ if (!file) return null;
189
+
190
+ return (
191
+ <Image
192
+ src={file}
193
+ alt={String(picture.alt ?? '')}
194
+ data-jtk-path={`blocks[${at}].work[${index}].src`}
195
+ widths={[640, 1280]}
196
+ sizes="(min-width: 900px) 30rem, 92vw"
197
+ layout="none"
198
+ />
199
+ );
200
+ })}
201
+ </div>
202
+ );
203
+ })
204
+ }
205
+ </article>
206
+ </main>
207
+ </Layout>
@@ -0,0 +1,64 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { COLLECTIONS, words } from '../content/blocks';
3
+ import { INDEXABLE, canonicalFor } from '../data/site';
4
+ import { allListed, href } from '../lib/entries';
5
+
6
+ /**
7
+ * One feed per collection, at `/blog/rss.xml`.
8
+ *
9
+ * Written by hand rather than through `@astrojs/rss`, for the same reason
10
+ * `sitemap.xml.ts` is: it is thirty lines, it has no dependency, and what goes
11
+ * in it is a decision rather than a default.
12
+ *
13
+ * Hidden entries are absent, because `allListed` is the only way anything here
14
+ * enumerates them. Empty while INDEXABLE is false — a preview announcing itself
15
+ * to a reader is a preview competing with the site it previews.
16
+ */
17
+ export async function getStaticPaths() {
18
+ return COLLECTIONS.map((collection) => ({
19
+ params: { feed: `${collection.prefix.replace(/^\//, '')}/rss` },
20
+ props: { name: collection.name },
21
+ }));
22
+ }
23
+
24
+ export const GET: APIRoute = async ({ props }) => {
25
+ const name = (props as { name: string }).name;
26
+ const all = await allListed();
27
+ const found = all.find((one) => one.collection.name === name);
28
+ const entries = INDEXABLE && found ? found.entries : [];
29
+
30
+ const items = entries
31
+ .map((entry) => {
32
+ const data = entry.data as Record<string, unknown>;
33
+ const link = canonicalFor(href(found!.collection, entry));
34
+ return ` <item>
35
+ <title>${escapeXML(String(data.title ?? ''))}</title>
36
+ <link>${link}</link>
37
+ <guid isPermaLink="true">${link}</guid>
38
+ ${data.date ? `<pubDate>${new Date(String(data.date)).toUTCString()}</pubDate>` : ''}
39
+ ${data.excerpt ? `<description>${escapeXML(String(data.excerpt))}</description>` : ''}
40
+ </item>`;
41
+ })
42
+ .join('\n');
43
+
44
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
45
+ <rss version="2.0">
46
+ <channel>
47
+ <title>${escapeXML(words(found?.collection.label) || name)}</title>
48
+ <link>${canonicalFor(found?.collection.prefix ?? '/')}</link>
49
+ <description>${escapeXML(words(found?.collection.label) || name)}</description>
50
+ ${items}
51
+ </channel>
52
+ </rss>
53
+ `;
54
+
55
+ return new Response(body, { headers: { 'content-type': 'application/xml; charset=utf-8' } });
56
+ };
57
+
58
+ function escapeXML(text: string): string {
59
+ return text
60
+ .replace(/&/g, '&amp;')
61
+ .replace(/</g, '&lt;')
62
+ .replace(/>/g, '&gt;')
63
+ .replace(/"/g, '&quot;');
64
+ }
@@ -0,0 +1,90 @@
1
+ ---
2
+ /**
3
+ * The landing page.
4
+ *
5
+ * A deliberately plain starting point: the structure and every visual decision
6
+ * come from the brief, through the frontend-design skill, one variant at a
7
+ * time. Nothing here is a design suggestion — it is a page that builds, with a
8
+ * correct <head>, a working form, a photo slot and every section of the content
9
+ * document rendered, so the first commit of a new project is already deployable
10
+ * and a generated one is already viewable.
11
+ *
12
+ * ── data-jtk-path, and why it is here from the first commit ──────────────────
13
+ *
14
+ * The studio's admin edits this site by tapping the text on it. That works
15
+ * because every element whose content comes from `jtk/content/index.json`
16
+ * carries the path of the field it came from, and `flPath` builds those from
17
+ * the document rather than from anything written by hand.
18
+ *
19
+ * The build fails without them — `annotation-lint.mjs` compares the published
20
+ * content against the generated HTML and refuses a page that dropped one. It is
21
+ * a gate rather than a convention, because an element that loses its path stops
22
+ * opening the editor **silently**, and nobody finds out for a week.
23
+ *
24
+ * So: whatever a variant does to this page, every field it renders keeps its
25
+ * `data-jtk-path={flPath(...)}`.
26
+ *
27
+ * ── the hero's ground ───────────────────────────────────────────────────────
28
+ *
29
+ * `jtk/design.json` holds the palette and which of the ten fields to
30
+ * paint. It is a document rather than props written into this file for the same
31
+ * reason the copy is: it can then be changed without an agent, and the studio
32
+ * bar's live accent drives the same tokens. With the document empty — a fresh
33
+ * `fl-init`, before anything has been designed — the hero is a plain section on
34
+ * the kit's own ground, which is what a scaffold should be.
35
+ *
36
+ * ── the surface ─────────────────────────────────────────────────────────────
37
+ *
38
+ * The same document's `surface` block holds the other three choices: the ground
39
+ * the page is printed on, the edge its blocks are drawn with, and how they
40
+ * arrive. Empty means none, and none is a real choice rather than a missing
41
+ * one — on a page carrying strong photographs it is usually the right one. See
42
+ * `docs/surface.md` of @jtakeit/astro.
43
+ */
44
+ import Layout from '../layouts/Layout.astro';
45
+ import Hero from '../components/Hero.astro';
46
+ import Blocks from '../components/Blocks.astro';
47
+ import HeroField from '../components/motion/HeroField.astro';
48
+ import Pattern from '../components/surface/Pattern.astro';
49
+ import { HOME } from '../copy/{{LOCALE}}';
50
+ import design from '../../jtk/design.json';
51
+
52
+ const painted =
53
+ design.field !== '' && Array.isArray(design.colors) && design.colors.length === 4;
54
+
55
+ const surface = design.surface ?? {};
56
+ const grounded = Boolean(surface.pattern);
57
+ ---
58
+
59
+ <Layout title={HOME.title} description={HOME.description} suffix={false}>
60
+ {
61
+ grounded && (
62
+ <Pattern
63
+ pattern={surface.pattern as never}
64
+ scope={(surface.scope || 'page') as never}
65
+ size={surface.size || undefined}
66
+ weight={surface.weight || undefined}
67
+ opacity={surface.opacity || undefined}
68
+ draw={surface.draw === true}
69
+ />
70
+ )
71
+ }
72
+
73
+ <main id="content">
74
+ {painted ? (
75
+ <HeroField
76
+ field={design.field as never}
77
+ ground="var(--fl-ground)"
78
+ colors={['var(--fl-c1)', 'var(--fl-c2)', 'var(--fl-c3)', 'var(--fl-c4)']}
79
+ scrim={design.scrim !== false}
80
+ class="hero"
81
+ >
82
+ <Hero />
83
+ </HeroField>
84
+ ) : (
85
+ <section class="hero"><Hero /></section>
86
+ )}
87
+
88
+ <Blocks />
89
+ </main>
90
+ </Layout>