@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
@@ -22,6 +22,7 @@ import {
22
22
  joinUrl,
23
23
  type UrlPath,
24
24
  } from "../url.ts";
25
+ import { escapeXml } from "../xml.ts";
25
26
  import { type LinkNames, type ParsedSpan, parseMarks } from "./marks.ts";
26
27
 
27
28
  /**
@@ -52,7 +53,7 @@ export type Span =
52
53
  * edit rather than a sweep through fifty-four deployments' copy.
53
54
  */
54
55
  | {
55
- readonly kind: "styled";
56
+ readonly kind: "variant";
56
57
  readonly text: string;
57
58
  readonly variant: string;
58
59
  }
@@ -104,12 +105,12 @@ export type Span =
104
105
  readonly href: Hash;
105
106
  }
106
107
  | {
107
- readonly kind: "email";
108
+ readonly kind: "mail";
108
109
  readonly text: string;
109
110
  readonly href: MailtoUrl;
110
111
  }
111
112
  | {
112
- readonly kind: "phone";
113
+ readonly kind: "tel";
113
114
  readonly text: string;
114
115
  readonly href: TelUrl;
115
116
  }
@@ -229,7 +230,7 @@ export type RichTextFor<Catalog, RouteId extends string> = <
229
230
  ...args: RichArgsOf<Catalog, K, RouteId>
230
231
  ) => RichText;
231
232
 
232
- /** `plain()`, keyed and parameterised exactly as `t()` is. */
233
+ /** `site.plain()`, keyed and parameterised exactly as `t()` is. */
233
234
  export type PlainTextFor<Catalog> = <K extends StringKeys<Catalog>>(
234
235
  ...args: TranslateArgsOf<Catalog, K>
235
236
  ) => string;
@@ -285,8 +286,8 @@ function resolve(
285
286
  case "text":
286
287
  case "bold":
287
288
  return { kind: span.kind, text };
288
- case "styled":
289
- return { kind: "styled", text, variant: span.variant };
289
+ case "variant":
290
+ return { kind: "variant", text, variant: span.variant };
290
291
  case "link":
291
292
  return resolveLink(
292
293
  text,
@@ -295,10 +296,10 @@ function resolve(
295
296
  link,
296
297
  origin
297
298
  );
298
- case "email":
299
- return { kind: "email", text, href: mailto(text, at) };
300
- case "phone":
301
- return { kind: "phone", text, href: tel(text, at) };
299
+ case "mail":
300
+ return { kind: "mail", text, href: mailto(text, at) };
301
+ case "tel":
302
+ return { kind: "tel", text, href: tel(text, at) };
302
303
  }
303
304
  }
304
305
 
@@ -473,19 +474,52 @@ function tel(text: string, at: string): TelUrl {
473
474
  }
474
475
 
