@escape-game-over/atlas 0.1.23 → 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.
Files changed (51) hide show
  1. package/README.md +29 -45
  2. package/bin/use-project.mjs +18 -13
  3. package/docs/NOT-BUILT.md +1 -1
  4. package/docs/client-scripts.md +73 -141
  5. package/docs/rich-text.md +25 -20
  6. package/package.json +5 -12
  7. package/src/analytics/google.ts +6 -6
  8. package/src/analytics/index.ts +4 -3
  9. package/src/analytics/tags.ts +14 -60
  10. package/src/analytics/umami.ts +8 -8
  11. package/src/astro/ConsentBanner.astro +25 -0
  12. package/src/astro/ConsentElement.astro +61 -0
  13. package/src/astro/Document.astro +44 -0
  14. package/src/astro/Image.astro +102 -0
  15. package/src/astro/MetaTags.astro +3 -26
  16. package/src/astro/RichText.astro +71 -0
  17. package/src/astro/Zoom.astro +61 -0
  18. package/src/astro/client.ts +19 -9
  19. package/src/astro/consent.ts +20 -0
  20. package/src/astro/dev-log.ts +8 -14
  21. package/src/astro/element.ts +111 -112
  22. package/src/astro/filters-view.ts +48 -64
  23. package/src/astro/filters.ts +46 -37
  24. package/src/astro/images.ts +27 -26
  25. package/src/astro/index.ts +2 -9
  26. package/src/astro/markup.ts +6 -6
  27. package/src/astro/site-routes.ts +10 -15
  28. package/src/config.ts +23 -36
  29. package/src/content/index.ts +2 -1
  30. package/src/content/marks.ts +13 -13
  31. package/src/content/rich.ts +58 -29
  32. package/src/hours.ts +48 -11
  33. package/src/i18n/define.ts +14 -74
  34. package/src/index.ts +41 -57
  35. package/src/jsonld/faq.ts +2 -1
  36. package/src/jsonld/node.ts +4 -14
  37. package/src/meta/index.ts +7 -13
  38. package/src/meta/share-image.ts +6 -19
  39. package/src/meta/tag.ts +1 -45
  40. package/src/money.ts +161 -6
  41. package/src/project.ts +84 -73
  42. package/src/routes/define.ts +8 -44
  43. package/src/routes/resolve.ts +1 -1
  44. package/src/site/api.ts +7 -33
  45. package/src/site/create.ts +6 -10
  46. package/src/site/define.ts +120 -0
  47. package/src/site/index.ts +2 -5
  48. package/src/site/page.ts +4 -2
  49. package/src/sitemap.ts +2 -35
  50. package/src/warn.ts +16 -17
  51. package/src/astro/dom.ts +0 -35
