@escape-game-over/atlas 0.1.24 → 0.1.25
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/README.md +27 -44
- package/bin/use-project.mjs +18 -13
- package/docs/NOT-BUILT.md +1 -1
- package/docs/client-scripts.md +73 -141
- package/docs/rich-text.md +13 -20
- package/package.json +5 -12
- package/src/analytics/google.ts +6 -6
- package/src/analytics/index.ts +4 -3
- package/src/analytics/tags.ts +13 -58
- package/src/analytics/umami.ts +8 -8
- package/src/astro/ConsentBanner.astro +25 -0
- package/src/astro/ConsentElement.astro +61 -0
- package/src/astro/Document.astro +44 -0
- package/src/astro/Image.astro +102 -0
- package/src/astro/MetaTags.astro +3 -26
- package/src/astro/RichText.astro +71 -0
- package/src/astro/Zoom.astro +61 -0
- package/src/astro/client.ts +19 -9
- package/src/astro/consent.ts +20 -0
- package/src/astro/dev-log.ts +8 -14
- package/src/astro/element.ts +111 -112
- package/src/astro/filters-view.ts +48 -64
- package/src/astro/filters.ts +42 -35
- package/src/astro/index.ts +2 -9
- package/src/astro/markup.ts +6 -6
- package/src/astro/site-routes.ts +9 -15
- package/src/config.ts +23 -36
- package/src/content/index.ts +1 -1
- package/src/content/marks.ts +13 -13
- package/src/content/rich.ts +26 -42
- package/src/hours.ts +48 -11
- package/src/i18n/define.ts +14 -74
- package/src/index.ts +40 -57
- package/src/meta/index.ts +7 -13
- package/src/meta/share-image.ts +2 -26
- package/src/meta/tag.ts +1 -45
- package/src/money.ts +161 -6
- package/src/project.ts +84 -73
- package/src/routes/define.ts +8 -44
- package/src/routes/resolve.ts +1 -1
- package/src/site/api.ts +7 -33
- package/src/site/create.ts +6 -10
- package/src/site/define.ts +120 -0
- package/src/site/index.ts +2 -5
- package/src/site/page.ts +4 -2
- package/src/sitemap.ts +2 -35
- package/src/warn.ts +16 -17
- package/src/astro/dom.ts +0 -35
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Linking to a page the active deployment switched off is a compile error, not a
|
|
|
61
61
|
One file per deployment, saying only what differs:
|
|
62
62
|
|
|
63
63
|
```ts
|
|
64
|
-
export default
|
|
64
|
+
export default atlas.project(defaultMessages, defaultRoutes, {
|
|
65
65
|
url: "https://acme.example",
|
|
66
66
|
siteName: "Acme Rome",
|
|
67
67
|
icon,
|
|
@@ -92,22 +92,22 @@ never writes a type argument:
|
|
|
92
92
|
|
|
93
93
|
```ts
|
|
94
94
|
// 1. which languages exist, how URLs are shaped
|
|
95
|
-
export
|
|
95
|
+
export const atlas = defineSite({ locales: {...}, defaultRouting: {...} })
|
|
96
96
|
|
|
97
97
|
// 2. the base copy and routes
|
|
98
|
-
export const baseMessages =
|
|
99
|
-
export const baseRoutes =
|
|
98
|
+
export const baseMessages = atlas.messages({...})
|
|
99
|
+
export const baseRoutes = atlas.routes({...})
|
|
100
100
|
|
|
101
101
|
// 3. one deployment
|
|
102
|
-
export default
|
|
102
|
+
export default atlas.project(baseMessages, baseRoutes, {...})
|
|
103
103
|
|
|
104
104
|
// 4. the API the pages use
|
|
105
|
-
export const site =
|
|
105
|
+
export const site = atlas.site(baseMessages, baseRoutes, project)
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
Step 4 returns `t()`, `rich()`, `plain()`, `pathFor()`, `urlFor()`, `fileUrl()`,
|
|
109
|
-
`
|
|
110
|
-
`
|
|
109
|
+
`localeLinksFor()`, `metaFor()`, `breadcrumbFor()`,
|
|
110
|
+
`getStaticPaths()`, `routes`, `sitemap()`, `robots()`, `llms()` and
|
|
111
111
|
`redirects()` already wired.
|
|
112
112
|
|
|
113
113
|
`t()` is bound to a locale and knows each message's `{placeholders}` from its
|
|
@@ -233,7 +233,7 @@ worded.
|
|
|
233
233
|
The same question answers it every time: *would this differ between two pages of
|
|
234
234
|
the same site?*
|
|
235
235
|
|
|
236
|
-
| Per page — passed to `metaFor()` | Site-level — declared in `
|
|
236
|
+
| Per page — passed to `metaFor()` | Site-level — declared in `atlas.project()` |
|
|
237
237
|
| -------------------------------- | ------------------------------------------ |
|
|
238
238
|
| `title`, `description` | `siteName`, `twitterSite` |
|
|
239
239
|
| `image` (asset **and** its alt) | `icon`, `themeColor`, `colorScheme` |
|
|
@@ -251,16 +251,9 @@ actually renders, and why `twitter:card` is pinned.
|
|
|
251
251
|
|
|
252
252
|
A project switches routes off. Anything defined *alongside* a route then has two
|
|
253
253
|
ways to rot: the page is built and its config is missing, or the page is gone and
|
|
254
|
-
its config lingers.
|
|
254
|
+
its config lingers. `PerRoute<typeof site, T>` rejects both.
|
|
255
255
|
|
|
256
|
-
|
|
257
|
-
| --------------------- | ------------------------------------ | ------------------------------------------------ |
|
|
258
|
-
| Shape | a table keyed by route id | a single value |
|
|
259
|
-
| Says | "every built route has one of these" | "this value belongs to *that* page" |
|
|
260
|
-
| When the route is off | that key is rejected | the type is `never` — the field cannot be filled |
|
|
261
|
-
| Use with | `satisfies` | a normal annotation |
|
|
262
|
-
|
|
263
|
-
`PerRoute` must be used with `satisfies`, not as an annotation: excess-property
|
|
256
|
+
It must be used with `satisfies`, not as an annotation: excess-property
|
|
264
257
|
checking is what catches the stale half, and only an object literal checked
|
|
265
258
|
against a known target gets it.
|
|
266
259
|
|
|
@@ -269,8 +262,6 @@ const heroes = {
|
|
|
269
262
|
home: { variant: "wide" },
|
|
270
263
|
about: { variant: "tall" },
|
|
271
264
|
} satisfies PerRoute<typeof site, Hero>; // ✗ if `about` is off, ✗ if `contact` is on
|
|
272
|
-
|
|
273
|
-
const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; // ✗ if off
|
|
274
265
|
```
|
|
275
266
|
|
|
276
267
|
## The rules this package keeps
|
|
@@ -299,16 +290,8 @@ const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; /
|
|
|
299
290
|
".": "./src/index.ts",
|
|
300
291
|
"./astro": "./src/astro/index.ts",
|
|
301
292
|
"./astro/images": "./src/astro/images.ts",
|
|
302
|
-
"./astro/
|
|
303
|
-
"./
|
|
304
|
-
"./astro/consent": "./src/astro/consent.ts",
|
|
305
|
-
"./astro/dev-log": "./src/astro/dev-log.ts",
|
|
306
|
-
"./astro/dom": "./src/astro/dom.ts",
|
|
307
|
-
"./astro/element": "./src/astro/element.ts",
|
|
308
|
-
"./astro/filters": "./src/astro/filters.ts",
|
|
309
|
-
"./astro/filters-view": "./src/astro/filters-view.ts",
|
|
310
|
-
"./astro/meta-tags": "./src/astro/MetaTags.astro",
|
|
311
|
-
"./astro/youtube": "./src/astro/youtube.ts"
|
|
293
|
+
"./astro/document": "./src/astro/Document.astro",
|
|
294
|
+
"./client": "./src/astro/client.ts"
|
|
312
295
|
}
|
|
313
296
|
```
|
|
314
297
|
|
|
@@ -324,27 +307,27 @@ may use `node:` built-ins but not `astro:assets`. `./astro/images` is the
|
|
|
324
307
|
opposite: it runs inside the build, from a page, and is unusable from a config.
|
|
325
308
|
Merging them breaks whichever caller loads first.
|
|
326
309
|
|
|
327
|
-
|
|
328
|
-
`youtube`, `background-video`, `consent`, `element
|
|
329
|
-
|
|
330
|
-
than in the build, and is separate again for the same reason: none of it can be
|
|
331
|
-
reached from a config, and none of it draws. See
|
|
310
|
+
`./client` is the browser half — `carousel`, `filters`, `filters-view`,
|
|
311
|
+
`youtube`, `background-video`, `consent`, `element` — one path
|
|
312
|
+
for all of it. See
|
|
332
313
|
[`docs/client-scripts.md`](docs/client-scripts.md).
|
|
333
314
|
|
|
334
315
|
**The package ships TypeScript source, and there is no build step.**
|
|
335
|
-
`
|
|
316
|
+
`Document.astro` could not go through `tsc` anyway, and Astro's own
|
|
336
317
|
`tsconfigs/base.json` already sets `allowImportingTsExtensions`, so every
|
|
337
318
|
consumer gets it for free.
|
|
338
319
|
|
|
339
320
|
## The `atlas` command
|
|
340
321
|
|
|
341
322
|
```bash
|
|
342
|
-
atlas use <project> #
|
|
323
|
+
atlas use <project> # links config/project -> config/projects/<name>
|
|
343
324
|
atlas use --fallback rome # only when nothing else named one
|
|
344
325
|
```
|
|
345
326
|
|
|
346
|
-
|
|
347
|
-
|
|
327
|
+
A link, not a copy: an edit made through `config/project` lands in the real
|
|
328
|
+
file. A directory junction on Windows, which needs no admin rights. Ignore it with
|
|
329
|
+
`**/config/project` — no trailing slash, since git sees a link as a file. The
|
|
330
|
+
contract is just that the project contains `project.ts`.
|
|
348
331
|
It resolves paths from the working directory, so a workspace's own
|
|
349
332
|
`package.json` passes nothing. A deployment pipeline can skip it and write its
|
|
350
333
|
own `config/project`.
|
|
@@ -358,7 +341,7 @@ command whose output gets deployed has to say what it is building.
|
|
|
358
341
|
```txt
|
|
359
342
|
src/ the package — see the export map above for what is reachable
|
|
360
343
|
index.ts the public API
|
|
361
|
-
site/ createSite() and the Site it returns
|
|
344
|
+
site/ defineSite(), createSite() and the Site it returns
|
|
362
345
|
meta/ head tags: canonical, hreflang, Open Graph, Twitter, robots
|
|
363
346
|
jsonld/ the @graph — one file per node, each linked to Google's docs
|
|
364
347
|
i18n/ defineMessages(), t(), placeholder extraction
|
|
@@ -400,9 +383,9 @@ proves the package is genuinely independent of anything consuming it.
|
|
|
400
383
|
|
|
401
384
|
The `astro check` pass covers `src/astro/`, the one directory that cannot meet
|
|
402
385
|
that bar. It needs `astro/client` for `astro:assets` and `ImageMetadata`, and
|
|
403
|
-
|
|
404
|
-
zero `.astro` files no matter how they are globbed. Without this pass the
|
|
405
|
-
|
|
386
|
+
the `.astro` components need a checker that can parse `.astro` at all — `tsc` reads
|
|
387
|
+
zero `.astro` files no matter how they are globbed. Without this pass the
|
|
388
|
+
components the package ships are checked by nothing: `astro check` in
|
|
406
389
|
`test:examples` only ever walks an example's own `src/`, so it never reaches it,
|
|
407
390
|
and a type error there passes the entire gate.
|
|
408
391
|
|
|
@@ -412,7 +395,7 @@ practical reason: `astro check` writes a `.astro/` types directory and a
|
|
|
412
395
|
publish both. The `tsconfig.json` there adds nothing — it extends
|
|
413
396
|
`src/astro/tsconfig.json` and only widens the glob, so the rules stay in one
|
|
414
397
|
place. `src/astro/tsconfig.json` is what an editor finds; without it, opening
|
|
415
|
-
`
|
|
398
|
+
`Document.astro` resolves against the root config, which supplies no ambient
|
|
416
399
|
types, and every prop degrades to `any`.
|
|
417
400
|
|
|
418
401
|
`test:examples` **builds every deployment**, not just type-checks one, and both
|
package/bin/use-project.mjs
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Links `config/project` to one project overlay, the folder the whole build
|
|
4
4
|
* reads from. Run automatically before `dev`, `build` and `check`.
|
|
5
5
|
*
|
|
6
|
+
* A link rather than a copy, so an edit made through `config/project` — where
|
|
7
|
+
* an editor's go-to-definition lands — is an edit to the real file. A
|
|
8
|
+
* junction on Windows, which needs no admin rights; a symlink elsewhere.
|
|
9
|
+
*
|
|
6
10
|
* atlas use acme
|
|
7
11
|
* PROJECT=acme npm run build
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* own `config/project` — the contract is just that it contains `project.ts`.
|
|
13
|
+
* A deployment pipeline can skip this command and write its own
|
|
14
|
+
* `config/project` — the contract is just that it contains `project.ts`.
|
|
12
15
|
*/
|
|
13
|
-
import {
|
|
16
|
+
import { lstat, readdir, rm, stat, symlink, unlink } from "node:fs/promises";
|
|
14
17
|
import { join } from "node:path";
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -26,10 +29,7 @@ import { join } from "node:path";
|
|
|
26
29
|
const ROOT = process.cwd();
|
|
27
30
|
const PROJECTS_DIR = join(ROOT, "config", "projects");
|
|
28
31
|
const TARGET_DIR = join(ROOT, "config", "project");
|
|
29
|
-
/**
|
|
30
|
-
* Only an entry-point check — the whole directory is copied recursively, so a
|
|
31
|
-
* project can add files and folders freely without touching this script.
|
|
32
|
-
*/
|
|
32
|
+
/** Only an entry-point check: a project can add files and folders freely. */
|
|
33
33
|
const ENTRY_FILE = "project.ts";
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -122,10 +122,15 @@ if (!(await exists(join(sourceDir, ENTRY_FILE)))) {
|
|
|
122
122
|
process.exit(1);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
await
|
|
125
|
+
// Only the link goes, never what it points at. A real directory is a copy left
|
|
126
|
+
// by an older version of this command.
|
|
127
|
+
const existing = await lstat(TARGET_DIR).catch(() => undefined);
|
|
128
|
+
if (existing?.isSymbolicLink()) await unlink(TARGET_DIR);
|
|
129
|
+
else if (existing !== undefined) await rm(TARGET_DIR, { recursive: true });
|
|
130
|
+
|
|
131
|
+
// "junction" is Windows' no-admin directory link, and ignored everywhere else.
|
|
132
|
+
await symlink(sourceDir, TARGET_DIR, "junction");
|
|
128
133
|
|
|
129
134
|
console.log(
|
|
130
|
-
`Using project "${name}" (config/projects/${name}
|
|
135
|
+
`Using project "${name}" (config/project -> config/projects/${name})`
|
|
131
136
|
);
|
package/docs/NOT-BUILT.md
CHANGED
|
@@ -230,7 +230,7 @@ type could — a property that is not valid for the type it sits on.
|
|
|
230
230
|
### Should a paragraph be authored as an array of translation keys?
|
|
231
231
|
|
|
232
232
|
**No. One sentence is one message, and the styling lives in the copy as
|
|
233
|
-
`[marks]`. `content/` carries the parser, the runs and `plain()`; nothing
|
|
233
|
+
`[marks]`. `content/` carries the parser, the runs and `site.plain()`; nothing
|
|
234
234
|
carries a `ContentItem[]`.**
|
|
235
235
|
|
|
236
236
|
This is the shape the sibling B2C template uses, and it is the one thing from
|
package/docs/client-scripts.md
CHANGED
|
@@ -32,33 +32,26 @@ the referrer policy — and everything that does, classes above all, comes from
|
|
|
32
32
|
caller through a callback. It too is handed the button and the box rather than
|
|
33
33
|
finding them.
|
|
34
34
|
|
|
35
|
-
| Module
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
`
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
the banner that is rendered. See NOT-BUILT.md on where the halves divide.
|
|
56
|
-
|
|
57
|
-
**A script imports one path: `@escape-game-over/atlas/client`**, which re-exports
|
|
58
|
-
every module above. All of it exists to touch a live document, so it belongs in
|
|
59
|
-
a `<script src>` and not in frontmatter or `astro.config.ts`. A component's
|
|
60
|
-
contract is the one thing both halves need, and it stays on `./astro/markup`,
|
|
61
|
-
which touches nothing.
|
|
35
|
+
| Module | Owns | Leaves to the project |
|
|
36
|
+
| ------------------ | -------------------------------------------------------------- | ----------------------------------------------- |
|
|
37
|
+
| `carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
|
|
38
|
+
| `filters` | which items match, and what the address bar says | every DOM read and write |
|
|
39
|
+
| `filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
|
|
40
|
+
| `youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
|
|
41
|
+
| `background-video` | playing or paused, playable or not, and which cut is loaded | the play/pause control, its icons and its label |
|
|
42
|
+
| `consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
|
|
43
|
+
| `element` | registering a custom element, and ending what it started | what the element does while it is on the page |
|
|
44
|
+
|
|
45
|
+
`consent` is the one module that ships its own behaviour: `<ConsentBanner
|
|
46
|
+
analytics={…}>` (`@escape-game-over/atlas/astro/consent-banner`) renders the
|
|
47
|
+
site's markup as its children, skips itself — script included — when the
|
|
48
|
+
analytics need no permission, and remembers, expires and applies the answer. The
|
|
49
|
+
markup spreads `consent.accept` and `consent.decline` on its buttons, and
|
|
50
|
+
`consent.reopen` on the control that brings it back.
|
|
51
|
+
|
|
52
|
+
**Browser code has one import path: `@escape-game-over/atlas/client`**, which
|
|
53
|
+
re-exports every module above. None of it touches the DOM at import, so an
|
|
54
|
+
element's contract can be imported in frontmatter from the same path.
|
|
62
55
|
|
|
63
56
|
## Lifetimes are the recurring bug
|
|
64
57
|
|
|
@@ -75,21 +68,17 @@ const detach = loop.attach(video); // background-video: the <video>
|
|
|
75
68
|
const detach = trailer.attach(button, box); // youtube: what is pressed, what it replaces
|
|
76
69
|
```
|
|
77
70
|
|
|
78
|
-
`
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
everything else.
|
|
71
|
+
`element` says the same thing in the shape a custom element needs: its function
|
|
72
|
+
runs when the element enters the page, `signal` ends what it registered when the
|
|
73
|
+
element leaves, and what it returns is the undo for everything else.
|
|
82
74
|
|
|
83
75
|
```ts
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return loop.attach(host);
|
|
76
|
+
export const thing = element("go-thing", { button: marker }, (host, { button }, signal) => {
|
|
77
|
+
button.one()?.addEventListener("click", open, { signal }); // ends with the visit
|
|
78
|
+
return loop.attach(host); // and so does this
|
|
87
79
|
});
|
|
88
80
|
```
|
|
89
81
|
|
|
90
|
-
It takes the component rather than a tag name, so the element it registers and
|
|
91
|
-
the roles it hands the function are the same contract the template spread.
|
|
92
|
-
|
|
93
82
|
An element can enter the page more than once — moving it in the DOM runs the
|
|
94
83
|
undo and then the function again — so it has to be able to run twice. State that
|
|
95
84
|
must survive a move goes in a `WeakMap` keyed by `host`, which is what the
|
|
@@ -114,9 +103,9 @@ the URL. The caller declares fields; the item shape follows from them.
|
|
|
114
103
|
```ts
|
|
115
104
|
const list = filters({
|
|
116
105
|
fields: {
|
|
117
|
-
q: { kind: "text"
|
|
118
|
-
category: { kind: "choice"
|
|
119
|
-
featured: { kind: "flag"
|
|
106
|
+
q: { kind: "text" },
|
|
107
|
+
category: { kind: "choice" },
|
|
108
|
+
featured: { kind: "flag" },
|
|
120
109
|
},
|
|
121
110
|
items: entries.map((entry) => ({
|
|
122
111
|
key: entry.id,
|
|
@@ -186,7 +175,7 @@ pass. What is deliberately absent is one field holding several selected values;
|
|
|
186
175
|
that is a `choices` kind, and it forces a URL encoding decision (repeated
|
|
187
176
|
parameters or comma-joined) that nothing has needed yet.
|
|
188
177
|
|
|
189
|
-
`param`
|
|
178
|
+
`param` defaults to the field's name. `param: false` keeps a field state-only, out of the URL.
|
|
190
179
|
|
|
191
180
|
### Facet counts: `matchedWithout`
|
|
192
181
|
|
|
@@ -278,25 +267,23 @@ The twelve lines every one of the three wrote around its search field, byte for
|
|
|
278
267
|
byte.
|
|
279
268
|
|
|
280
269
|
```ts
|
|
281
|
-
const search = searchBox({
|
|
282
|
-
input: one<HTMLInputElement>("[data-filter-search]"),
|
|
283
|
-
clear: one("[data-filter-clear]"),
|
|
284
|
-
empty: one("[data-filter-empty]"),
|
|
285
|
-
});
|
|
286
|
-
|
|
287
270
|
const list = filters({
|
|
288
271
|
fields, items,
|
|
289
|
-
onChange({
|
|
290
|
-
search.render(state.q, matched.size);
|
|
272
|
+
onChange({ matched }) {
|
|
291
273
|
// …everything this list draws for itself
|
|
292
274
|
},
|
|
293
275
|
});
|
|
294
|
-
|
|
295
|
-
const unbind = search.bind(list, "q");
|
|
296
276
|
const detach = list.attach();
|
|
277
|
+
const unbind = searchBox(list, "q", {
|
|
278
|
+
input: one<HTMLInputElement>("[data-filter-search]"),
|
|
279
|
+
clear: one("[data-filter-clear]"),
|
|
280
|
+
empty: one("[data-filter-empty]"),
|
|
281
|
+
});
|
|
297
282
|
```
|
|
298
283
|
|
|
299
|
-
|
|
284
|
+
It paints the list's current state at once, then repaints on every change
|
|
285
|
+
through `list.subscribe`, after the page's own `onChange`. The part worth
|
|
286
|
+
centralising is this:
|
|
300
287
|
|
|
301
288
|
```ts
|
|
302
289
|
if (input != null && input.value !== query) input.value = query;
|
|
@@ -306,19 +293,13 @@ It exists because `popstate` and `reset` move state without touching the
|
|
|
306
293
|
keyboard — forget it and the back button leaves a stale query sitting in the box
|
|
307
294
|
while the list shows something else. The inequality is not an optimisation:
|
|
308
295
|
assigning `value` moves the caret to the end, so writing it unconditionally would
|
|
309
|
-
make the field unusable mid-word.
|
|
310
|
-
|
|
311
|
-
`bind` is a second call rather than part of construction because of an ordering
|
|
312
|
-
knot. `render` runs inside the list's `onChange`, so the box must exist before
|
|
313
|
-
the list; `bind` needs the list. Two `const`s in sequence is the honest shape,
|
|
314
|
-
and the alternative is a `let` the reader has to carry. Its return is the undo,
|
|
315
|
-
like `attach` — so a page-load teardown aborts both.
|
|
296
|
+
make the field unusable mid-word. Its return is the undo, like `attach`.
|
|
316
297
|
|
|
317
298
|
`TextFieldOf<F>` restricts the second argument to the list's `text` fields, so a
|
|
318
299
|
box pointed at a `flag` is a compile error rather than a filter that silently
|
|
319
300
|
never matches.
|
|
320
301
|
|
|
321
|
-
Every element is optional and `null` is accepted, because `
|
|
302
|
+
Every element is optional and `null` is accepted, because a role's `one()`
|
|
322
303
|
returns `null` and a page may have a search field with no clear button. Handing
|
|
323
304
|
over what a lookup returned, unchecked, is the point.
|
|
324
305
|
|
|
@@ -333,7 +314,7 @@ errors, and the page looks wrong in a way that does not point at the filter.
|
|
|
333
314
|
```ts
|
|
334
315
|
onChange({ matched }) {
|
|
335
316
|
for (const item of items) item.hidden = !matched.has(keyOf(item));
|
|
336
|
-
hideEmpty(groups, (group) =>
|
|
317
|
+
hideEmpty(groups, (group) => rowsIn.get(group) ?? []);
|
|
337
318
|
}
|
|
338
319
|
```
|
|
339
320
|
|
|
@@ -346,8 +327,8 @@ city in it is hidden; a region is empty when every country in it is hidden,
|
|
|
346
327
|
**Which makes the order load-bearing: innermost first.**
|
|
347
328
|
|
|
348
329
|
```ts
|
|
349
|
-
hideEmpty(countries, (c) =>
|
|
350
|
-
hideEmpty(regions, (r) =>
|
|
330
|
+
hideEmpty(countries, (c) => citiesIn.get(c) ?? []);
|
|
331
|
+
hideEmpty(regions, (r) => countriesIn.get(r) ?? []);
|
|
351
332
|
```
|
|
352
333
|
|
|
353
334
|
Run the other way round, the regions are judged against countries nothing has
|
|
@@ -380,37 +361,39 @@ filters({ … }) + searchBox + hideEmpty // the roll-up gone too
|
|
|
380
361
|
A page whose markup wants a class instead of `hidden`, or removal from the DOM,
|
|
381
362
|
skips the helper and writes it in `onChange`. Nothing degrades.
|
|
382
363
|
|
|
383
|
-
## `
|
|
364
|
+
## `element`
|
|
384
365
|
|
|
385
366
|
A script that enhances server-rendered markup has to agree with the template on
|
|
386
367
|
attribute names, and nothing checks that agreement. `data-filter-regoin` in the
|
|
387
368
|
template and `dataset.filterRegion` in the script compile, build and ship, and
|
|
388
|
-
the facet simply never matches
|
|
389
|
-
|
|
390
|
-
and its template share, and the values in them.
|
|
369
|
+
the facet simply never matches. `element` names every attribute a script and its
|
|
370
|
+
template share, types the values in them, and registers the behaviour.
|
|
391
371
|
|
|
392
372
|
```ts
|
|
393
|
-
//
|
|
394
|
-
export const
|
|
395
|
-
key: { kind: "text" },
|
|
396
|
-
|
|
397
|
-
|
|
373
|
+
// faq.ts — imported by the template, and loaded by the page's script
|
|
374
|
+
export const faq = element("go-faq", {
|
|
375
|
+
row: { key: { kind: "text" }, status: { kind: "choice", of: ["open", "soon"] } },
|
|
376
|
+
search: marker,
|
|
377
|
+
}, (host, { row, search }, signal) => {
|
|
378
|
+
for (const { element, values } of row.all()) {
|
|
379
|
+
values.status; // "open" | "soon"
|
|
380
|
+
}
|
|
381
|
+
const input = search.one<HTMLInputElement>(); // a marker role is the element itself
|
|
398
382
|
});
|
|
399
|
-
export const search = markup("network-search");
|
|
400
383
|
```
|
|
401
384
|
|
|
402
385
|
```astro
|
|
403
|
-
<
|
|
404
|
-
<input {...search.attrs()} type="search">
|
|
405
|
-
```
|
|
386
|
+
<script src="./faq.ts"></script>
|
|
406
387
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const input = search.require<HTMLInputElement>(this).element;
|
|
388
|
+
<go-faq {...faq.root}>
|
|
389
|
+
<input {...faq.search.attrs()} type="search">
|
|
390
|
+
<details {...faq.row.attrs({ key, status: "open" })}>…</details>
|
|
391
|
+
</go-faq>
|
|
412
392
|
```
|
|
413
393
|
|
|
394
|
+
It registers itself when loaded in a browser and does nothing in Node, so the
|
|
395
|
+
template imports the same file for the contract.
|
|
396
|
+
|
|
414
397
|
- **Names are derived, never spelled.** `data-network-row` marks the element and
|
|
415
398
|
`data-network-row-status` holds a field. A field that does not exist is a
|
|
416
399
|
compile error in the template and in the script.
|
|
@@ -460,67 +443,16 @@ lookup: it finds only elements carrying its own marker, writes only its own
|
|
|
460
443
|
attributes, and sets no class, `aria` or `hidden`. The names are the project's,
|
|
461
444
|
so nothing in this package has to be matched.
|
|
462
445
|
|
|
463
|
-
### `component`: roles owned by one custom element
|
|
464
|
-
|
|
465
|
-
`markup` leaves two things to the project, and both bite: a name per role, which
|
|
466
|
-
two components can pick alike, and scope, since `querySelectorAll` from an
|
|
467
|
-
element also finds everything inside a nested copy of that element. `component`
|
|
468
|
-
ties every role to a custom element and settles both.
|
|
469
|
-
|
|
470
|
-
```ts
|
|
471
|
-
export const faq = component("go-faq", {
|
|
472
|
-
row: { key: { kind: "text" }, text: { kind: "text" } },
|
|
473
|
-
search: {},
|
|
474
|
-
});
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
```astro
|
|
478
|
-
<faq.tag {...faq.root}>
|
|
479
|
-
<input {...faq.search.attrs()} type="search">
|
|
480
|
-
<details {...faq.row.attrs({ key, text })}>…</details>
|
|
481
|
-
</faq.tag>
|
|
482
|
-
```
|
|
483
|
-
|
|
484
|
-
```ts
|
|
485
|
-
defineElement(faq, (host, signal, { row }) => {
|
|
486
|
-
for (const { element, values } of row.all()) { … }
|
|
487
|
-
});
|
|
488
|
-
```
|
|
489
|
-
|
|
490
446
|
- **Names come from the tag.** Every attribute is `data-<tag>-<role>` plus the
|
|
491
|
-
field, and the browser refuses to define one tag twice, so two
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
- **Lookups stay in their instance.** `all`, `one` and `require` return only
|
|
496
|
-
elements whose nearest ancestor with this tag is the root's. A nested copy
|
|
497
|
-
keeps its elements, and a lookup from an element inside the instance, like a
|
|
498
|
-
dropdown's own panel, still counts as that instance. Elements are filtered
|
|
499
|
-
before they are read, so a broken nested copy cannot fail the outer lookup.
|
|
500
|
-
- **The tag is written once.** `faq.tag` is what the template renders, and the
|
|
501
|
-
component itself is what `defineElement` registers the behaviour under. A contract stays inert either
|
|
502
|
-
way: it names things, and nothing in it touches a document, which is why
|
|
503
|
-
frontmatter can import it.
|
|
504
|
-
- **The names are checked in the editor.** A tag without a hyphen, or a role
|
|
505
|
-
that is not camelCase, is a compile error where it is written — the rules are
|
|
506
|
-
types, character by character, as in `i18n/placeholders.ts`. Nothing is
|
|
507
|
-
validated at run time, because a name that reached the browser malformed would
|
|
508
|
-
already have been refused there: by `setAttribute`, by `querySelectorAll`, or
|
|
509
|
-
by `customElements.define`.
|
|
510
|
-
- **Most roles carry nothing**, and say so: `search: marker` rather than
|
|
511
|
-
`search: {}`, which reads like options somebody forgot to fill in.
|
|
512
|
-
|
|
513
|
-
- **The roles arrive bound to the instance.** `defineElement` takes the
|
|
514
|
-
component, so `all`, `one` and `require` need no root: there is no way to
|
|
515
|
-
write `faq.row.all(document)`, which compiles and returns the roles belonging
|
|
516
|
-
to no instance at all.
|
|
447
|
+
field, and the browser refuses to define one tag twice, so two elements cannot
|
|
448
|
+
share an attribute.
|
|
449
|
+
- **Lookups stay in their instance.** A nested copy of the element keeps its
|
|
450
|
+
own roles.
|
|
517
451
|
- **The root says it is one.** `{...faq.root}` writes `data-atlas-root`, so a
|
|
518
|
-
project gives every root a display in one stylesheet rule
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
that lives outside every instance, such as a footer button that reopens a
|
|
523
|
-
banner, is still plain `markup`.
|
|
452
|
+
project gives every root a display in one stylesheet rule. Forget it and the
|
|
453
|
+
element says so on the dev server.
|
|
454
|
+
- **Names are checked in the editor.** A tag without a hyphen, or a role that is
|
|
455
|
+
not camelCase, is a compile error where it is written.
|
|
524
456
|
|
|
525
457
|
## `youtube`
|
|
526
458
|
|
|
@@ -664,7 +596,7 @@ right properties is a faithful stand-in.
|
|
|
664
596
|
inside the instance, and a broken nested element that must not be read.
|
|
665
597
|
`type-tests/component.ts` pins the role types and the reserved keys.
|
|
666
598
|
|
|
667
|
-
`carousel`, `consent
|
|
599
|
+
`carousel`, `consent` and `element` have none. They need a real DOM and
|
|
668
600
|
this package carries no environment for one; adding `happy-dom` as a dev
|
|
669
601
|
dependency and setting `environment` in `vitest.config.ts` is what that would
|
|
670
602
|
take.
|
package/docs/rich-text.md
CHANGED
|
@@ -29,10 +29,10 @@ Five marks, one void mark, two escapes. That is the whole vocabulary.
|
|
|
29
29
|
| Written | Short for | Run | Argument | Notes |
|
|
30
30
|
| ---------------- | ----------- | ---------------------------------- | -------- | -------------------------------------------- |
|
|
31
31
|
| `[b]…[/b]` | **b**old | `{ kind: "bold" }` | — | `<strong>` — emphasis, not a font weight |
|
|
32
|
-
| `[v:name]…[/v]` | **v**ariant | `{ kind: "
|
|
32
|
+
| `[v:name]…[/v]` | **v**ariant | `{ kind: "variant", variant }` | required | the renderer maps `name` to classes |
|
|
33
33
|
| `[a:slot]…[/a]` | **a**nchor | `{ kind: "link", to, href, url? }` | required | the **call site** says where `slot` goes |
|
|
34
|
-
| `[mail]…[/mail]` | `mailto:` | `{ kind: "
|
|
35
|
-
| `[tel]…[/tel]` | `tel:` | `{ kind: "
|
|
34
|
+
| `[mail]…[/mail]` | `mailto:` | `{ kind: "mail", href }` | — | the wrapped text must be the address |
|
|
35
|
+
| `[tel]…[/tel]` | `tel:` | `{ kind: "tel", href }` | — | the wrapped text must be the number |
|
|
36
36
|
| `[br]` | **br**eak | `{ kind: "break" }` | — | wraps nothing, closed by nothing |
|
|
37
37
|
| `[[` and `]]` | an escape | literal `[` and `]` | — | the escapes, matching `{{` and `}}` in `t()` |
|
|
38
38
|
|
|
@@ -192,7 +192,7 @@ what to do:
|
|
|
192
192
|
|
|
193
193
|
```txt
|
|
194
194
|
Message "about.intro" (en-US) carries marks, and t() can only print them.
|
|
195
|
-
Read it with rich(), or with plain(
|
|
195
|
+
Read it with rich(), or with site.plain() for the words alone.
|
|
196
196
|
```
|
|
197
197
|
|
|
198
198
|
A malformed mark fails there too, with the same error it would give anywhere
|
|
@@ -215,10 +215,8 @@ const plainText = site.plain(locale);
|
|
|
215
215
|
plainText("about.intro", { company }) // no `venue`, no `ask`
|
|
216
216
|
```
|
|
217
217
|
|
|
218
|
-
`plain(rich(…))` is the same answer when the runs are already in hand.
|
|
219
|
-
|
|
220
218
|
Reach for `t()` when the copy is structurally plain and should stay that way — a
|
|
221
|
-
button label, an `aria-label`. Reach for `plain()` when the answer is prose: it
|
|
219
|
+
button label, an `aria-label`. Reach for `site.plain()` when the answer is prose: it
|
|
222
220
|
keeps working on the day someone adds emphasis to the sentence, where `t()` would
|
|
223
221
|
start throwing.
|
|
224
222
|
|
|
@@ -236,20 +234,15 @@ faqPage([{ question, answer: html(rich("faq.city.a", { network: "network" })) }]
|
|
|
236
234
|
|
|
237
235
|
## The renderer half
|
|
238
236
|
|
|
239
|
-
|
|
240
|
-
look
|
|
241
|
-
|
|
237
|
+
`@escape-game-over/atlas/astro/rich-text` renders the runs. The site supplies the
|
|
238
|
+
look — a class per `[v:name]` and one for links — usually in a small wrapper
|
|
239
|
+
([`examples/b2c/src/components/RichText.astro`](../examples/b2c/src/components/RichText.astro)):
|
|
242
240
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
rendering as nothing at all, on every page, silently.
|
|
241
|
+
```astro
|
|
242
|
+
<RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} link="…" />
|
|
243
|
+
```
|
|
247
244
|
|
|
248
|
-
|
|
249
|
-
is one to copy and restyle. The part worth keeping is its `VARIANTS` map: it is
|
|
250
|
-
the only place a role becomes a colour, so a rebrand is that object rather than a
|
|
251
|
-
sweep through every deployment's sentences. It falls back to no classes for a
|
|
252
|
-
variant it does not know — losing the sentence is the worse failure.
|
|
245
|
+
A variant with no class still renders its words, and warns at build.
|
|
253
246
|
|
|
254
247
|
## What fails, and where
|
|
255
248
|
|
|
@@ -270,4 +263,4 @@ variant it does not know — losing the sentence is the worse failure.
|
|
|
270
263
|
| `[tel]` around a local number | build error — write it with a country code |
|
|
271
264
|
| `http://` as a link target | build error — an `http://` link is a downgrade |
|
|
272
265
|
| `"contact#"` — a fragment that names nothing | build error |
|
|
273
|
-
| A marked message read with `t()` | build error, pointing at `rich()` and `plain()`
|
|
266
|
+
| A marked message read with `t()` | build error, pointing at `rich()` and `site.plain()` |
|