@escape-game-over/atlas 0.1.22 → 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 +2 -1
- package/docs/client-scripts.md +20 -9
- package/docs/rich-text.md +12 -0
- package/package.json +3 -3
- package/src/analytics/tags.ts +1 -2
- package/src/astro/element.ts +87 -16
- package/src/astro/filters.ts +4 -2
- package/src/astro/images.ts +27 -26
- package/src/astro/markup.ts +23 -5
- package/src/astro/site-routes.ts +1 -0
- package/src/content/index.ts +1 -0
- package/src/content/rich.ts +45 -0
- package/src/index.ts +1 -0
- package/src/jsonld/faq.ts +2 -1
- package/src/jsonld/node.ts +4 -14
- package/src/meta/share-image.ts +12 -1
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
|
|
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/client-scripts.md
CHANGED
|
@@ -81,12 +81,15 @@ what it registered when the element leaves, and what it returns is the undo for
|
|
|
81
81
|
everything else.
|
|
82
82
|
|
|
83
83
|
```ts
|
|
84
|
-
defineElement(
|
|
84
|
+
defineElement(thing, (host, signal) => {
|
|
85
85
|
host.addEventListener("click", open, { signal }); // ends with the visit
|
|
86
86
|
return loop.attach(host); // and so does this
|
|
87
87
|
});
|
|
88
88
|
```
|
|
89
89
|
|
|
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
|
+
|
|
90
93
|
An element can enter the page more than once — moving it in the DOM runs the
|
|
91
94
|
undo and then the function again — so it has to be able to run twice. State that
|
|
92
95
|
must survive a move goes in a `WeakMap` keyed by `host`, which is what the
|
|
@@ -472,15 +475,15 @@ export const faq = component("go-faq", {
|
|
|
472
475
|
```
|
|
473
476
|
|
|
474
477
|
```astro
|
|
475
|
-
<faq.tag>
|
|
478
|
+
<faq.tag {...faq.root}>
|
|
476
479
|
<input {...faq.search.attrs()} type="search">
|
|
477
480
|
<details {...faq.row.attrs({ key, text })}>…</details>
|
|
478
481
|
</faq.tag>
|
|
479
482
|
```
|
|
480
483
|
|
|
481
484
|
```ts
|
|
482
|
-
defineElement(faq
|
|
483
|
-
for (const { element, values } of
|
|
485
|
+
defineElement(faq, (host, signal, { row }) => {
|
|
486
|
+
for (const { element, values } of row.all()) { … }
|
|
484
487
|
});
|
|
485
488
|
```
|
|
486
489
|
|
|
@@ -494,8 +497,8 @@ defineElement(faq.tag, (host, signal) => {
|
|
|
494
497
|
keeps its elements, and a lookup from an element inside the instance, like a
|
|
495
498
|
dropdown's own panel, still counts as that instance. Elements are filtered
|
|
496
499
|
before they are read, so a broken nested copy cannot fail the outer lookup.
|
|
497
|
-
- **The tag is written once.** `faq.tag` is what the template renders and
|
|
498
|
-
`defineElement` registers the behaviour under. A contract stays inert either
|
|
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
|
|
499
502
|
way: it names things, and nothing in it touches a document, which is why
|
|
500
503
|
frontmatter can import it.
|
|
501
504
|
- **The names are checked in the editor.** A tag without a hyphen, or a role
|
|
@@ -507,9 +510,17 @@ defineElement(faq.tag, (host, signal) => {
|
|
|
507
510
|
- **Most roles carry nothing**, and say so: `search: marker` rather than
|
|
508
511
|
`search: {}`, which reads like options somebody forgot to fill in.
|
|
509
512
|
|
|
510
|
-
|
|
511
|
-
|
|
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.
|
|
517
|
+
- **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 rather than a class
|
|
519
|
+
per component. Forget it and the element says so on the dev server.
|
|
520
|
+
|
|
521
|
+
`tag` and `root` are the component's own keys, so no role may take them. A role
|
|
522
|
+
that lives outside every instance, such as a footer button that reopens a
|
|
523
|
+
banner, is still plain `markup`.
|
|
513
524
|
|
|
514
525
|
## `youtube`
|
|
515
526
|
|
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.
|
|
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,
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@biomejs/biome": "2.5.13",
|
|
61
61
|
"@types/node": "26.5.1",
|
|
62
|
-
"@vitest/coverage-istanbul": "5.0.
|
|
62
|
+
"@vitest/coverage-istanbul": "5.0.1",
|
|
63
63
|
"astro": "7.3.2",
|
|
64
64
|
"typescript": "6.0.3",
|
|
65
|
-
"vitest": "5.0.
|
|
65
|
+
"vitest": "5.0.1"
|
|
66
66
|
}
|
|
67
67
|
}
|
package/src/analytics/tags.ts
CHANGED
|
@@ -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
|
|
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");
|
package/src/astro/element.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { reportDevError } from "./dev-log.ts";
|
|
2
|
+
import type { Marked, Markup } from "./markup.ts";
|
|
3
|
+
import { ROOT_ATTRIBUTE } from "./markup.ts";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* What is left to undo when the element leaves, or nothing.
|
|
@@ -10,43 +12,87 @@ import { reportDevError } from "./dev-log.ts";
|
|
|
10
12
|
// biome-ignore lint/suspicious/noConfusingVoidType: that is the distinction.
|
|
11
13
|
type Undo = void | (() => void);
|
|
12
14
|
|
|
15
|
+
/** One role's lookups, already pointed at the element they belong to. */
|
|
16
|
+
type Bound<M> =
|
|
17
|
+
M extends Markup<infer F>
|
|
18
|
+
? {
|
|
19
|
+
all<T extends Element = HTMLElement>(): Marked<T, F>[];
|
|
20
|
+
one<T extends Element = HTMLElement>(): Marked<T, F> | null;
|
|
21
|
+
require<T extends Element = HTMLElement>(): Marked<T, F>;
|
|
22
|
+
}
|
|
23
|
+
: never;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every role of a component, bound to one instance.
|
|
27
|
+
*
|
|
28
|
+
* The root is not a parameter here, which is the point: `faq.row.all(document)`
|
|
29
|
+
* compiles and quietly returns the roles that belong to *no* instance, and this
|
|
30
|
+
* removes the chance to write it.
|
|
31
|
+
*/
|
|
32
|
+
export type Roles<C> = {
|
|
33
|
+
readonly [K in Exclude<keyof C, "tag" | "root">]: Bound<C[K]>;
|
|
34
|
+
};
|
|
35
|
+
|
|
13
36
|
/**
|
|
14
37
|
* What an element does while it is on the page.
|
|
15
38
|
*
|
|
16
|
-
* `host` is the element itself
|
|
17
|
-
* anything registered with `signal` comes down on its own
|
|
18
|
-
* be undone is the returned function,
|
|
39
|
+
* `host` is the element itself, `signal` is aborted when it leaves — so
|
|
40
|
+
* anything registered with `signal` comes down on its own — and `roles` are
|
|
41
|
+
* this instance's. Whatever else has to be undone is the returned function,
|
|
42
|
+
* which runs at the same moment.
|
|
19
43
|
*/
|
|
20
|
-
export type Connect = (
|
|
44
|
+
export type Connect<C> = (
|
|
45
|
+
host: HTMLElement,
|
|
46
|
+
signal: AbortSignal,
|
|
47
|
+
roles: Roles<C>
|
|
48
|
+
) => Undo;
|
|
49
|
+
|
|
50
|
+
/** The shape this file needs off a role, without importing its field types. */
|
|
51
|
+
interface AnyRole {
|
|
52
|
+
all(root: ParentNode): unknown;
|
|
53
|
+
one(root: ParentNode): unknown;
|
|
54
|
+
require(root: ParentNode): unknown;
|
|
55
|
+
}
|
|
21
56
|
|
|
22
57
|
/**
|
|
23
|
-
* Registers a custom element
|
|
58
|
+
* Registers a component's custom element, with its behaviour as one function.
|
|
24
59
|
*
|
|
25
60
|
* ```ts
|
|
26
|
-
*
|
|
27
|
-
*
|
|
61
|
+
* export const faq = component("go-faq", { row: { key: { kind: "text" } } });
|
|
62
|
+
*
|
|
63
|
+
* defineElement(faq, (host, signal, { row }) => {
|
|
64
|
+
* for (const { element, values } of row.all()) { … }
|
|
28
65
|
* return loop.attach(host); // runs when the element leaves the page
|
|
29
66
|
* });
|
|
30
67
|
* ```
|
|
31
68
|
*
|
|
32
69
|
* - **An element can enter the page more than once.** Moving it runs the undo
|
|
33
|
-
* and then `connect` again, so `connect` has to be able to run twice.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
70
|
+
* and then `connect` again, so `connect` has to be able to run twice. Astro's
|
|
71
|
+
* `ClientRouter` connects a persisted element three times per navigation.
|
|
72
|
+
* State that must survive belongs in a `WeakMap` keyed by `host`.
|
|
36
73
|
* - **The defining script must stay a deferred module.** Astro emits
|
|
37
74
|
* `<script src>` as `type="module"`, so the element has its children by the
|
|
38
75
|
* time it is upgraded. `is:inline` runs it too early and every lookup inside
|
|
39
76
|
* the element finds nothing.
|
|
40
77
|
* - **A `connect` that throws leaves that one element inert** and reports it,
|
|
41
|
-
* rather than taking its siblings down with it
|
|
42
|
-
* like `require` throw and say what is missing.
|
|
78
|
+
* rather than taking its siblings down with it.
|
|
43
79
|
*
|
|
44
80
|
* See docs/client-scripts.md.
|
|
45
81
|
*/
|
|
46
|
-
export function defineElement
|
|
82
|
+
export function defineElement<C extends { readonly tag: string }>(
|
|
83
|
+
component: C,
|
|
84
|
+
connect: Connect<C>
|
|
85
|
+
): void {
|
|
86
|
+
const { tag } = component;
|
|
87
|
+
|
|
88
|
+
// Read once: the roles are fixed when the component is built, and only the
|
|
89
|
+
// element they point at changes.
|
|
90
|
+
const roleNames = Object.keys(component).filter(
|
|
91
|
+
(key) => key !== "tag" && key !== "root"
|
|
92
|
+
);
|
|
93
|
+
|
|
47
94
|
// The class is built in here rather than at module scope so `HTMLElement`
|
|
48
|
-
// is only read in a browser
|
|
49
|
-
// import it, and the build runs in Node.
|
|
95
|
+
// is only read in a browser.
|
|
50
96
|
customElements.define(
|
|
51
97
|
tag,
|
|
52
98
|
class extends HTMLElement {
|
|
@@ -68,7 +114,16 @@ export function defineElement(tag: string, connect: Connect): void {
|
|
|
68
114
|
const ac = new AbortController();
|
|
69
115
|
this.#ac = ac;
|
|
70
116
|
try {
|
|
71
|
-
|
|
117
|
+
if (
|
|
118
|
+
import.meta.env.DEV &&
|
|
119
|
+
!this.hasAttribute(ROOT_ATTRIBUTE)
|
|
120
|
+
) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`<${tag}> carries no ${ROOT_ATTRIBUTE} — does the template spread {...x.root} on it?`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
this.#undo =
|
|
126
|
+
connect(this, ac.signal, this.#roles()) ?? undefined;
|
|
72
127
|
} catch (error) {
|
|
73
128
|
this.#report(error);
|
|
74
129
|
}
|
|
@@ -83,6 +138,22 @@ export function defineElement(tag: string, connect: Connect): void {
|
|
|
83
138
|
this.#end();
|
|
84
139
|
}
|
|
85
140
|
|
|
141
|
+
/** This instance's roles: the component's, with the root supplied. */
|
|
142
|
+
#roles(): Roles<C> {
|
|
143
|
+
const bound: Record<string, unknown> = {};
|
|
144
|
+
for (const name of roleNames) {
|
|
145
|
+
const role = (component as Record<string, unknown>)[
|
|
146
|
+
name
|
|
147
|
+
] as AnyRole;
|
|
148
|
+
bound[name] = {
|
|
149
|
+
all: () => role.all(this),
|
|
150
|
+
one: () => role.one(this),
|
|
151
|
+
require: () => role.require(this),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return bound as Roles<C>;
|
|
155
|
+
}
|
|
156
|
+
|
|
86
157
|
#end(): void {
|
|
87
158
|
// Aborted before the undo runs, so an async continuation that
|
|
88
159
|
// checks the signal can see the visit is over.
|
package/src/astro/filters.ts
CHANGED
|
@@ -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
|
-
|
|
350
|
-
|
|
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. */
|
package/src/astro/images.ts
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
311
|
-
height: ratio.height,
|
|
312
|
+
...producedSize(source, ratio.width, ratio.height, "cover"),
|
|
312
313
|
format,
|
|
313
314
|
};
|
|
314
315
|
})
|
package/src/astro/markup.ts
CHANGED
|
@@ -514,13 +514,28 @@ export function markup<
|
|
|
514
514
|
/** A component's roles, by name: each is the fields of one `markup` role. */
|
|
515
515
|
export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
|
|
516
516
|
|
|
517
|
-
/** The
|
|
518
|
-
type Reserved = "tag";
|
|
517
|
+
/** The keys the component object uses itself, so no role may take them. */
|
|
518
|
+
type Reserved = "tag" | "root";
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* What marks a component's root element.
|
|
522
|
+
*
|
|
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
|
|
525
|
+
* template forgot to spread it.
|
|
526
|
+
*/
|
|
527
|
+
export const ROOT_ATTRIBUTE = "data-atlas-root";
|
|
519
528
|
|
|
520
529
|
export type Component<Tag extends string, R extends ComponentRoles> = {
|
|
521
530
|
/** The custom element's name: what the template renders, and what
|
|
522
531
|
* `defineElement` registers the behaviour under. */
|
|
523
532
|
readonly tag: Tag;
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* What the root element carries, spread by the template that renders it:
|
|
536
|
+
* `<faq.tag {...faq.root}>`.
|
|
537
|
+
*/
|
|
538
|
+
readonly root: Readonly<Record<string, string>>;
|
|
524
539
|
} & { readonly [K in keyof R]: Markup<R[K]> };
|
|
525
540
|
|
|
526
541
|
/**
|
|
@@ -533,8 +548,8 @@ export type Component<Tag extends string, R extends ComponentRoles> = {
|
|
|
533
548
|
* search: {},
|
|
534
549
|
* });
|
|
535
550
|
*
|
|
536
|
-
* // template: <faq.tag> … <details {...faq.row.attrs({ key, text })}>
|
|
537
|
-
* // script: defineElement(faq
|
|
551
|
+
* // template: <faq.tag {...faq.root}> … <details {...faq.row.attrs({ key, text })}>
|
|
552
|
+
* // script: defineElement(faq, (host, signal, { row }) => row.all().forEach(…));
|
|
538
553
|
* ```
|
|
539
554
|
*
|
|
540
555
|
* Two things `markup` alone leaves to the project:
|
|
@@ -630,7 +645,10 @@ export function component<
|
|
|
630
645
|
};
|
|
631
646
|
};
|
|
632
647
|
|
|
633
|
-
const built: Record<string, unknown> = {
|
|
648
|
+
const built: Record<string, unknown> = {
|
|
649
|
+
tag,
|
|
650
|
+
root: { [ROOT_ATTRIBUTE]: "" },
|
|
651
|
+
};
|
|
634
652
|
for (const [name, fields] of Object.entries(roles)) {
|
|
635
653
|
built[name] = scoped(name, fields);
|
|
636
654
|
}
|
package/src/astro/site-routes.ts
CHANGED
|
@@ -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",
|
package/src/content/index.ts
CHANGED
package/src/content/rich.ts
CHANGED
|
@@ -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
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.
|
|
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
|
*/
|
package/src/jsonld/node.ts
CHANGED
|
@@ -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
|
|
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/share-image.ts
CHANGED
|
@@ -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 =
|
|
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
|
/**
|