@@ -4,8 +4,8 @@
4
4
  * ```ts
5
5
  * const list = filters({
6
6
  * fields: {
7
- * q: { kind: "text", param: "q" },
8
- * category: { kind: "choice", param: "category" },
7
+ * q: { kind: "text" },
8
+ * category: { kind: "choice" },
9
9
  * },
10
10
  * items: entries.map((entry) => ({
11
11
  * key: entry.id,
@@ -40,10 +40,11 @@
40
40
  *
41
41
  * `flag` is not a tri-state: on, off and *either* is a `choice` with two values.
42
42
  */
43
- export type Field =
44
- | { readonly kind: "text"; readonly param?: string }
45
- | { readonly kind: "choice"; readonly param?: string }
46
- | { readonly kind: "flag"; readonly param?: string };
43
+ export interface Field {
44
+ readonly kind: "text" | "choice" | "flag";
45
+ /** The URL parameter. Defaults to the field's name; `false` keeps it out of the URL. */
46
+ readonly param?: string | false;
47
+ }
47
48
 
48
49
  /** The fields of one list, named by the caller. */
49
50
  export type FieldMap = Readonly<Record<string, Field>>;
@@ -66,14 +67,6 @@ export type ItemValue<F extends Field> = F["kind"] extends "flag"
66
67
  ? string | readonly string[]
67
68
  : string;
68
69
 
69
- /**
70
- * What one field contributes.
71
- *
72
- * @deprecated Say which side you are on: `StateValue` or `ItemValue`. This
73
- * named both while they were the same type, and is `StateValue` now.
74
- */
75
- export type FieldValue<F extends Field> = StateValue<F>;
76
-
77
70
  /** Every field's value for one item, derived from the field declaration. */
78
71
  export type ItemValues<F extends FieldMap> = {
79
72
  readonly [K in keyof F]: ItemValue<F[K]>;
@@ -144,6 +137,11 @@ export interface Filters<F extends FieldMap> {
144
137
  ): void;
145
138
  /** Clears one field, or all of them. Always a push: clearing is deliberate. */
146
139
  reset(field?: keyof F): void;
140
+ /**
141
+ * Also called on every change, after `onChange`. Returns the undo. For a
142
+ * helper like `searchBox` that keeps its own part of the page in step.
143
+ */
144
+ subscribe(listener: (change: FilterChange<F>) => void): () => void;
147
145
  /**
148
146
  * Reads the URL, applies it, and starts listening — returns the undo.
149
147
  *
@@ -189,16 +187,20 @@ export function filters<const F extends FieldMap>(
189
187
  }
190
188
  keys.add(item.key);
191
189
  }
192
- const params = new Set<string>();
190
+ // Field name → URL parameter, for the fields that are in the URL.
191
+ const params = new Map<string, string>();
192
+ const taken = new Set<string>();
193
193
  for (const name of names) {
194
- const param = fields[name]?.param;
195
- if (param === undefined) continue;
196
- if (params.has(param)) {
194
+ const given = fields[name]?.param;
195
+ if (given === false) continue;
196
+ const param = given ?? name;
197
+ if (taken.has(param)) {
197
198
  throw new Error(
198
199
  `Two filter fields share the URL parameter "${param}".`
199
200
  );
200
201
  }
201
- params.add(param);
202
+ taken.add(param);
203
+ params.set(name, param);
202
204
  }
203
205
 
204
206
  // Folded once at construction: doing it per item per keystroke is the
@@ -305,15 +307,16 @@ export function filters<const F extends FieldMap>(
305
307
  const next: Record<string, string | boolean> = {};
306
308
  for (const name of names) {
307
309
  const field = fields[name];
310
+ const param = params.get(name);
308
311
  if (field === undefined) continue;
309
- if (field.param === undefined) {
312
+ if (param === undefined) {
310
313
  // A state-only field is not in the URL, so the URL has nothing
311
314
  // to say about it. Clearing it here wiped a search mid-typing
312
315
  // whenever another parameter on the page moved.
313
316
  next[name] = state[name];
314
317
  continue;
315
318
  }
316
- const raw = search.get(field.param);
319
+ const raw = search.get(param);
317
320
  next[name] = field.kind === "flag" ? raw === FLAG_ON : (raw ?? "");
318
321
  }
319
322
  state = next as FilterState<F>;
@@ -328,33 +331,34 @@ export function filters<const F extends FieldMap>(
328
331
  // attributed the first time someone typed in the search box. Its own
329
332
  // are deleted first, then rewritten, so one state gives one URL.
330
333
  const search = new URLSearchParams(window.location.search);
331
- for (const name of names) {
332
- const param = fields[name]?.param;
333
- if (param !== undefined) search.delete(param);
334
- }
335
- for (const name of names) {
336
- const field = fields[name];
337
- if (field?.param === undefined) continue;
338
- if (field.kind === "flag") {
339
- if (state[name] === true) search.set(field.param, FLAG_ON);
334
+ for (const param of params.values()) search.delete(param);
335
+ for (const [name, param] of params) {
336
+ if (fields[name]?.kind === "flag") {
337
+ if (state[name] === true) search.set(param, FLAG_ON);
340
338
  continue;
341
339
  }
342
340
  const value = (state[name] as string).trim();
343
- if (value !== "") search.set(field.param, value);
341
+ if (value !== "") search.set(param, value);
344
342
  }
345
343
 
346
344
  const query = search.toString();
347
345
  // The bare path when nothing is left, rather than a trailing `?`.
348
346
  const url = query === "" ? window.location.pathname : `?${query}`;
349
- if (history === "push") window.history.pushState({}, "", url);
350
- else window.history.replaceState({}, "", url);
347
+ // `null` on a pushed step, so Astro's `ClientRouter` leaves its popstate
348
+ // to this module; a replaced entry keeps whatever state it already had.
349
+ if (history === "push") window.history.pushState(null, "", url);
350
+ else window.history.replaceState(window.history.state, "", url);
351
351
  }
352
352
 
353
353
  /** One field's value as the matcher and the URL will read it. */
354
354
  const normalized = (name: keyof F & string, value: unknown): unknown =>
355
355
  fields[name]?.kind === "text" ? (value as string).trim() : value;
356
356
 
357
- const announce = (): void => onChange({ state, matched });
357
+ const listeners = new Set<(change: FilterChange<F>) => void>();
358
+ const announce = (): void => {
359
+ onChange({ state, matched });
360
+ for (const listener of listeners) listener({ state, matched });
361
+ };
358
362
 
359
363
  recompute();
360
364
 
@@ -415,8 +419,13 @@ export function filters<const F extends FieldMap>(
415
419
  announce();
416
420
  },
417
421
 
422
+ subscribe(listener) {
423
+ listeners.add(listener);
424
+ return () => listeners.delete(listener);
425
+ },
426
+
418
427
  attach() {
419
- const listeners = new AbortController();
428
+ const events = new AbortController();
420
429
 
421
430
  // A `popstate` listener that outlives its page is the leak with no
422
431
  // visible symptom: it keeps rendering into markup that was replaced.
@@ -427,7 +436,7 @@ export function filters<const F extends FieldMap>(
427
436
  recompute();
428
437
  announce();
429
438
  },
430
- { signal: listeners.signal }
439
+ { signal: events.signal }
431
440
  );
432
441
 
433
442
  attached = true;
@@ -439,7 +448,7 @@ export function filters<const F extends FieldMap>(
439
448
  announce();
440
449
 
441
450
  return () => {
442
- listeners.abort();
451
+ events.abort();
443
452
  attached = false;
444
453
  };
445
454
  },
@@ -158,16 +158,6 @@ export async function shareImage(
158
158
  // Not warned about here. `metaFor` already warns about an undersized
159
159
  // `og:image`, on every page and whether or not this helper made it, so
160
160
  // saying it twice would only halve the chance either line is read.
161
- //
162
- // Measured as a scale factor, not by comparing dimensions, because the two
163
- // fits reach the box differently. `cover` scales until *both* sides are
164
- // covered, so a source short in either dimension would be enlarged.
165
- // `contain` scales until the *first* side fits, so an 800x800 logo lands at
166
- // 630x630 — a reduction, though it is narrower than 1200.
167
- const scale =
168
- fit === "cover"
169
- ? Math.max(width / source.width, height / source.height)
170
- : Math.min(width / source.width, height / source.height);
171
161
  const image = await getImage({
172
162
  src: source,
173
163
  width,
@@ -187,20 +177,32 @@ export async function shareImage(
187
177
  })(),
188
178
  });
189
179
 
190
- // The size that came *back*, not the size asked for.
191
- //
192
- // Neither `attributes` nor `options` can be trusted here: both echo the
193
- // request, and the request is exactly what was not honoured. What is known
194
- // is the rule — Astro refuses to enlarge — so a source too small for the box
195
- // comes back untouched, at its own size. Reporting the request instead would
196
- // put an `og:image:width` in every head that the file does not match, which
197
- // is worse than a small image: a scraper lays out a space and finds
198
- // something else in it.
199
- const produced =
200
- scale > 1
201
- ? { width: source.width, height: source.height }
202
- : { width, height };
203
- return { src: image.src, ...produced, format };
180
+ return {
181
+ src: image.src,
182
+ ...producedSize(source, width, height, fit),
183
+ format,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * The size `getImage` actually returns, not the size asked for.
189
+ *
190
+ * Neither `attributes` nor `options` can be trusted: both echo the request.
191
+ * Astro refuses to enlarge, so a source too small for the box comes back
192
+ * untouched, at its own size. Measured as a scale factor because the fits reach
193
+ * the box differently: `cover` scales until *both* sides are covered, `contain`
194
+ * until the *first* side fits.
195
+ */
196
+ function producedSize(
197
+ source: ImageMetadata,
198
+ width: number,
199
+ height: number,
200
+ fit: "cover" | "contain"
201
+ ): { width: number; height: number } {
202
+ const pick = fit === "cover" ? Math.max : Math.min;
203
+ return pick(width / source.width, height / source.height) > 1
204
+ ? { width: source.width, height: source.height }
205
+ : { width, height };
204
206
  }
205
207
 
206
208
  /**
@@ -307,8 +309,7 @@ export async function photoSet(
307
309
  });
308
310
  return {
309
311
  src: image.src,
310
- width: ratio.width,
311
- height: ratio.height,
312
+ ...producedSize(source, ratio.width, ratio.height, "cover"),
312
313
  format,
313
314
  };
314
315
  })
@@ -22,15 +22,8 @@
22
22
  * - `public-files.ts` types the contents of `public/`, the one part of a site
23
23
  * the compiler cannot otherwise see.
24
24
  *
25
- * The rest of this folder is imported directly rather than from here, because
26
- * each is reached from a different place in a project: `…/astro/images` from
27
- * a view or a config, `…/astro/consent` from a client-side script, and
28
- * `…/astro/meta-tags` from a layout.
29
- *
30
- * That last one is a `.astro` component, and the only one lib carries. It is
31
- * here rather than in a project's `src/` because it holds no design and no
32
- * copy — it renders the `MetaTag` union and nothing else, which makes it the
33
- * mirror of `asMetaTag` in `lib/meta/tag.ts` rather than a piece of a theme.
25
+ * The rest is imported from its own path: `…/astro/images` from a view,
26
+ * `…/astro/document` from a layout, `…/client` from a script.
34
27
  */
35
28
 
36
29
  export {
@@ -99,7 +99,7 @@ type CheckedMarkupName<N extends string> = string extends N
99
99
  ? unknown
100
100
  : MalformedMarkupName<N>;
101
101
 
102
- type CheckedTagName<N extends string> = string extends N
102
+ export type CheckedTagName<N extends string> = string extends N
103
103
  ? unknown
104
104
  : IsHyphenated<N> extends true
105
105
  ? N extends `${string}-${string}`
@@ -108,7 +108,7 @@ type CheckedTagName<N extends string> = string extends N
108
108
  : MalformedTagName<N>;
109
109
 
110
110
  /** The same, for every key of a fields or roles object. */
111
- type CheckedKeys<T> = {
111
+ export type CheckedKeys<T> = {
112
112
  [K in keyof T]: K extends string
113
113
  ? IsCamelCase<K> extends true
114
114
  ? unknown
@@ -515,20 +515,20 @@ export function markup<
515
515
  export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
516
516
 
517
517
  /** The keys the component object uses itself, so no role may take them. */
518
- type Reserved = "tag" | "root";
518
+ export type Reserved = "tag" | "root";
519
519
 
520
520
  /**
521
521
  * What marks a component's root element.
522
522
  *
523
523
  * One name, so a project gives every root a display in a single stylesheet rule
524
- * rather than a class per component, and so `defineElement` can say when a
524
+ * rather than a class per component, and so `element` can say when a
525
525
  * template forgot to spread it.
526
526
  */
527
527
  export const ROOT_ATTRIBUTE = "data-atlas-root";
528
528
 
529
529
  export type Component<Tag extends string, R extends ComponentRoles> = {
530
530
  /** The custom element's name: what the template renders, and what
531
- * `defineElement` registers the behaviour under. */
531
+ * `element` registers the behaviour under. */
532
532
  readonly tag: Tag;
533
533
 
534
534
  /**
@@ -549,7 +549,7 @@ export type Component<Tag extends string, R extends ComponentRoles> = {
549
549
  * });
550
550
  *
551
551
  * // template: <faq.tag {...faq.root}> … <details {...faq.row.attrs({ key, text })}>
552
- * // script: defineElement(faq, (host, signal, { row }) => row.all().forEach(…));
552
+ * // behaviour: see `element` in element.ts, which builds on this.
553
553
  * ```
