@escape-game-over/atlas 0.1.23 → 0.1.24

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 CHANGED
@@ -166,7 +166,8 @@ route id or a URL. A slot is filled at the call site, where it is checked agains
166
166
  the routes this deployment builds and written once instead of once per language.
167
167
 
168
168
  `site.plain(locale)` reads the same message as words alone, for a
169
- `<meta description>` or `llms.txt`; `t()` refuses a message with marks rather
169
+ `<meta description>` or `llms.txt`, and `html(rich(…))` as inline markup for a
170
+ structured-data answer; `t()` refuses a message with marks rather
170
171
  than printing the brackets. See [`docs/rich-text.md`](docs/rich-text.md).
171
172
 
172
173
  ## What the build emits, and who asks for it
package/docs/rich-text.md CHANGED
@@ -222,6 +222,18 @@ button label, an `aria-label`. Reach for `plain()` when the answer is prose: it
222
222
  keeps working on the day someone adds emphasis to the sentence, where `t()` would
223
223
  start throwing.
224
224
 
225
+ ## The same sentence as inline HTML
226
+
227
+ `html(rich(…))` is for a structured-data field that accepts a little markup — a
228
+ `FAQPage` answer's `text`. It emits `<a>`, `<strong>` and `<br>`, links by the
229
+ absolute `url` because nothing reading it has a page to resolve a path against,
230
+ and escapes all the copy. A `[v:]` role and an in-page anchor come out as their
231
+ words.
232
+
233
+ ```ts
234
+ faqPage([{ question, answer: html(rich("faq.city.a", { network: "network" })) }], at)
235
+ ```
236
+
225
237
  ## The renderer half
226
238
 
227
239
  lib decides which runs exist and what they say; the project decides what they
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -77,8 +77,7 @@ export interface AnalyticsTags {
77
77
  * `JSON.stringify` handles quotes and backslashes. The `<` escape handles the
78
78
  * one thing it cannot: a `</script` anywhere in the text ends the element,
79
79
  * whatever JavaScript makes of it. `<` is a valid escape inside a JS
80
- * string and invisible to anything reading the value — the same bargain
81
- * `serializeJsonLd` strikes, for the same reason.
80
+ * string and invisible to anything reading the value, JSON parsers included.
82
81
  */
83
82
  export const literal = (value: unknown): string =>
84
83
  JSON.stringify(value).replaceAll("<", "\\u003c");
@@ -346,8 +346,10 @@ export function filters<const F extends FieldMap>(
346
346
  const query = search.toString();
347
347
  // The bare path when nothing is left, rather than a trailing `?`.
348
348
  const url = query === "" ? window.location.pathname : `?${query}`;
349
- if (history === "push") window.history.pushState({}, "", url);
350
- else window.history.replaceState({}, "", url);
349
+ // `null` on a pushed step, so Astro's `ClientRouter` leaves its popstate
350
+ // to this module; a replaced entry keeps whatever state it already had.
351
+ if (history === "push") window.history.pushState(null, "", url);
352
+ else window.history.replaceState(window.history.state, "", url);
351
353
  }
352
354
 
353
355
  /** One field's value as the matcher and the URL will read it. */
@@ -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
  })
@@ -280,6 +280,7 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
280
280
  // optional, so `buld: {…}` would be assignable and silently do
281
281
  // nothing. The annotation is what makes a typo an error.
282
282
  const config: ConfigUpdate = {
283
+ site: site.url,
283
284
  output: "static",
284
285
  build: { format: "file" },
285
286
  trailingSlash: "ignore",
@@ -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,
@@ -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
  /**
@@ -493,6 +494,50 @@ export function plain(rich: RichText): string {
493
494
  );
494
495
  }
495
496
 
497
+ /**
498
+ * The runs as inline HTML, for a field that takes markup rather than a page.
499
+ *
500
+ * Written for a `FAQPage` answer, whose `text` accepts a handful of tags —
501
+ * `<a>`, `<strong>`, `<br>` among them. Only those are emitted: a `[v:]` role
502
+ * is presentation, which structured data has no use for, so it is its words.
503
+ *
504
+ * Links carry their absolute `url`, since nothing reading this has a page to
505
+ * resolve a path against. An anchor has none, so it is its words too.
506
+ *
507
+ * Every piece of copy is escaped: the field is read as HTML, so an `&` or a `<`
508
+ * in a translation would otherwise be read as markup.
509
+ */
510
+ export function html(rich: RichText): string {
511
+ return rich
512
+ .map((span) => {
513
+ switch (span.kind) {
514
+ case "text":
515
+ case "styled":
516
+ return escapeXml(span.text);
517
+ case "bold":
518
+ return `<strong>${escapeXml(span.text)}</strong>`;
519
+ case "link":
520
+ return span.to === "anchor"
521
+ ? escapeXml(span.text)
522
+ : anchor(span.url, span.text);
523
+ case "email":
524
+ case "phone":
525
+ return anchor(span.href, span.text);
526
+ case "break":
527
+ return "<br>";
528
+ default: {
529
+ const unreachable: never = span;
530
+ return unreachable;
531
+ }
532
+ }
533
+ })
534
+ .join("");
535
+ }
536
+
537
+ function anchor(href: string, text: string): string {
538
+ return `<a href="${escapeXml(href)}">${escapeXml(text)}</a>`;
539
+ }
540
+
496
541
  /**
497
542
  * Runs of whitespace become one, and the ends are trimmed.
498
543
  *
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export {
72
72
  // a slot filled with a route id resolves against nothing there. `site.rich(locale)` is
73
73
  // the only way in, and `Span` is what a renderer switches on.
74
74
  export {
75
+ html,
75
76
  type LinkTarget,
76
77
  type PlainTextFor,
77
78
  plain,
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
  }
@@ -3,12 +3,23 @@ import { absoluteUrl, type HttpsUrl } from "../url.ts";
3
3
  import { warn } from "../warn.ts";
4
4
  import { link, type MetaTag } from "./tag.ts";
5
5
 
6
- export type ImageFormat = "png" | "jpg" | "webp";
6
+ export type ImageFormat =
7
+ | "png"
8
+ | "jpg"
9
+ | "jpeg"
10
+ | "webp"
11
+ | "avif"
12
+ | "gif"
13
+ | "svg";
7
14
 
8
15
  const IMAGE_MIME: Readonly<Record<ImageFormat, string>> = {
9
16
  png: "image/png",
10
17
  jpg: "image/jpeg",
18
+ jpeg: "image/jpeg",
11
19
  webp: "image/webp",
20
+ avif: "image/avif",
21
+ gif: "image/gif",
22
+ svg: "image/svg+xml",
12
23
  };
13
24
 
14
25
  /**