@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.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/bin/jtk.mjs +41 -0
- package/docs/booking.md +164 -0
- package/docs/catalogue.md +459 -0
- package/docs/collections.md +249 -0
- package/docs/css.md +86 -0
- package/docs/gallery.md +127 -0
- package/docs/hero-motion.md +189 -0
- package/docs/kit.md +454 -0
- package/docs/languages.md +182 -0
- package/docs/lead-form.md +109 -0
- package/docs/pages.md +193 -0
- package/docs/photos.md +314 -0
- package/docs/scaffold.md +75 -0
- package/docs/shapes.md +140 -0
- package/docs/surface.md +187 -0
- package/lib/catalogue.mjs +1678 -0
- package/lib/codes.mjs +171 -0
- package/lib/create.mjs +282 -0
- package/package.json +16 -0
- package/template/astro.config.mjs +84 -0
- package/template/figures.mjs +122 -0
- package/template/gitignore +16 -0
- package/template/jtakeit-meta.mjs +112 -0
- package/template/jtk/content/index.json +38 -0
- package/template/jtk/design.json +24 -0
- package/template/markdown.mjs +36 -0
- package/template/package-lock.json +5320 -0
- package/template/package.json +26 -0
- package/template/specimens.mjs +46 -0
- package/template/src/components/Blocks.astro +151 -0
- package/template/src/components/BookingForm.astro +506 -0
- package/template/src/components/Clip.astro +155 -0
- package/template/src/components/Hero.astro +66 -0
- package/template/src/components/LeadForm.astro +347 -0
- package/template/src/components/OpeningHours.astro +69 -0
- package/template/src/components/Pile.astro +185 -0
- package/template/src/components/Shot.astro +472 -0
- package/template/src/components/gallery/Gallery.astro +381 -0
- package/template/src/components/gallery/galleries.ts +139 -0
- package/template/src/components/motion/HeroField.astro +520 -0
- package/template/src/components/motion/fields.ts +430 -0
- package/template/src/components/surface/Pattern.astro +278 -0
- package/template/src/components/surface/patterns.ts +187 -0
- package/template/src/content/blocks.ts +758 -0
- package/template/src/content.config.ts +19 -0
- package/template/src/copy/LOCALE.ts +324 -0
- package/template/src/data/site.ts +137 -0
- package/template/src/layouts/Layout.astro +282 -0
- package/template/src/lib/alive.ts +49 -0
- package/template/src/lib/entries.ts +106 -0
- package/template/src/lib/entryLoader.ts +315 -0
- package/template/src/lib/noise.ts +26 -0
- package/template/src/lib/page.ts +287 -0
- package/template/src/lib/photos.ts +168 -0
- package/template/src/lib/under.ts +32 -0
- package/template/src/lib/uploads.ts +85 -0
- package/template/src/pages/[...entry].astro +207 -0
- package/template/src/pages/[...feed].xml.ts +64 -0
- package/template/src/pages/index.astro +90 -0
- package/template/src/pages/llms.txt.ts +50 -0
- package/template/src/pages/privacy.astro +59 -0
- package/template/src/pages/robots.txt.ts +21 -0
- package/template/src/pages/sitemap.xml.ts +50 -0
- package/template/src/styles/global.css +411 -0
- package/template/src/styles/surface.css +375 -0
- package/template/tsconfig.json +5 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import type { Loader, LoaderContext } from 'astro/loaders';
|
|
2
|
+
import { createSatteriMarkdownProcessor } from '@astrojs/markdown-satteri';
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { join, relative, sep } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { HAST_PLUGINS } from '../../markdown.mjs';
|
|
7
|
+
import { BLOCKS, type Collection } from '../content/blocks';
|
|
8
|
+
// The one place that says what a specimen is called. The meta plugin needs the
|
|
9
|
+
// same answer — an address nobody can find is a draft the edge hands to
|
|
10
|
+
// anybody — and a rule written twice is a rule that ends up meaning two things.
|
|
11
|
+
import { specimenId } from '../../specimens.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reading a collection's entries out of the repository.
|
|
15
|
+
*
|
|
16
|
+
* ── why this is a loader of ours and not `glob` ─────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* An entry used to be markdown with frontmatter, and Astro's glob loader read
|
|
19
|
+
* exactly that shape with nothing of ours in between. It could hold one block,
|
|
20
|
+
* because the body *was* the file — and a post that wants a gallery between two
|
|
21
|
+
* paragraphs cannot be written that way at all (the platform's wiki/30).
|
|
22
|
+
*
|
|
23
|
+
* So an entry is a document of blocks now, like every other page: a `.json`
|
|
24
|
+
* file with `blocks`, the first of which is the post — its title, its date, its
|
|
25
|
+
* cover and its prose. Everything after it is whatever the collection lets a
|
|
26
|
+
* post hold, rendered by this site's own components.
|
|
27
|
+
*
|
|
28
|
+
* Which leaves one job glob cannot do: the prose is a *string* now, and it has
|
|
29
|
+
* to become HTML through the same markdown pipeline as everything else.
|
|
30
|
+
*
|
|
31
|
+
* ── and not through `ctx.renderMarkdown`, which is the trap ────────────────
|
|
32
|
+
*
|
|
33
|
+
* That is the sanctioned call and it builds its renderer **without the plugins
|
|
34
|
+
* this site configured** — Astro's own code, not a setting: it passes `image`,
|
|
35
|
+
* `syntaxHighlight`, `shikiConfig`, `gfm` and `smartypants`, and stops. A body
|
|
36
|
+
* rendered through it silently loses `figures.mjs`, so a row of three
|
|
37
|
+
* photographs comes out as three paragraphs. Measured, not guessed at: the
|
|
38
|
+
* markers Astro leaves for its image pipeline were still in the HTML.
|
|
39
|
+
*
|
|
40
|
+
* So the same renderer is built here with the same options, from
|
|
41
|
+
* `markdown.mjs`, which is the one place either this or `astro.config.mjs`
|
|
42
|
+
* reads. The `fileURL` is what makes `` resolve **beside the
|
|
43
|
+
* entry's own file** — which is exactly where the build downloads it, so the
|
|
44
|
+
* media pipeline is untouched by any of this — and `imagePaths` is what Astro
|
|
45
|
+
* reads back to optimise them.
|
|
46
|
+
*/
|
|
47
|
+
export function entries(collection: Collection): Loader {
|
|
48
|
+
return {
|
|
49
|
+
name: 'jtakeit-entries',
|
|
50
|
+
async load(ctx: LoaderContext) {
|
|
51
|
+
const base = join(process.cwd(), 'jtk', 'content', collection.prefix.replace(/^\//, ''));
|
|
52
|
+
// No `image:` here on purpose. The processor's image option is
|
|
53
|
+
// `{ domains, remotePatterns }` — which remote pictures may be fetched —
|
|
54
|
+
// and not the site's `{ layout }`. Passing the latter type-checked as
|
|
55
|
+
// nothing in common and meant nothing at runtime; the layout is applied
|
|
56
|
+
// when Astro replaces the markers, from the site config.
|
|
57
|
+
const renderer = await createSatteriMarkdownProcessor({ hastPlugins: HAST_PLUGINS });
|
|
58
|
+
ctx.store.clear();
|
|
59
|
+
/** Whatever this collection's own entries show, for the specimens below. */
|
|
60
|
+
const seen: string[] = [];
|
|
61
|
+
|
|
62
|
+
for (const file of await jsonUnder(base)) {
|
|
63
|
+
const raw = await readFile(file, 'utf8');
|
|
64
|
+
const document = JSON.parse(raw) as {
|
|
65
|
+
blocks?: Record<string, unknown>[];
|
|
66
|
+
visible?: boolean;
|
|
67
|
+
seo?: Record<string, unknown>;
|
|
68
|
+
};
|
|
69
|
+
const blocks = document.blocks ?? [];
|
|
70
|
+
|
|
71
|
+
// blocks[0] is the post. A document with none is not corrupt — it is an
|
|
72
|
+
// entry somebody emptied — and refusing to load it would take the only
|
|
73
|
+
// page from which it can be seen and fixed.
|
|
74
|
+
const post = blocks[0] ?? {};
|
|
75
|
+
const body = typeof post.body === 'string' ? post.body : '';
|
|
76
|
+
|
|
77
|
+
const id = relative(base, file).split(sep).join('/').replace(/\.json$/, '');
|
|
78
|
+
const rendered = await drawn(renderer, body, pathToFileURL(file));
|
|
79
|
+
|
|
80
|
+
ctx.store.set({
|
|
81
|
+
id,
|
|
82
|
+
data: {
|
|
83
|
+
...post,
|
|
84
|
+
/** Everything after the post, for the template to render. */
|
|
85
|
+
rest: await written(renderer, blocks.slice(1), file),
|
|
86
|
+
/** Whether the site lists it. The page itself is always built. */
|
|
87
|
+
visible: document.visible !== false,
|
|
88
|
+
/*
|
|
89
|
+
* What the post is found by, where it is not what it is called.
|
|
90
|
+
*
|
|
91
|
+
* The admin writes these two beside the post — a title for a result
|
|
92
|
+
* list and the sentence under it — and they are an override, not a
|
|
93
|
+
* requirement: a post that says nothing here is found by its own
|
|
94
|
+
* heading and its announcement. Read in `[...entry].astro`, which
|
|
95
|
+
* is the only place that knows what a head tag is.
|
|
96
|
+
*/
|
|
97
|
+
seo: document.seo ?? {},
|
|
98
|
+
},
|
|
99
|
+
rendered,
|
|
100
|
+
/*
|
|
101
|
+
* The half that is easy to leave out and impossible to notice.
|
|
102
|
+
*
|
|
103
|
+
* `imagePaths` in the rendered metadata is what the *runtime* reads
|
|
104
|
+
* to swap a marker for an optimised picture. This is what makes the
|
|
105
|
+
* pictures exist to swap in: the store builds `content-assets` from
|
|
106
|
+
* `assetImports` and from nothing else. Without it the build passes,
|
|
107
|
+
* the page renders, and every picture in a post is an `<img>` with a
|
|
108
|
+
* marker where its `src` should be — which is exactly what happened
|
|
109
|
+
* the first time, and the reason there is a test for it.
|
|
110
|
+
*/
|
|
111
|
+
assetImports: rendered.metadata.imagePaths,
|
|
112
|
+
digest: ctx.generateDigest(raw),
|
|
113
|
+
filePath: relative(process.cwd(), file),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
for (const found of pictures(blocks)) seen.push(found);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await specimens(ctx, renderer, collection, seen);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One page per arrangement, built with everything else and listed nowhere.
|
|
126
|
+
*
|
|
127
|
+
* ── why a site builds a page nobody will read ───────────────────────────────
|
|
128
|
+
*
|
|
129
|
+
* The admin's preview is the built site with the draft patched onto it, and
|
|
130
|
+
* that works while the shape is the shape the build knows. Put a gallery into a
|
|
131
|
+
* post and it stops: the page has no element for it, and only a build can make
|
|
132
|
+
* one — the arrangement is this repository's markup and this repository's CSS,
|
|
133
|
+
* and an admin that drew its own would be guessing at both.
|
|
134
|
+
*
|
|
135
|
+
* So the build draws one of each, once. The admin takes the markup from here
|
|
136
|
+
* and puts it where the new block goes, and what somebody sees is this site's
|
|
137
|
+
* own arrangement rather than an approximation of it.
|
|
138
|
+
*
|
|
139
|
+
* ── rendered by the route that renders the real thing ───────────────────────
|
|
140
|
+
*
|
|
141
|
+
* Not by a page of its own. A fragment rendered somewhere else is styled
|
|
142
|
+
* somewhere else — `:nth-child`, the classes of a parent, a grid that counts
|
|
143
|
+
* its children — and the copy would be subtly wrong in a way nobody could see
|
|
144
|
+
* until it was live. These are entries, so they go through `[...entry].astro`
|
|
145
|
+
* inside the same article as every other post, and the context is identical by
|
|
146
|
+
* construction.
|
|
147
|
+
*
|
|
148
|
+
* ── invisible, and that is not a trick ──────────────────────────────────────
|
|
149
|
+
*
|
|
150
|
+
* `visible: false` is the same flag a post nobody has finished carries: every
|
|
151
|
+
* place that *lists* entries skips it, the sitemap never sees it, and the
|
|
152
|
+
* studio's edge serves it only to a session that is editing the site. Which is
|
|
153
|
+
* exactly who asks for it.
|
|
154
|
+
*
|
|
155
|
+
* The pictures are the site's own, taken from the entries it already has. A
|
|
156
|
+
* collection with no photographs anywhere gets an empty arrangement — the
|
|
157
|
+
* container without its contents, which is still the right container.
|
|
158
|
+
*/
|
|
159
|
+
async function specimens(
|
|
160
|
+
ctx: LoaderContext,
|
|
161
|
+
renderer: Renderer,
|
|
162
|
+
collection: Collection,
|
|
163
|
+
pictures: string[],
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
for (const kind of collection.body ?? []) {
|
|
166
|
+
const type = BLOCKS.find((one) => one.type === kind);
|
|
167
|
+
if (type === undefined) continue;
|
|
168
|
+
|
|
169
|
+
for (const view of type.views ?? [undefined]) {
|
|
170
|
+
const block: Record<string, unknown> = { _key: `${kind}-1`, type: kind, v: type.v };
|
|
171
|
+
if (view !== undefined) block.view = view.key;
|
|
172
|
+
|
|
173
|
+
const gallery = type.fields.find((one) => one.kind === 'media' && one.multiple === true);
|
|
174
|
+
if (gallery !== undefined) {
|
|
175
|
+
const wanted = Math.max(1, view?.min ?? 1);
|
|
176
|
+
block[gallery.key] = pictures.slice(0, wanted).map((src) => ({ src, alt: '' }));
|
|
177
|
+
}
|
|
178
|
+
for (const field of type.fields) {
|
|
179
|
+
if (field.kind === 'text' && block[field.key] === undefined) block[field.key] = ' ';
|
|
180
|
+
if (field.kind === 'markdown' && block[field.key] === undefined) block[field.key] = ' ';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
ctx.store.set({
|
|
184
|
+
id: specimenId(kind, view?.key),
|
|
185
|
+
data: {
|
|
186
|
+
title: ' ',
|
|
187
|
+
rest: await written(renderer, [block], 'specimen'),
|
|
188
|
+
visible: false,
|
|
189
|
+
seo: {},
|
|
190
|
+
},
|
|
191
|
+
rendered: { html: '', metadata: {} },
|
|
192
|
+
digest: ctx.generateDigest(JSON.stringify(block)),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Every picture a document points at, in the order it points at them. */
|
|
199
|
+
function* pictures(blocks: Record<string, unknown>[]): Generator<string> {
|
|
200
|
+
for (const block of blocks) {
|
|
201
|
+
for (const value of Object.values(block)) {
|
|
202
|
+
if (!Array.isArray(value)) continue;
|
|
203
|
+
for (const item of value) {
|
|
204
|
+
const src = (item as Record<string, unknown> | null)?.src;
|
|
205
|
+
if (typeof src === 'string' && src !== '') yield src;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The blocks after the post, with every run of prose in them turned to HTML.
|
|
213
|
+
*
|
|
214
|
+
* A post is a sequence — prose, a gallery, more prose — and the runs after the
|
|
215
|
+
* first are `text` blocks. They are rendered here rather than by Astro's own
|
|
216
|
+
* `render()`, which does one body per entry and knows nothing about a second.
|
|
217
|
+
*
|
|
218
|
+
* Prose only: a picture in a post is a block chosen from the menu this
|
|
219
|
+
* repository declares, not an `![]()` written into the text. The build says so
|
|
220
|
+
* rather than rendering a broken image, because the failure is otherwise a
|
|
221
|
+
* marker in the HTML that nobody sees until the page is live.
|
|
222
|
+
*/
|
|
223
|
+
async function written(
|
|
224
|
+
renderer: Renderer,
|
|
225
|
+
blocks: Record<string, unknown>[],
|
|
226
|
+
file: string,
|
|
227
|
+
): Promise<Record<string, unknown>[]> {
|
|
228
|
+
const out: Record<string, unknown>[] = [];
|
|
229
|
+
|
|
230
|
+
for (const block of blocks) {
|
|
231
|
+
if (block.type !== 'text') {
|
|
232
|
+
out.push(block);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const body = typeof block.body === 'string' ? block.body : '';
|
|
237
|
+
if (/!\[[^\]]*\]\(/.test(body)) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`${file}: a run of text in a post may not hold a picture — a picture is a block. ` +
|
|
240
|
+
'See COLLECTIONS[].body and the block menu in the admin.',
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { code } = await renderer.render(body, { frontmatter: {}, fileURL: pathToFileURL(file) });
|
|
245
|
+
out.push({ ...block, html: code });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The prose as HTML, in the shape the content store expects.
|
|
253
|
+
*
|
|
254
|
+
* `imagePaths` is the half that is easy to leave out and impossible to notice:
|
|
255
|
+
* it is how Astro knows which files to optimise, and without it the markers it
|
|
256
|
+
* left in the HTML are never replaced — a picture in a post renders as an
|
|
257
|
+
* `<img>` with no `src` at all.
|
|
258
|
+
*/
|
|
259
|
+
async function drawn(renderer: Renderer, body: string, fileURL: URL) {
|
|
260
|
+
const { code, metadata } = await renderer.render(body, { frontmatter: {}, fileURL });
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
html: code,
|
|
264
|
+
metadata: {
|
|
265
|
+
...metadata,
|
|
266
|
+
imagePaths: [...(metadata.localImagePaths ?? []), ...(metadata.remoteImagePaths ?? [])],
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The renderer, taken from the function that makes one rather than described
|
|
273
|
+
* again here.
|
|
274
|
+
*
|
|
275
|
+
* It used to be a hand-written signature, and the copy drifted: it said
|
|
276
|
+
* `metadata` was `Record<string, string[] | undefined>`, while the real one is
|
|
277
|
+
* `{ headings, localImagePaths, remoteImagePaths, frontmatter }` — `headings`
|
|
278
|
+
* is not an array of strings and `frontmatter` is not an array at all. Four
|
|
279
|
+
* errors, and the sort that gets worse rather than better, because the
|
|
280
|
+
* description was in this file and the thing described is in a package that
|
|
281
|
+
* keeps moving.
|
|
282
|
+
*
|
|
283
|
+
* Deriving it leaves nothing to drift. It is deliberately NOT
|
|
284
|
+
* `import type { MarkdownRenderer } from '@astrojs/internal-helpers/markdown'`:
|
|
285
|
+
* that package is not a dependency of this project, only a transitive one of
|
|
286
|
+
* astro, and reaching across that line is a build that breaks on somebody
|
|
287
|
+
* else's refactor.
|
|
288
|
+
*/
|
|
289
|
+
type Renderer = Awaited<ReturnType<typeof createSatteriMarkdownProcessor>>;
|
|
290
|
+
|
|
291
|
+
/** Every `.json` under a collection, however deeply a site nests them. */
|
|
292
|
+
async function jsonUnder(dir: string): Promise<string[]> {
|
|
293
|
+
let found: string[] = [];
|
|
294
|
+
|
|
295
|
+
let listing;
|
|
296
|
+
try {
|
|
297
|
+
listing = await readdir(dir, { withFileTypes: true });
|
|
298
|
+
} catch {
|
|
299
|
+
// A collection nobody has written an entry for yet. Not an error: a site
|
|
300
|
+
// ships its blog before its first post.
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const item of listing) {
|
|
305
|
+
const full = join(dir, item.name);
|
|
306
|
+
// A collection's own pictures land beside its entries at build time. They
|
|
307
|
+
// are not entries, and reading one as such is a stack trace.
|
|
308
|
+
if (item.isDirectory()) {
|
|
309
|
+
if (item.name !== 'media') found = found.concat(await jsonUnder(full));
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (item.name.endsWith('.json')) found.push(full);
|
|
313
|
+
}
|
|
314
|
+
return found;
|
|
315
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One noise tile, for everything in the kit that needs grain.
|
|
3
|
+
*
|
|
4
|
+
* Two places wanted it — the `grain` ground in components/surface/patterns.ts
|
|
5
|
+
* and the `film` treatment on a photograph — and two copies of a texture is how
|
|
6
|
+
* one of them gets retuned and the other does not. It lives here because
|
|
7
|
+
* src/lib is never pruned from a handoff, so neither caller has to own it.
|
|
8
|
+
*
|
|
9
|
+
* Turbulence makes the noise; the colour matrix throws the colour away and
|
|
10
|
+
* keeps the red channel as *alpha*, which is what a CSS mask reads.
|
|
11
|
+
* `stitchTiles` is what lets it repeat without a seam.
|
|
12
|
+
*/
|
|
13
|
+
export const GRAIN_TILE =
|
|
14
|
+
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.86' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E\")";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How big the tile is drawn.
|
|
18
|
+
*
|
|
19
|
+
* `GRAIN_SIZE` is what it was authored at, and it is right for a ground: a
|
|
20
|
+
* page-sized layer needs a tile large enough that the repeat is never obvious.
|
|
21
|
+
* A photograph is a few hundred pixels wide, so the same tile covers half of it
|
|
22
|
+
* and the noise reads as blotches and streaks rather than as grain — the smaller
|
|
23
|
+
* value scales it down into something the eye takes for film.
|
|
24
|
+
*/
|
|
25
|
+
export const GRAIN_SIZE = '180px';
|
|
26
|
+
export const GRAIN_SIZE_PHOTO = '90px';
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type { Shown } from '../copy/{{LOCALE}}';
|
|
2
|
+
import { LOCALES } from '../content/blocks';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Any page's content document, for a site that has more than one.
|
|
6
|
+
*
|
|
7
|
+
* ── what a second page costs, and why it is this file ───────────────────────
|
|
8
|
+
*
|
|
9
|
+
* The home page reads its own document through `src/copy/<locale>.ts`, which is
|
|
10
|
+
* that page's *vocabulary*: `HOME.hero.title`, `HOME.cta.label`, shaped by hand
|
|
11
|
+
* to the blocks this site happens to have. It is rewritten per project, which
|
|
12
|
+
* is exactly why the generic reader is not in it — an agent reshaping the home
|
|
13
|
+
* page's copy must not be able to break how every other page is read.
|
|
14
|
+
*
|
|
15
|
+
* So: a second page imports this, names its own path, and gets the same three
|
|
16
|
+
* things the home page has — the blocks in order, a way to reach one, and the
|
|
17
|
+
* annotation path for every field it renders.
|
|
18
|
+
*
|
|
19
|
+
* ---
|
|
20
|
+
* import { readPage } from '../lib/page';
|
|
21
|
+
* const prices = readPage('/prices');
|
|
22
|
+
* const { at, of } = prices.block('rates');
|
|
23
|
+
* ---
|
|
24
|
+
* <h2 data-jtk-path={prices.pathAt(at, 'title')}>{prices.str(of, 'title')}</h2>
|
|
25
|
+
*
|
|
26
|
+
* ── the document has to be in the repository ────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* `jtk/content/prices.json` is committed with empty strings in it, the
|
|
29
|
+
* same way `index.json` is, and for the same reason: a static import of a file
|
|
30
|
+
* that is not there is a build error rather than an empty page, and a fresh
|
|
31
|
+
* project has to build on a laptop before it has ever been attached.
|
|
32
|
+
*
|
|
33
|
+
* The admin learns the page exists when the branch is imported — a page is a
|
|
34
|
+
* file somebody wrote, and that is the only way one comes into being.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
type Block = Record<string, unknown> & { type: string };
|
|
38
|
+
type Item = Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
interface Document {
|
|
41
|
+
path?: string;
|
|
42
|
+
seo?: Record<string, unknown>;
|
|
43
|
+
/** The page's language. Absent is the site's own — see the platform's wiki/29. */
|
|
44
|
+
locale?: string;
|
|
45
|
+
blocks?: Block[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One language of a page, for `hreflang` and for a switcher. */
|
|
49
|
+
export interface Alternate {
|
|
50
|
+
/** Empty is the site's own language. `META.lang` is what it is called. */
|
|
51
|
+
locale: string;
|
|
52
|
+
path: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every page document in the repository, keyed by the path it declares.
|
|
57
|
+
*
|
|
58
|
+
* Eager, because a page is read while its route renders and there is nothing to
|
|
59
|
+
* wait for; and by glob rather than by import, because the whole point is that
|
|
60
|
+
* a route names a path and does not have to know which file it lives in.
|
|
61
|
+
*/
|
|
62
|
+
const documents = import.meta.glob<Document>('/jtk/content/**/*.json', {
|
|
63
|
+
eager: true,
|
|
64
|
+
import: 'default',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The shared document, if this site has one.
|
|
69
|
+
*
|
|
70
|
+
* A glob of exactly one file rather than an import of it, because a static
|
|
71
|
+
* import of a file that is not there is a build error — and most sites have
|
|
72
|
+
* none. `readShared()` on a site without one answers with nothing, and every
|
|
73
|
+
* `str` off it is an empty string, which is what a page renders before anything
|
|
74
|
+
* has been written.
|
|
75
|
+
*/
|
|
76
|
+
const sharedDocuments: Record<string, Document | undefined> = {};
|
|
77
|
+
for (const [file, document] of Object.entries(
|
|
78
|
+
import.meta.glob<Document>('/jtk/shared*.json', { eager: true, import: 'default' }),
|
|
79
|
+
)) {
|
|
80
|
+
// shared.json is the site's own language; shared.de.json is German. Named
|
|
81
|
+
// rather than nested, because there is one per language and a directory of
|
|
82
|
+
// one file is a directory somebody has to explain.
|
|
83
|
+
const match = /\/shared(?:\.([a-z]{2}(?:-[a-z]{2})?))?\.json$/.exec(file);
|
|
84
|
+
if (match !== null) sharedDocuments[match[1] ?? ''] = document;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ANNOTATE = import.meta.env.PUBLIC_JTK_ANNOTATE !== 'false';
|
|
88
|
+
|
|
89
|
+
export interface Reading {
|
|
90
|
+
/** The page's language, empty for the site's own. */
|
|
91
|
+
locale: string;
|
|
92
|
+
/**
|
|
93
|
+
* Every language this page exists in, itself included, in the order the
|
|
94
|
+
* documents were found.
|
|
95
|
+
*
|
|
96
|
+
* Empty for a page that has one language, which is most pages on most sites.
|
|
97
|
+
* **Feed it to `<Layout alternates={…}>`**: without `hreflang` two language
|
|
98
|
+
* versions of one page compete with each other in search, which is the only
|
|
99
|
+
* thing in this whole area that costs money.
|
|
100
|
+
*/
|
|
101
|
+
alternates: Alternate[];
|
|
102
|
+
/** The page's own title and description, for `<Layout>`. */
|
|
103
|
+
seo: { title: string; description: string };
|
|
104
|
+
/** Every block, in the document's own order. */
|
|
105
|
+
PAGE: readonly Block[];
|
|
106
|
+
/** A block by type, with the index an annotation path is built from. */
|
|
107
|
+
block(type: string): { at: number; of: Block };
|
|
108
|
+
str(of: Block, key: string): string;
|
|
109
|
+
rows(of: Block, key: string): Item[];
|
|
110
|
+
picture(of: Block, at: number, key: string): Shown;
|
|
111
|
+
gallery(of: Block, at: number, key: string): Shown[];
|
|
112
|
+
pathAt(at: number, field: string, item?: number, subField?: string): string | undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function readPage(path: string): Reading {
|
|
116
|
+
return reading(find(path), '');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Another page's document, read from the page that is *showing* it.
|
|
121
|
+
*
|
|
122
|
+
* ── a tile in a list is somebody else's words ───────────────────────────────
|
|
123
|
+
*
|
|
124
|
+
* A blog listing draws a heading, a date and an excerpt for every post, and
|
|
125
|
+
* every one of them belongs to that post's document rather than to the
|
|
126
|
+
* listing's. Annotated the ordinary way they would name a field the listing
|
|
127
|
+
* page has not got, so until this existed they carried no annotation at all —
|
|
128
|
+
* and the words an owner most wants to fix were the one place on their site
|
|
129
|
+
* they could not tap.
|
|
130
|
+
*
|
|
131
|
+
* The prefix says which document, by the address the tile already links to:
|
|
132
|
+
*
|
|
133
|
+
* <a data-jtk-path={post.pathAt(0, 'title')}>{post.str(of, 'title')}</a>
|
|
134
|
+
* → data-jtk-path="page:/blog/healing:blocks[0].title"
|
|
135
|
+
*
|
|
136
|
+
* The admin resolves it against that page, writes it there, and the post's own
|
|
137
|
+
* page updates with it. `annotation-lint` checks the claim from the other end:
|
|
138
|
+
* an address no page has, or a field that page's document has not got, fails
|
|
139
|
+
* the build.
|
|
140
|
+
*
|
|
141
|
+
* Use it where a page renders another page's content and nowhere else. A
|
|
142
|
+
* heading you wrote in *this* page's document is this page's, however much it
|
|
143
|
+
* looks like a tile.
|
|
144
|
+
*/
|
|
145
|
+
export function readOther(path: string): Reading {
|
|
146
|
+
return reading(find(path), `page:${path}:`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The text that is on every page.
|
|
151
|
+
*
|
|
152
|
+
* ── one value, and the annotation says which document it is in ──────────────
|
|
153
|
+
*
|
|
154
|
+
* A footer sentence, the line under the wordmark, the words over the form. They
|
|
155
|
+
* are on every page and belong to none of them, and the wrong answer is the
|
|
156
|
+
* obvious one: declared in each page's document, a three-page site has three
|
|
157
|
+
* copies, and they diverge the first time somebody edits one. The owner changes
|
|
158
|
+
* the footer on the prices page, looks at the home page, and reports that their
|
|
159
|
+
* change disappeared. It did not — it was saved, on one page in three.
|
|
160
|
+
*
|
|
161
|
+
* So it lives in `jtk/shared.json`, once, and every field of it is
|
|
162
|
+
* annotated with the prefix that says so:
|
|
163
|
+
*
|
|
164
|
+
* <p data-jtk-path={chrome.pathAt(at, 'note')}>{chrome.str(of, 'note')}</p>
|
|
165
|
+
* → data-jtk-path="shared:blocks[0].note"
|
|
166
|
+
*
|
|
167
|
+
* `pathAt` here writes the prefix for you. **Annotate it on every page that
|
|
168
|
+
* renders it** — unlike a page's own field, the same annotation on five pages
|
|
169
|
+
* is correct and is what makes the sentence editable wherever somebody happens
|
|
170
|
+
* to be looking. The admin resolves it against the site's document, not the
|
|
171
|
+
* page's, and tells the owner the line is on every page before they change it.
|
|
172
|
+
*/
|
|
173
|
+
export function readShared(locale = ''): Reading {
|
|
174
|
+
return reading(sharedDocuments[locale], 'shared:');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The languages a page exists in.
|
|
179
|
+
*
|
|
180
|
+
* ── from the address, because the address is the whole rule ─────────────────
|
|
181
|
+
*
|
|
182
|
+
* A page lives at the same address in every language, with the language in
|
|
183
|
+
* front of it: `/prices` and `/de/preise` are not a pair, `/prices` and
|
|
184
|
+
* `/de/prices` are. So take the language off the front and look for the same
|
|
185
|
+
* address under every other one.
|
|
186
|
+
*
|
|
187
|
+
* This used to read a `group` key both documents declared, which let each
|
|
188
|
+
* language have its own words. That key is a second source of truth for
|
|
189
|
+
* something the addresses already say, and one nobody can check: `group:
|
|
190
|
+
* "work"` on one page against `group: "works"` on the other is two unrelated
|
|
191
|
+
* pages, no error anywhere, and a site that has quietly lost its hreflang. The
|
|
192
|
+
* platform stopped reading it and `fl-check` refuses a document that still
|
|
193
|
+
* carries one.
|
|
194
|
+
*
|
|
195
|
+
* A page in one language answers with nothing rather than with itself: a
|
|
196
|
+
* `hreflang` set of one is noise.
|
|
197
|
+
*/
|
|
198
|
+
function alternatesOf(document: Document | undefined): Alternate[] {
|
|
199
|
+
const here = document?.path;
|
|
200
|
+
if (typeof here !== 'string') return [];
|
|
201
|
+
|
|
202
|
+
const base = basePathOf(here);
|
|
203
|
+
const found = Object.values(documents)
|
|
204
|
+
.filter((other) => typeof other?.path === 'string' && basePathOf(other.path as string) === base)
|
|
205
|
+
.map((other) => ({ locale: other?.locale ?? '', path: other?.path as string }));
|
|
206
|
+
|
|
207
|
+
return found.length > 1 ? found : [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `/de/prices` → `/prices`, and `/de` → `/`, which is the home page. */
|
|
211
|
+
function basePathOf(path: string): string {
|
|
212
|
+
for (const locale of LOCALES) {
|
|
213
|
+
if (path === `/${locale}` || path === `/${locale}/`) return '/';
|
|
214
|
+
if (path.startsWith(`/${locale}/`)) return path.slice(locale.length + 1);
|
|
215
|
+
}
|
|
216
|
+
return path;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function reading(document: Document | undefined, prefix: string): Reading {
|
|
220
|
+
const blocks = (document?.blocks ?? []) as Block[];
|
|
221
|
+
|
|
222
|
+
const str = (of: Block, key: string): string => (of[key] as string) ?? '';
|
|
223
|
+
const rows = (of: Block, key: string): Item[] => (of[key] as Item[]) ?? [];
|
|
224
|
+
|
|
225
|
+
const pathAt = (at: number, field: string, item?: number, subField?: string): string | undefined => {
|
|
226
|
+
if (!ANNOTATE) return undefined;
|
|
227
|
+
if (at < 0 || at >= blocks.length) return undefined;
|
|
228
|
+
|
|
229
|
+
return item === undefined
|
|
230
|
+
? `${prefix}blocks[${at}].${field}`
|
|
231
|
+
: `${prefix}blocks[${at}].${field}[${item}].${subField}`;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const block = (type: string): { at: number; of: Block } => {
|
|
235
|
+
const at = blocks.findIndex((candidate) => candidate.type === type);
|
|
236
|
+
// A block the document does not have is not an error: the admin can remove
|
|
237
|
+
// one, and a page that threw at build time because a section was deleted
|
|
238
|
+
// would make removing a section a deploy incident.
|
|
239
|
+
return at === -1 ? { at: -1, of: { type } } : { at, of: blocks[at]! };
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
locale: document?.locale ?? '',
|
|
244
|
+
alternates: alternatesOf(document),
|
|
245
|
+
seo: {
|
|
246
|
+
title: (document?.seo?.title as string) ?? '',
|
|
247
|
+
description: (document?.seo?.description as string) ?? '',
|
|
248
|
+
},
|
|
249
|
+
PAGE: blocks,
|
|
250
|
+
block,
|
|
251
|
+
str,
|
|
252
|
+
rows,
|
|
253
|
+
pathAt,
|
|
254
|
+
picture: (of, at, key) => ({
|
|
255
|
+
name: str(of, key),
|
|
256
|
+
alt: str(of, `${key}_alt`),
|
|
257
|
+
path: pathAt(at, key),
|
|
258
|
+
}),
|
|
259
|
+
gallery: (of, at, key) =>
|
|
260
|
+
rows(of, key).flatMap((item, i) => {
|
|
261
|
+
const src = (item.src as string) ?? '';
|
|
262
|
+
if (!src) return [];
|
|
263
|
+
return [
|
|
264
|
+
{
|
|
265
|
+
name: src,
|
|
266
|
+
alt: (item.alt as string) ?? '',
|
|
267
|
+
poster: (item.poster as string) || undefined,
|
|
268
|
+
path: pathAt(at, key, i, 'src'),
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The document that declares this path.
|
|
277
|
+
*
|
|
278
|
+
* By the `path` inside the file rather than by the file's name, because that is
|
|
279
|
+
* what publish writes and what the admin reads back: the two must agree about
|
|
280
|
+
* which page is which, and a file somebody renamed is a page that moved.
|
|
281
|
+
*/
|
|
282
|
+
function find(path: string): Document | undefined {
|
|
283
|
+
for (const document of Object.values(documents)) {
|
|
284
|
+
if (document?.path === path) return document;
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|