554
554
  *
555
555
  * Two things `markup` alone leaves to the project:
@@ -67,16 +67,10 @@ export interface SiteRoutesOptions {
67
67
  *
68
68
  * The finished file rather than a flag, because this is the one output whose
69
69
  * words lib does not have: page names and summaries live in a catalog under
70
- * a key convention it cannot guess. Omitting it does not turn the file off —
71
- * `site.llms()` still writes one, listing every page by route id with no
72
- * summaries. That is a worse file and a real one; switching it off is a
73
- * decision, made with `llms: false` in the site config.
70
+ * a key convention it cannot guess. Omitted, `site.llms()` writes one listing
71
+ * every page by route id with no summaries.
74
72
  */
75
73
  readonly llms?: GeneratedFile;
76
- /** Write the sitemap. Defaults to true. */
77
- readonly sitemap?: boolean;
78
- /** Write `robots.txt`. Defaults to true. */
79
- readonly robots?: boolean;
80
74
  }
81
75
 
82
76
  /**
@@ -98,7 +92,7 @@ export interface SiteRoutesOptions {
98
92
  * again for the reason above.
99
93
  */
100
94
  export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
101
- const { site, redirects, llms, sitemap = true, robots = true } = options;
95
+ const { site, redirects, llms } = options;
102
96
 