475
476
  /**
476
- * The words alone, for everywhere that takes a string rather than markup.
477
+ * The runs as inline HTML, for a field that takes markup rather than a page.
477
478
  *
478
- * The reason this whole module sits in lib rather than in each project. A meta
479
- * description, an `llms.txt` summary and a structured-data `description` all
480
- * want the same sentence the page renders, and every one of them takes a plain
481
- * string — so without this each project flattens the runs by hand, and they
482
- * drift. The implementation this replaces did exactly that: a chain of `if`s
483
- * per kind, `return ""` for the ones it could not render, and a `throw` on
484
- * anything it had not been taught.
479
+ * Written for a `FAQPage` answer, whose `text` accepts a handful of tags —
480
+ * `<a>`, `<strong>`, `<br>` among them. Only those are emitted: a `[v:]` role
481
+ * is presentation, which structured data has no use for, so it is its words.
485
482
  *
486
- * A `[br]` becomes a space, because that is what it is once the markup is gone,
487
- * and runs of whitespace collapse — copy split across a line break otherwise
488
- * arrives with a double space in the middle of a `<meta>` tag.
483
+ * Links carry their absolute `url`, since nothing reading this has a page to
484
+ * resolve a path against. An anchor has none, so it is its words too.
485
+ *
486
+ * Every piece of copy is escaped: the field is read as HTML, so an `&` or a `<`
487
+ * in a translation would otherwise be read as markup.
488
+ */
489
+ export function html(rich: RichText): string {
490
+ return rich
491
+ .map((span) => {
492
+ switch (span.kind) {
493
+ case "text":
494
+ case "variant":
495
+ return escapeXml(span.text);
496
+ case "bold":
497
+ return `<strong>${escapeXml(span.text)}</strong>`;
498
+ case "link":
499
+ return span.to === "anchor"
500
+ ? escapeXml(span.text)
501
+ : anchor(span.url, span.text);
502
+ case "mail":
503
+ case "tel":
504
+ return anchor(span.href, span.text);
505
+ case "break":
506
+ return "<br>";
507
+ default: {
508
+ const unreachable: never = span;
509
+ return unreachable;
510
+ }
511
+ }
512
+ })
513
+ .join("");
514
+ }
515
+
516
+ function anchor(href: string, text: string): string {
517
+ return `<a href="${escapeXml(href)}">${escapeXml(text)}</a>`;
518
+ }
519
+
520
+ /**
521
+ * The words of runs already built — for a message whose values `rich()` was
522
+ * given in code. A `[br]` becomes a space and whitespace collapses.
489
523
  */