103
97
  /** `llms.txt (4.2 kB)` — the name and what it actually weighs. */
104
98
  const describe = (file: GeneratedFile): string =>
@@ -115,12 +109,9 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
115
109
  // it and leaves the same string in `llms.txt` untouched. A build is always
116
110
  // right, since it evaluates the config in a fresh process.
117
111
  const generate = (): GeneratedFile[] => {
118
- const files: GeneratedFile[] = [];
119
- if (sitemap) files.push(...site.sitemap().files);
120
- if (robots) files.push(site.robots());
121
- // `llmsUrl` is what every page's head links to, so a file must exist at
122
- // it: the described one when given, a bare listing otherwise. Undefined
123
- // means the config turned both off.
112
+ const files: GeneratedFile[] = [...site.sitemap().files, site.robots()];
113
+ // Every page's head links to it, so one is written unless the config
114
+ // turned it off.
124
115
  if (site.llmsUrl !== undefined) files.push(llms ?? site.llms());
125
116
  // Length rather than presence: a site with no rules gets no file, not an
126
117
  // empty one.
@@ -280,6 +271,7 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
280
271
  // optional, so `buld: {…}` would be assignable and silently do
281
272
  // nothing. The annotation is what makes a typo an error.
282
273
  const config: ConfigUpdate = {
274
+ site: site.url,
283
275
  output: "static",
284
276
  build: { format: "file" },
285
277
  trailingSlash: "ignore",
@@ -348,6 +340,9 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
348
340
  // every locale is prefixed, which is the first URL anyone
349
341
  // opens. Serving them here makes dev answer as production
350
342
  // will, rather than as a place where the rules do not exist.
343
+ // Not Astro's `redirects`: it serves every redirect to a
344
+ // non-route (an external URL, a public file) as 301,
345
+ // whatever status it was given.
351
346
  // Matched with and without a trailing slash, because a host
352
347
  // either normalises one away before its rules run or treats
353
348
  // the two as the same URL. Dev should not be the only place
package/src/config.ts CHANGED
@@ -99,18 +99,7 @@ export interface SiteConfigShape {
99
99
  readonly defaultRouting: RoutingConfig<string>;
100
100
  readonly sitemap?: SitemapConfig;
101
101
  readonly robots?: RobotsConfig;
102
- /**
103
- * `false` to publish no `llms.txt`. Omitted, one is published under the
104
- * default name.
105
- *
106
- * Opt-out like the two above, even though lib cannot write this one
107
- * unaided — it needs a name and a summary per page, which only the consumer
108
- * has. Forgetting to supply them is a build error rather than a reason to
109
- * make the file opt-in: this key is also what puts `<link rel="describedby">`
110
- * in every head, and a page cannot tell whether `site.llms()` was called
111
- * somewhere, so the declaration stands in for it and `siteRoutes` checks
112
- * that it was kept.
113
- */
102
+ /** `false` publishes no `llms.txt`, and no page links to one. */
114
103
  readonly llms?: LlmsConfig | false;
115
104
  // No share image here on purpose: an image belongs to the page it
116
105
  // represents, so the page passes its own to `metaFor`.
@@ -135,25 +124,28 @@ export type LocalesOf<C> = C extends { readonly locales: infer M }
135
124
  * `string`, and the config reports a cascade of malformed-tag errors on lines
136
125
  * that were fine.
137
126
  */
138
- export function defineSiteConfig<
139
- const T extends SiteConfigShape & {
140
- readonly defaultRouting: {
141
- readonly defaultLocale: StringKeys<T["locales"]>;
142
- // Mapped over the keys actually written, not declared as a
143
- // `Partial<Record<…>>`: a constraint is checked by assignability,
144
- // and an extra key is assignable to a type whose properties are all
145
- // optional. Demanding an impossible value for a bad key is what
146
- // makes the error land on that line.
147
- readonly pageSegmentByLocale?: {
148
- readonly [K in StringKeys<
149
- T["defaultRouting"]["pageSegmentByLocale"]
150
- >]: K extends StringKeys<T["locales"]>
151
- ? string
152
- : LocaleNotDeclared<K>;
153
- };
127
+ /** Everything a site config must satisfy; see `defineSite`. */
128
+ export type SiteConfigChecks<T extends SiteConfigShape> = SiteConfigShape & {
129
+ readonly defaultRouting: {
130
+ readonly defaultLocale: StringKeys<T["locales"]>;
131
+ // Mapped over the keys actually written, not declared as a
132
+ // `Partial<Record<…>>`: a constraint is checked by assignability,
133
+ // and an extra key is assignable to a type whose properties are all
134
+ // optional. Demanding an impossible value for a bad key is what
135
+ // makes the error land on that line.
136
+ readonly pageSegmentByLocale?: {
137
+ readonly [K in StringKeys<
138
+ T["defaultRouting"]["pageSegmentByLocale"]
139
+ >]: K extends StringKeys<T["locales"]>
140
+ ? string
141
+ : LocaleNotDeclared<K>;
154
142
  };
155
- readonly locales: ValidateLocales<T["locales"]>;
156
- },
143
+ };
144
+ readonly locales: ValidateLocales<T["locales"]>;
145
+ };
146
+
147
+ export function defineSiteConfig<
148
+ const T extends SiteConfigShape & SiteConfigChecks<T>,
157
149
  >(config: T): T {
158
150
  return config;
159
151
  }
@@ -179,12 +171,7 @@ export interface LocaleNotDeclared<L extends string> {
179
171
  readonly __LOCALE_NOT_DECLARED__: `locale "${L}" is not among this site's locales, so anything keyed by it would never be read`;
180
172
  }
181
173
 
182
- /**
183
- * Editor-facing error for anything written for a locale a project does not ship.
184
- *
185
- * Lives here rather than beside one of its users because two of them need it:
186
- * `defineProject`, for locale metadata, and `defineMessageOverrides`, for copy.
187
- */
174
+ /** Editor-facing error for anything written for a locale a project does not ship. */
188
175
  export interface LocaleNotEnabled<L extends string> {
189
176
  readonly __LOCALE_NOT_ENABLED__: `locale "${L}" is not among this project's enabledLocales, so anything written for it would never be built`;
190
177
  }
@@ -17,7 +17,7 @@
17
17
  * <RichText spans={rich("about.intro")} />
18
18
  *
19
19
  * // the same sentence, where a string is what fits
20
- * description: plain(rich("about.intro"))
20
+ * description: site.plain(locale)("about.intro")
21
21
  * ```
22
22
  *
23
23
  * There is no authored `ContentItem[]`. Where a project needs to compose runs
@@ -29,6 +29,7 @@ export { assertNoMarks, type ParsedSpan, parseMarks } from "./marks.ts";
29
29
  export {
30
30
  createPlainText,
31
31
  createRichText,
32
+ html,
32
33
  type LinkResolver,
33
34
  type LinkTarget,
34
35
  type PlainTextFor,
@@ -48,7 +48,7 @@ export type ParsedSpan =
48
48
  | { readonly kind: "text"; readonly text: string }
49
49
  | { readonly kind: "bold"; readonly text: string }
50
50
  | {
51
- readonly kind: "styled";
51
+ readonly kind: "variant";
52
52
  readonly text: string;
53
53
  readonly variant: string;
54
54
  }
@@ -58,8 +58,8 @@ export type ParsedSpan =
58
58
  /** The slot to fill, e.g. `venue` in `[a:venue]`. Never a URL. */
59
59
  readonly name: string;
60
60
  }
61
- | { readonly kind: "email"; readonly text: string }
62
- | { readonly kind: "phone"; readonly text: string }
61
+ | { readonly kind: "mail"; readonly text: string }
62
+ | { readonly kind: "tel"; readonly text: string }
63
63
  | { readonly kind: "break" };
64
64
 
65
65
  /**
@@ -81,10 +81,10 @@ export type ParsedSpan =
81
81
  */
82
82
  const MARKS = {
83
83
  b: "bold",
84
- v: "styled",
84
+ v: "variant",
85
85
  a: "link",
86
- mail: "email",
87
- tel: "phone",
86
+ mail: "mail",
87
+ tel: "tel",
88
88
  } as const;
89
89
 
90
90
  type MarkName = keyof typeof MARKS;
@@ -244,9 +244,9 @@ export function parseMarks(
244
244
  switch (MARKS[mark.name]) {
245
245
  case "bold":
246
246
  return { kind: "bold", text: content };
247
- case "styled":
247
+ case "variant":
248
248
  return {
249
- kind: "styled",
249
+ kind: "variant",
250
250
  text: content,
251
251
  variant: mark.argument,
252
252
  };
@@ -256,10 +256,10 @@ export function parseMarks(
256
256
  text: content,
257
257
  name: mark.argument,
258
258
  };
259
- case "email":
260
- return { kind: "email", text: content };
261
- case "phone":
262
- return { kind: "phone", text: content };
259
+ case "mail":
260
+ return { kind: "mail", text: content };
261
+ case "tel":
262
+ return { kind: "tel", text: content };
263
263
  }
264
264
  }
265
265
 
@@ -407,7 +407,7 @@ export function assertNoMarks(template: string, at: string): void {
407
407
  );
408
408
  if (marked) {
409
409
  throw new Error(
410
- `${at} carries marks, and t() can only print them. Read it with rich(), or with plain(rich(…)) for the words alone.`
410
+ `${at} carries marks, and t() can only print them. Read it with rich(), or with site.plain() for the words alone.`
411
411
  );
412
412
  }
413
413
  }