490
524
  export function plain(rich: RichText): string {
491
525
  return collapse(
@@ -505,14 +539,9 @@ function collapse(parts: readonly string[]): string {
505
539
  }
506
540
 
507
541
  /**
508
- * The same, straight from a message key — `plain(rich(…))` without the nesting.
509
- *
510
- * Which sounds like sugar and is mostly about where it gets used. The callers
511
- * that want words rather than runs are the ones furthest from a renderer:
512
- * `llms()`'s `describe`, a `<meta name="description">`, a structured-data
513
- * `description`. Those are already assembling several strings at once, and
514
- * `plain(rich("about.intro", { company }))` reads as two operations there when
515
- * it is one question — what does this message say.
542
+ * The words of a message alone, for everywhere that takes a string rather than
543
+ * markup: a meta description, an `llms.txt` summary, a structured-data
544
+ * `description`. A `[br]` becomes a space and whitespace collapses.
516
545
  *
517
546
  * It also answers that question for a message with no marks at all, which `t()`
518
547
  * would too. That overlap is deliberate: a description built this way keeps
package/src/hours.ts CHANGED
@@ -164,18 +164,22 @@ export function groupHours(
164
164
  }
165
165
 
166
166
  /**
167
- * The words a printed week needs, which lib does not have.
168
- *
169
- * Day names and "closed" are copy: they are translated, and lib holds no copy.
170
- * Everything else about the line — which days collapse into a span, what order
171
- * they come in, where the dashes go — is mechanical and is done here, because
172
- * otherwise every project rewrites the same joins in a component.
167
+ * How to print a week. Day names come from `Intl` in `locale`; "closed" is
168
+ * copy, so the caller translates it.
173
169
  */
174
170
  export interface HoursFormat {
175
- /** What to call a day. Usually a lookup in the project's catalog. */
176
- day(day: Weekday): string;
171
+ /** The language the days are named in, e.g. `"el-GR"`. */
172
+ locale: string;
173
+ /** `Mon` or `Monday`. Defaults to `"short"`. */
174
+ weekday?: "short" | "long";
177
175
  /** The word for a day the venue is shut. */
178
176
  closed: string;
177
+ /**
178
+ * Times print as written (`14:00`) unless this is `"h12"` (`2:00 PM`).
179
+ * Not the locale's default, which gives Greek a 12-hour clock nobody there
180
+ * uses on a door.
181
+ */
182
+ hourCycle?: "h12";
179
183
  /** Between the ends of a span: `Mon–Thu`, `14:00–23:30`. */
180
184
  between?: string;
181
185
  /** Between spans: `Mon–Thu, Sun`. */
@@ -207,20 +211,53 @@ export function formatHours(
207
211
  ): readonly FormattedHours[] {
208
212
  const between = format.between ?? "–";
209
213
  const and = format.and ?? ", ";
214
+ // 2024-01-01 was a Monday, so `WEEK[i]` falls on the 1st plus `i`.
215
+ const names = new Intl.DateTimeFormat(format.locale, {
216
+ weekday: format.weekday ?? "short",
217
+ timeZone: "UTC",
218
+ });
219
+ const dayName = (day: Weekday): string =>
220
+ names.format(new Date(Date.UTC(2024, 0, 1 + WEEK.indexOf(day))));
221
+ const clock =
222
+ format.hourCycle === "h12"
223
+ ? new Intl.DateTimeFormat(format.locale, {
224
+ hour: "numeric",
225
+ minute: "2-digit",
226
+ hourCycle: "h12",
227
+ timeZone: "UTC",
228
+ })
229
+ : undefined;
230
+ const time = (at: TimeOfDay): string =>
231
+ clock === undefined
232
+ ? at
233
+ : clock.format(
234
+ new Date(
235
+ Date.UTC(
236
+ 2024,
237
+ 0,
238
+ 1,
239
+ Number(at.slice(0, 2)),
240
+ Number(at.slice(3, 5))
241
+ )
242
+ )
243
+ );
210
244
 
211
245
  return groupHours(hours, format.weekStart).map((group) => ({
212
246
  days: group.runs
213
247
  .map(([first, last]) =>
214
248
  first === last
215
- ? format.day(first)
216
- : `${format.day(first)}${between}${format.day(last)}`
249
+ ? dayName(first)
250
+ : `${dayName(first)}${between}${dayName(last)}`
217
251
  )
218
252
  .join(and),
219
253
  times:
220
254
  group.hours === "closed"
221
255
  ? format.closed
222
256
  : group.hours
223
- .map((range) => `${range.opens}${between}${range.closes}`)
257
+ .map(
258
+ (range) =>
259
+ `${time(range.opens)}${between}${time(range.closes)}`
260
+ )
224
261
  .join(and),
225
262
  }));
226
263
  }
@@ -11,15 +11,15 @@ import type {
11
11
  * `true` when `Catalog` declares every key in `Required`; otherwise an object
12
12
  * type naming the ones it does not.
13
13
  *
14
- * Annotate a `const … = true` with it. A missing message then fails at that
15
- * declaration, and the error text lists exactly which keys are absent, rather
16
- * than surfacing later as a page with no `<title>`:
14
+ * Check it with `satisfies`. A missing message then fails on that line, and the
15
+ * error text lists exactly which keys are absent, rather than surfacing later
16
+ * as a page with no `<title>`:
17
17
  *
18
18
  * ```ts
19
- * export const routeMessagesAreComplete: CatalogCovers<
19
+ * true satisfies CatalogCovers<
20
20
  * typeof baseMessages,
21
21
  * `route.${RouteId}.${"nav" | "title"}`
22
- * > = true;
22
+ * >;
23
23
  * ```
24
24
  *
25
25
  * The pattern earns its keep over a plain `Required extends StringKeys<Catalog>`
@@ -67,7 +67,7 @@ type SelfConsistent<E, K> = [MismatchedLocales<E, EntryTokens<E>>] extends [
67
67
  MismatchedLocales<E, EntryTokens<E>> & string
68
68
  >;
69
69
 
70
- type ValidateBase<T> = { [K in keyof T]: SelfConsistent<T[K], K> };
70
+ export type ValidateBase<T> = { [K in keyof T]: SelfConsistent<T[K], K> };
71
71
 
72
72
  /**
73
73
  * Rejects locale keys the site does not ship.
@@ -76,7 +76,7 @@ type ValidateBase<T> = { [K in keyof T]: SelfConsistent<T[K], K> };
76
76
  * from the literal rather than checked against a fixed target, so an extra key
77
77
  * structurally satisfies the constraint and would sit in the catalog unread.
78
78
  */
79
- type NoExtraLocales<T, L extends string> = {
79
+ export type NoExtraLocales<T, L extends string> = {
80
80
  [K in keyof T]: NoExcessKeys<T[K], L>;
81
81
  };
82
82
 
@@ -102,21 +102,7 @@ export function defineMessages<
102
102
  return catalog;
103
103
  }
104
104
 
105
- /**
106
- * Everything a copy overlay must satisfy — and the only place it is stated.
107
- *
108
- * The mirror of `RouteOverlayKeys` / `RouteOverlayShape`, for the same reason:
109
- * `defineMessageOverrides` and `defineProject` both accept an overlay, and a
110
- * check added to one and not the other makes that one silently the safer place
111
- * to write copy. Both name these, so a new rule reaches both.
112
- *
113
- * Excess keys go in the constraint, where a typo'd message id reports on that
114
- * key; the placeholder and locale rules go in the parameter, because they map
115
- * the whole object.
116
- */
117
- export type MessageOverlayKeys<Base, T> = NoExcessKeys<T, StringKeys<Base>>;
118
-
119
- /** @see {@link MessageOverlayKeys} — the parameter-position half. */
105
+ /** What `defineProject` checks an `overrideMessages` against: placeholders and locales. */
120
106
  export type ValidateOverrideCatalog<
121
107
  T,
122
108
  Base,
@@ -135,63 +121,17 @@ type ValidateOverrides<T, Base> = {
135
121
  };
136
122
 
137
123
  /**
138
- * Declares a project's copy overlay: any subset of keys, any subset of locales.
139
- *
140
- * Both the message ids and the locales are inferred from the base catalog you
141
- * pass in, so overriding one locale of one string needs no type arguments.
142
- *
143
- * Enforced at compile time:
144
- * - the key must exist in the base catalog, so a typo is rejected rather than
145
- * silently becoming a string nothing reads;
146
- * - the replacement must use exactly the `{placeholders}` and `[marks]` of the
147
- * base message, so every existing `t()` and `rich()` call site stays correct.
148
- */
149
- export function defineMessageOverrides<
150
- const C extends SiteConfigShape,
151
- const Base extends BaseCatalog<LocalesOf<C>>,
152
- const T extends OverrideCatalog<LocalesOf<C>> & MessageOverlayKeys<Base, T>,
153
- >(
154
- // Read for its type only, and taken first like every other definer here.
155
- // It supplies the locale universe directly rather than having it inferred
156
- // from the catalog's entries: `LocalesOfCatalog` reads the locales actually
157
- // *used*, which is the same union for a well-formed catalog and a narrower
158
- // one for a partial or empty draft.
159
- //
160
- // Note what this does not do: `config` declares which locales the site has,
161
- // not which ones a project publishes. Rejecting copy for a language a given
162
- // deployment has switched off is `MessageOverrides`' job, because only the
163
- // project knows its own `enabledLocales`.
164
- _config: C,
165
- _base: Base,
166
- overrides: T & ValidateOverrides<T, Base> & NoExtraLocales<T, LocalesOf<C>>
167
- ): T {
168
- return overrides;
169
- }
170
-
171
- /**
172
- * The shape of one project's copy overrides: known keys, published locales.
173
- *
174
- * For annotating the object itself, which is what puts the error on the line
175
- * that is wrong:
124
+ * One project's copy overrides — known keys, published locales — for an overlay
125
+ * kept in its own file:
176
126
  *
177
127
  * ```ts
178
- * const overrides = {
128
+ * export const romeMessages = {
179
129
  * "site.cta": { "en-US": "Book a room" },
180
- * } satisfies MessageOverrides<typeof defaultMessages, EnabledLocale>;
181
- *
182
- * export const overrideMessages = defineMessageOverrides(
183
- * config,
184
- * defaultMessages,
185
- * overrides
186
- * );
130
+ * } as const satisfies MessageOverrides<typeof defaultMessages, EnabledLocale>;
187
131
  * ```
188
132
  *
189
- * The two halves do different jobs and both are needed. This type knows the
190
- * project's locale set, so it rejects copy for a language that is never built —
191
- * but it cannot compare an override's *text* with the default's, so it cannot
192
- * see a dropped `{placeholder}`. `defineMessageOverrides` is the reverse: it
193
- * reads both texts, and knows nothing about which locales this deployment
194
- * publishes. Annotate with one, wrap in the other.
133
+ * A dropped `{placeholder}` is not visible to a type like this; `defineProject`
134
+ * reports it, naming the key and locale.
195
135
  */
196
136
  export type MessageOverrides<Catalog, L extends string> = {
197
137
  readonly [K in StringKeys<Catalog>]?: Readonly<Partial<Record<L, string>>>;
package/src/index.ts CHANGED
@@ -1,27 +1,19 @@
1
1
  /**
2
2
  * The public API. A consuming project should need nothing outside this module.
3
3
  *
4
- * The flow is four steps, each inferring its types from the values of the last:
4
+ * Everything starts from `defineSite`:
5
5
  *
6
6
  * ```ts
7
- * // 1. site.config.ts — which languages exist, how URLs are shaped
8
- * export default defineSiteConfig({ locales: {...}, defaultRouting: {...} })
9
- *
10
- * // 2. src/data — the base copy and routes
11
- * export const baseMessages = defineMessages(config, {...})
12
- * export const baseRoutes = defineRoutes(config, {...})
13
- *
14
- * // 3. projects/<name>/project.ts — one deployment's overlay
15
- * export default defineProject(config, baseMessages, baseRoutes, {...})
16
- *
17
- * // 4. src/site.ts — the API the pages use
18
- * export const site = createSite(config, baseMessages, baseRoutes, project)
7
+ * export const atlas = defineSite({ locales: {...}, defaultRouting: {...} }) // site.config.ts
8
+ * export const defaultMessages = atlas.messages({...}) // messages.ts
9
+ * export const defaultRoutes = atlas.routes({...}) // routes.ts
10
+ * export default atlas.project(defaultMessages, defaultRoutes, {...}) // projects/<name>/project.ts
11
+ * export const site = atlas.site(defaultMessages, defaultRoutes, project) // site.ts
19
12
  * ```
20
13
  */
21
14
 
22
15
  export {
23
16
  type AnalyticsSettings,
24
- type AnalyticsTag,
25
17
  type AnalyticsTags,
26
18
  CONSENT_UPDATE_GLOBAL,
27
19
  type ConsentDefaults,
@@ -32,14 +24,13 @@ export {
32
24
  type UmamiSettings,
33
25
  type UmamiTracker,
34
26
  } from "./analytics/index.ts";
35
- export {
36
- defineSiteConfig,
37
- type LanguageTag,
38
- type LocaleMeta,
39
- type LocalesOf,
40
- type ResolvedLocaleMeta,
41
- type RoutingConfig,
42
- type SiteConfigShape,
27
+ export type {
28
+ LanguageTag,
29
+ LocaleMeta,
30
+ LocalesOf,
31
+ ResolvedLocaleMeta,
32
+ RoutingConfig,
33
+ SiteConfigShape,
43
34
  } from "./config.ts";
44
35
  export {
45
36
  assertCoordinates,
@@ -72,6 +63,7 @@ export {
72
63
  // a slot filled with a route id resolves against nothing there. `site.rich(locale)` is
73
64
  // the only way in, and `Span` is what a renderer switches on.
74
65
  export {
66
+ html,
75
67
  type LinkTarget,
76
68
  type PlainTextFor,
77
69
  plain,
@@ -99,13 +91,11 @@ export {
99
91
  type Weekday,
100
92
  type WeeklyHours,
101
93
  } from "./hours.ts";
102
- export {
103
- type BaseCatalog,
104
- type CatalogCovers,
105
- defineMessageOverrides,
106
- defineMessages,
107
- type MessageOverrides,
108
- type OverrideCatalog,
94
+ export type {
95
+ BaseCatalog,
96
+ CatalogCovers,
97
+ MessageOverrides,
98
+ OverrideCatalog,
109
99
  } from "./i18n/define.ts";
110
100
  export type {
111
101
  EntryPlaceholders,
@@ -170,10 +160,8 @@ export {
170
160
  type ArticleContent,
171
161
  buildMeta,
172
162
  buildNotFoundMeta,
173
- type ImageFormat,
174
163
  type MetaContent,
175
164
  type MetaTag,
176
- type OgImage,
177
165
  type OpenGraphType,
178
166
  type PageKind,
179
167
  type RobotsPolicy,
@@ -196,7 +184,7 @@ export {
196
184
  type TieredPrice,
197
185
  tierRange,
198
186
  } from "./money.ts";
199
- export { defineProject, type ProjectInput } from "./project.ts";
187
+ export type { ProjectInput } from "./project.ts";
200
188
  export {
201
189
  buildCloudflareRedirects,
202
190
  buildRedirects,
@@ -211,15 +199,14 @@ export {
211
199
  type ValidateRedirectTargets,
212
200
  } from "./redirects.ts";
213
201
  export type { RobotsConfig, RobotsGroup } from "./robots.ts";
214
- export {
215
- type ChangeFreq,
216
- defineRouteOverrides,
217
- defineRoutes,
218
- type EnabledRouteIdFor,
219
- type RouteData,
220
- type RouteOverride,
221
- type RouteRegistry,
222
- type RouteSitemap,
202
+ export type {
203
+ ChangeFreq,
204
+ EnabledRouteIdFor,
205
+ RouteData,
206
+ RouteOverride,
207
+ RouteOverrides,
208
+ RouteRegistry,
209
+ RouteSitemap,
223
210
  } from "./routes/define.ts";
224
211
  export {
225
212
  enableRoutes,
@@ -228,24 +215,21 @@ export {
228
215
  type FamilyOptions,
229
216
  familyGuard,
230
217
  type RouteIdsOf,
231
- routeFamily,
232
218
  } from "./routes/family.ts";
233
219
  export type { RouteEntry } from "./routes/resolve.ts";
234
- export {
235
- type Alternate,
236
- type Crumb,
237
- createSite,
238
- type LinkOptions,
239
- type LlmsOptions,
240
- type LocaleLink,
241
- type PageContent,
242
- type PageMeta,
243
- type PageProps,
244
- type PerRoute,
245
- type RouteIdOf,
246
- type Site,
247
- type StaticPath,
248
- type WhenEnabled,
220
+ export { defineSite } from "./site/define.ts";
221
+ export type {
222
+ Alternate,
223
+ Crumb,
224
+ LinkOptions,
225
+ LlmsOptions,
226
+ LocaleLink,
227
+ PageContent,
228
+ PageMeta,
229
+ PageProps,
230
+ PerRoute,
231
+ Site,
232
+ StaticPath,
249
233
  } from "./site/index.ts";
250
234
  export type { Sitemap, SitemapConfig } from "./sitemap.ts";
251
235
  export type { IsoDate, Letter, StringKeys } from "./types.ts";
package/src/jsonld/faq.ts CHANGED
@@ -11,7 +11,8 @@ export interface FaqEntry {
11
11
  * `<ul>`, `<li>`, `<a>`, `<b>`, `<strong>`, `<i>`, `<em>` — and drops
12
12
  * everything else. Plain text is what a caller passing a translated string
13
13
  * has anyway, and it cannot be silently half-rendered, so nothing here
14
- * builds markup for you. Pass the markup yourself if the answer needs it.
14
+ * builds markup for you. An answer with links in it wants `html(rich(…))`,
15
+ * which emits only tags from that list and escapes the copy.
15
16
  *
16
17
  * Checked August 2026.
17
18
  */
@@ -5,6 +5,8 @@
5
5
  * kind of node and imports these to do it.
6
6
  */
7
7
 
8
+ import { literal } from "../analytics/tags.ts";
9
+
8
10
  /** A node in the graph. Deliberately loose: `@type` is what a consumer varies. */
9
11
  export interface JsonLdNode {
10
12
  readonly "@type": string | readonly string[];
@@ -60,19 +62,7 @@ export function alternateName(
60
62
  : { alternateName: alternate };
61
63
  }
62
64
 
63
- /**
64
- * The graph, serialised for a `<script type="application/ld+json">`.
65
- *
66
- * Every `<` is replaced by its unicode escape, and that is the whole reason
67
- * this is a function rather than a `JSON.stringify` at the call site. A script element ends at the first
68
- * `</script` in its text, so a business name, a room description or an alt text
69
- * containing one would close the block early and spill the rest of the graph
70
- * into the page as markup. The escape is invisible to a JSON parser and to
71
- * anything reading the data.
72
- */
65
+ /** The graph, serialised for a `<script type="application/ld+json">`. */
73
66
  export function serializeJsonLd(nodes: readonly JsonLdNode[]): string {
74
- return JSON.stringify({
75
- "@context": "https://schema.org",
76
- "@graph": nodes,
77
- }).replaceAll("<", "\\u003c");
67
+ return literal({ "@context": "https://schema.org", "@graph": nodes });
78
68
  }
package/src/meta/index.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  notFoundAnalytics,
30
30
  } from "../analytics/index.ts";
31
31
  import type { ResolvedLocaleMeta } from "../config.ts";
32
+ import type { ImageAsset } from "../image.ts";
32
33
  import {
33
34
  article,
34
35
  type BreadcrumbStep,
@@ -42,11 +43,10 @@ import { robotsContent, warnAboutRobots } from "./robots.ts";
42
43
  import {
43
44
  iconLinks,
44
45
  type OgImage,
45
- type SiteIcon,
46
46
  warnAboutShareImage,
47
47
  warnIfClipped,
48
48
  } from "./share-image.ts";
49
- import { asMetaTag, link, type MetaTag, meta, preamble } from "./tag.ts";
49
+ import { link, type MetaTag, meta, preamble } from "./tag.ts";
50
50
  import { type SiteVerification, verificationTags } from "./verification.ts";
51
51
 
52
52
  export type {
@@ -57,13 +57,7 @@ export type {
57
57
  ThemeColor,
58
58
  } from "./content.ts";
59
59
  export type { RobotsDirective, RobotsPolicy } from "./robots.ts";
60
- export {
61
- type ImageFormat,
62
- type OgImage,
63
- resolveShareImage,
64
- type ShareImage,
65
- type SiteIcon,
66
- } from "./share-image.ts";
60
+ export { resolveShareImage, type ShareImage } from "./share-image.ts";
67
61
  export type { MetaTag } from "./tag.ts";
68
62
  export type { SiteVerification } from "./verification.ts";
69
63
 
@@ -155,7 +149,7 @@ interface MetaInputDerived<L extends string> extends ChromeInput {
155
149
  */
156
150
  export interface ChromeInput {
157
151
  /** The site's square icon, used for every icon link. */
158
- readonly icon: SiteIcon;
152
+ readonly icon: ImageAsset;
159
153
  /**
160
154
  * Tints the browser UI — Android Chrome's bar, iOS Safari, an installed PWA.
161
155
  * Two values emit one tag per `prefers-color-scheme`.
@@ -250,8 +244,8 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
250
244
  // that must precede every tag reading them, and a tag that got there first
251
245
  // would silently apply the permissive implicit default instead.
252
246
  const analytics = analyticsScripts(input.analytics, input.canonical);
253
- for (const script of analytics.head) tags.push(asMetaTag(script));
254
- const bodyTags: MetaTag[] = analytics.body.map(asMetaTag);
247
+ tags.push(...analytics.head);
248
+ const bodyTags: MetaTag[] = [...analytics.body];
255
249
 
256
250
  if (input.llmsUrl !== undefined) {
257
251
  // Points a client at the file describing this page, per llmstxt.org.
@@ -473,7 +467,7 @@ export function buildNotFoundMeta(input: NotFoundInput): MetaTag[] {
473
467
  name: "robots",
474
468
  content: robotsContent({ index: false, follow: true }),
475
469
  }),
476
- ...notFoundAnalytics(input.analytics).map(asMetaTag),
470
+ ...notFoundAnalytics(input.analytics),
477
471
  ...chromeTags(input),
478
472
  ];
479
473
  }