@caelo-cms/shared 0.10.52 → 0.10.55

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 (91) hide show
  1. package/dist/auto-update.d.ts +5 -2
  2. package/dist/auto-update.d.ts.map +1 -1
  3. package/dist/auto-update.js +13 -6
  4. package/dist/auto-update.js.map +1 -1
  5. package/dist/fonts.d.ts +12 -0
  6. package/dist/fonts.d.ts.map +1 -1
  7. package/dist/fonts.js +37 -1
  8. package/dist/fonts.js.map +1 -1
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/link-refs.d.ts +250 -0
  14. package/dist/link-refs.d.ts.map +1 -0
  15. package/dist/link-refs.js +416 -0
  16. package/dist/link-refs.js.map +1 -0
  17. package/dist/markup-structure.d.ts +79 -0
  18. package/dist/markup-structure.d.ts.map +1 -0
  19. package/dist/markup-structure.js +274 -0
  20. package/dist/markup-structure.js.map +1 -0
  21. package/dist/media.d.ts +25 -1
  22. package/dist/media.d.ts.map +1 -1
  23. package/dist/media.js +35 -0
  24. package/dist/media.js.map +1 -1
  25. package/dist/nested-module-render.d.ts +4 -1
  26. package/dist/nested-module-render.d.ts.map +1 -1
  27. package/dist/nested-module-render.js +14 -2
  28. package/dist/nested-module-render.js.map +1 -1
  29. package/dist/platform-updates.d.ts +7 -3
  30. package/dist/platform-updates.d.ts.map +1 -1
  31. package/dist/platform-updates.js +13 -2
  32. package/dist/platform-updates.js.map +1 -1
  33. package/dist/preview-compose.d.ts +14 -3
  34. package/dist/preview-compose.d.ts.map +1 -1
  35. package/dist/preview-compose.js +60 -21
  36. package/dist/preview-compose.js.map +1 -1
  37. package/dist/publish-live-parity.d.ts +18 -0
  38. package/dist/publish-live-parity.d.ts.map +1 -0
  39. package/dist/publish-live-parity.js +21 -0
  40. package/dist/publish-live-parity.js.map +1 -0
  41. package/dist/raw-text-escape.d.ts +18 -0
  42. package/dist/raw-text-escape.d.ts.map +1 -0
  43. package/dist/raw-text-escape.js +40 -0
  44. package/dist/raw-text-escape.js.map +1 -0
  45. package/dist/responsive-images.d.ts.map +1 -1
  46. package/dist/responsive-images.js +3 -3
  47. package/dist/responsive-images.js.map +1 -1
  48. package/dist/seo.js +1 -1
  49. package/dist/seo.js.map +1 -1
  50. package/dist/template-balance.d.ts +3 -0
  51. package/dist/template-balance.d.ts.map +1 -0
  52. package/dist/template-balance.js +214 -0
  53. package/dist/template-balance.js.map +1 -0
  54. package/dist/template-engine.d.ts +11 -0
  55. package/dist/template-engine.d.ts.map +1 -1
  56. package/dist/template-engine.js +12 -0
  57. package/dist/template-engine.js.map +1 -1
  58. package/dist/theme-render.d.ts +2 -2
  59. package/dist/theme-render.d.ts.map +1 -1
  60. package/dist/theme-render.js +29 -5
  61. package/dist/theme-render.js.map +1 -1
  62. package/dist/version.d.ts +2 -2
  63. package/dist/version.js +1 -1
  64. package/package.json +2 -2
  65. package/src/auto-update.test.ts +32 -1
  66. package/src/auto-update.ts +13 -6
  67. package/src/fonts.test.ts +37 -0
  68. package/src/fonts.ts +38 -1
  69. package/src/index.ts +4 -0
  70. package/src/link-refs.test.ts +552 -0
  71. package/src/link-refs.ts +569 -0
  72. package/src/markup-structure.test.ts +184 -0
  73. package/src/markup-structure.ts +304 -0
  74. package/src/media.ts +43 -0
  75. package/src/nested-module-render.ts +18 -1
  76. package/src/platform-updates.test.ts +5 -0
  77. package/src/platform-updates.ts +13 -2
  78. package/src/preview-compose-deferrals.test.ts +103 -0
  79. package/src/preview-compose.ts +79 -28
  80. package/src/publish-live-parity.ts +25 -0
  81. package/src/raw-text-escape.ts +44 -0
  82. package/src/responsive-images.test.ts +10 -0
  83. package/src/responsive-images.ts +6 -3
  84. package/src/seo.test.ts +7 -0
  85. package/src/seo.ts +1 -1
  86. package/src/template-balance.test.ts +97 -0
  87. package/src/template-balance.ts +216 -0
  88. package/src/template-engine.ts +23 -0
  89. package/src/theme-render-sanitize.test.ts +40 -0
  90. package/src/theme-render.ts +40 -8
  91. package/src/version.ts +1 -1
@@ -0,0 +1,569 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Internal links as REFERENCES, not literal paths (epic #724).
5
+ *
6
+ * A link to one of the site's own pages or media assets is stored as a
7
+ * plain-string reference and turned into a URL only when a page is
8
+ * rendered — for the page being rendered, in the environment it is
9
+ * rendered for. Renaming or moving a page then changes no stored link.
10
+ *
11
+ * Grammar (one form for every place a link can live — `link` / `url`
12
+ * fields, `link-list` items, `*_href` values, module HTML attributes,
13
+ * rich text, structured-set items, field defaults):
14
+ *
15
+ * caelo:page/<pageId>[?query][#anchor]
16
+ * caelo:media/<mediaId>[/<variant>]
17
+ *
18
+ * `<pageId>` / `<mediaId>` are lowercase UUIDs. `query` and `anchor` are
19
+ * carried verbatim (`?ref=nav`, `#pricing`). `variant` is a media
20
+ * variant tag (`webp-800`, `square-400`); absent means the original.
21
+ * Because a reference is a plain string, every surface carries it
22
+ * without a schema change, and a reference inside HTML (`href="…"`,
23
+ * `src="…"`, a `srcset` entry) is found by the same scanner as a whole
24
+ * field value.
25
+ *
26
+ * Who sees the grammar: nobody who authors content. Humans and the AI
27
+ * keep writing `/about`; the write path canonicalises it to a reference
28
+ * (rollout C — until then nothing writes references), and every read
29
+ * that shows content back (tool results, editor fields) turns references
30
+ * into human paths again ({@link humaniseLinkRefs}). The grammar is a
31
+ * storage form, not an authoring form (CLAUDE.md §1A).
32
+ *
33
+ * Resolution is ONE interface ({@link LinkResolver}) used by the
34
+ * template engine, the composer, the editor preview, live-edit and the
35
+ * static generator. A reference whose target does not exist is a loud
36
+ * failure (`link-target-missing:<kind>/<id>` in `missingSlots`, the raw
37
+ * reference left in the output — CLAUDE.md §2); one whose target exists
38
+ * but is not published in the environment being rendered resolves to
39
+ * its URL and is reported as `unpublished` so the caller can apply the
40
+ * environment's policy (warn at Stage, block at Publish live — the
41
+ * blocking check is rollout F).
42
+ *
43
+ * Seams for the later rollout steps:
44
+ * - D (locale composition point): {@link SiteLinkIndex.forPage} is the
45
+ * per-rendering-page hook. A `link-resolve` contribution maps the
46
+ * target to the rendering page's same-locale sibling there; nothing
47
+ * in the engine or composer changes.
48
+ * - E (data migration): {@link mapLinkRefsDeep} / the canonicaliser's
49
+ * pure core walk the same shapes the migration rewrites.
50
+ * - F (reference integrity): {@link RecordingLinkResolver.observed}
51
+ * lists every reference a render touched with its resolution, and
52
+ * {@link linkIssueSeverity} is the per-environment policy.
53
+ */
54
+
55
+ import { buildMediaUrl } from "./media.js";
56
+ import type { PageUrlStyle } from "./seo.js";
57
+ import { trimSlashes } from "./url.js";
58
+
59
+ /** The scheme every internal reference starts with. */
60
+ export const LINK_REF_SCHEME = "caelo:";
61
+
62
+ /** A reference to one of the site's pages. */
63
+ export interface PageLinkRef {
64
+ readonly kind: "page";
65
+ readonly pageId: string;
66
+ /** Query string WITHOUT the leading `?`, or null. */
67
+ readonly query: string | null;
68
+ /** Fragment WITHOUT the leading `#`, or null. */
69
+ readonly anchor: string | null;
70
+ }
71
+
72
+ /** A reference to one of the site's media assets. */
73
+ export interface MediaLinkRef {
74
+ readonly kind: "media";
75
+ readonly mediaId: string;
76
+ /** Variant tag (`webp-800`), or null for the original. */
77
+ readonly variant: string | null;
78
+ }
79
+
80
+ /** Any internal reference. */
81
+ export type LinkRef = PageLinkRef | MediaLinkRef;
82
+
83
+ /**
84
+ * `missingSlots` marker for a link value that starts like a reference
85
+ * (`caelo:page/`, `caelo:media/`) but does not match the grammar — a
86
+ * truncated or uppercased id, an id followed by stray characters
87
+ * (`caelo:page/<id>/team`), an unknown media variant form. Reported as
88
+ * `link-ref-malformed:<the text as written>` and left raw: a guessed
89
+ * target would be a silent fallback (CLAUDE.md §2), and ignoring it
90
+ * would ship a dead `caelo:` href. Only link positions count (a whole
91
+ * value, an attribute value, a srcset entry); prose that mentions the
92
+ * grammar is left alone.
93
+ */
94
+ export const LINK_REF_MALFORMED = "link-ref-malformed";
95
+
96
+ // Pieces of the grammar. Every quantifier is bounded or runs over a
97
+ // character class that cannot overlap its neighbour, and every
98
+ // alternative starts with a literal, so scanning is linear (CodeQL
99
+ // js/polynomial-redos; the budget tests in link-refs.test.ts pin it).
100
+ const UUID_SRC = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
101
+ // Query: URL query characters, `&amp;`-escaped ampersands included; no
102
+ // quote, space, angle bracket, `#` or comma (a comma ends a srcset entry).
103
+ const QUERY_SRC = "[A-Za-z0-9_\\-.~%=&;+/:@!$()*]{1,512}";
104
+ // Fragment: what an HTML id / URL fragment realistically carries.
105
+ const ANCHOR_SRC = "[A-Za-z0-9_\\-.~%:]{1,256}";
106
+ const VARIANT_SRC = "[a-z][a-z0-9-]{0,63}";
107
+ // `caelo:` is a scheme only where a scheme can start: `xcaelo:page/…`
108
+ // is some other scheme's text, not a reference.
109
+ const REF_START_SRC = "(?<![A-Za-z0-9+.\\-])caelo:";
110
+ // An id (or media variant) must END where the grammar says: a following
111
+ // id or path character (`<id>abc`, `<id>/team`, `/webp-800/x`) makes the
112
+ // whole thing malformed instead of resolving the prefix and gluing the
113
+ // rest onto the URL.
114
+ const ID_END_SRC = "(?![A-Za-z0-9_/\\-])";
115
+ // Fallback alternative: tried only where both grammar alternatives fail
116
+ // at the same position, and only in LINK positions — the start of the
117
+ // string (a whole field value), of an attribute value (after a quote or
118
+ // `=`) or of a srcset entry (after a comma). Prose that talks about the
119
+ // grammar (`<code>caelo:page/&lt;id&gt;</code>` in a docs page) is text,
120
+ // not a broken link. Captures a bounded snippet for the marker.
121
+ const MALFORMED_SRC = "(?<=^|[\"'=,]|, )caelo:(?:page|media)/[^\\s\"'<>,]{0,96}";
122
+
123
+ const PAGE_BODY_SRC = `page/(${UUID_SRC})${ID_END_SRC}(?:\\?(${QUERY_SRC}))?(?:#(${ANCHOR_SRC}))?`;
124
+ const MEDIA_BODY_SRC = `media/(${UUID_SRC})(?:/(${VARIANT_SRC}))?${ID_END_SRC}`;
125
+
126
+ /**
127
+ * Global scanner for references embedded anywhere in a string. Groups:
128
+ * 1-3 page (id, query, anchor), 4-5 media (id, variant); a match with
129
+ * neither is malformed.
130
+ */
131
+ function linkRefScanner(): RegExp {
132
+ // A fresh RegExp per call: global regexes carry `lastIndex` state.
133
+ // No alternative accepts a quote, so scanning an HTML attribute value
134
+ // stops at its boundary.
135
+ return new RegExp(`${REF_START_SRC}(?:${PAGE_BODY_SRC}|${MEDIA_BODY_SRC})|${MALFORMED_SRC}`, "g");
136
+ }
137
+
138
+ const PAGE_REF_EXACT = new RegExp(`^caelo:${PAGE_BODY_SRC}$`);
139
+ const MEDIA_REF_EXACT = new RegExp(`^caelo:${MEDIA_BODY_SRC}$`);
140
+
141
+ /** The reference a scanner match denotes, or null for a malformed one. */
142
+ function refFromMatch(m: ArrayLike<string | undefined>): LinkRef | null {
143
+ if (m[1] !== undefined) {
144
+ return { kind: "page", pageId: m[1], query: m[2] ?? null, anchor: m[3] ?? null };
145
+ }
146
+ if (m[4] !== undefined) return { kind: "media", mediaId: m[4], variant: m[5] ?? null };
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * Parse a string that IS exactly one reference (a whole field value).
152
+ * Returns null for anything else — a path, an external URL, or a string
153
+ * that merely contains a reference.
154
+ */
155
+ export function parseLinkRef(value: string): LinkRef | null {
156
+ if (!value.startsWith(LINK_REF_SCHEME)) return null;
157
+ const page = PAGE_REF_EXACT.exec(value);
158
+ if (page) {
159
+ return {
160
+ kind: "page",
161
+ pageId: page[1] as string,
162
+ query: page[2] ?? null,
163
+ anchor: page[3] ?? null,
164
+ };
165
+ }
166
+ const media = MEDIA_REF_EXACT.exec(value);
167
+ if (media) return { kind: "media", mediaId: media[1] as string, variant: media[2] ?? null };
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * Serialise a reference to its stored string form.
173
+ *
174
+ * @throws when the result would not parse back (an id that is not a
175
+ * lowercase UUID, a query / anchor / variant outside the grammar's
176
+ * character classes): storing it would write a link no render can
177
+ * resolve. Callers validate author input before building a ref.
178
+ */
179
+ export function formatLinkRef(ref: LinkRef): string {
180
+ const out =
181
+ ref.kind === "media"
182
+ ? `${LINK_REF_SCHEME}media/${ref.mediaId}${ref.variant ? `/${ref.variant}` : ""}`
183
+ : `${LINK_REF_SCHEME}page/${ref.pageId}` +
184
+ (ref.query ? `?${ref.query}` : "") +
185
+ (ref.anchor ? `#${ref.anchor}` : "");
186
+ if (parseLinkRef(out) === null) {
187
+ throw new Error(
188
+ `formatLinkRef: ${JSON.stringify(out)} is not a valid internal reference ` +
189
+ "(ids are lowercase UUIDs; query, anchor and variant must use the grammar's characters, see link-refs.ts)",
190
+ );
191
+ }
192
+ return out;
193
+ }
194
+
195
+ /**
196
+ * The target-identity half of a reference, `page/<id>` / `media/<id>` —
197
+ * what failure markers and integrity reports name (query, anchor and
198
+ * variant do not change whether the target exists).
199
+ */
200
+ export function linkRefTargetKey(ref: LinkRef): string {
201
+ return ref.kind === "page" ? `page/${ref.pageId}` : `media/${ref.mediaId}`;
202
+ }
203
+
204
+ /** Cheap pre-check: can `text` contain a reference at all? */
205
+ export function mayContainLinkRef(text: string): boolean {
206
+ return text.includes(LINK_REF_SCHEME);
207
+ }
208
+
209
+ /**
210
+ * Every well-formed reference embedded in `text`, in order (duplicates
211
+ * kept). Malformed ones are not references; see
212
+ * {@link findMalformedLinkRefs}.
213
+ */
214
+ export function findLinkRefs(text: string): LinkRef[] {
215
+ if (!mayContainLinkRef(text)) return [];
216
+ const out: LinkRef[] = [];
217
+ for (const m of text.matchAll(linkRefScanner())) {
218
+ const ref = refFromMatch(m);
219
+ if (ref) out.push(ref);
220
+ }
221
+ return out;
222
+ }
223
+
224
+ /**
225
+ * Every piece of `text` that starts like a reference (`caelo:page/`,
226
+ * `caelo:media/`) but breaks the grammar, exactly as written (bounded
227
+ * snippet), in order.
228
+ */
229
+ export function findMalformedLinkRefs(text: string): string[] {
230
+ if (!mayContainLinkRef(text)) return [];
231
+ const out: string[] = [];
232
+ for (const m of text.matchAll(linkRefScanner())) {
233
+ if (refFromMatch(m) === null) out.push(m[0]);
234
+ }
235
+ return out;
236
+ }
237
+
238
+ /**
239
+ * Replace every well-formed reference embedded in `text` with
240
+ * `replacer`'s answer. `raw` is the reference exactly as it appeared, so
241
+ * a replacer can leave it in place (return `raw`) — the loud-raw
242
+ * convention for a target that cannot be resolved. Malformed references
243
+ * are left untouched.
244
+ */
245
+ export function replaceLinkRefs(
246
+ text: string,
247
+ replacer: (ref: LinkRef, raw: string) => string,
248
+ ): string {
249
+ if (!mayContainLinkRef(text)) return text;
250
+ return text.replace(linkRefScanner(), (...args: unknown[]) => {
251
+ const raw = args[0] as string;
252
+ const ref = refFromMatch(args.slice(0, 6) as (string | undefined)[]);
253
+ return ref ? replacer(ref, raw) : raw;
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Walk a JSON value (content values, field defaults, set items) and map
259
+ * every string through {@link replaceLinkRefs}. Returns the SAME
260
+ * reference when nothing changed, so callers can cheaply detect no-ops.
261
+ */
262
+ export function mapLinkRefsDeep(
263
+ value: unknown,
264
+ replacer: (ref: LinkRef, raw: string) => string,
265
+ ): unknown {
266
+ if (typeof value === "string") return replaceLinkRefs(value, replacer);
267
+ if (Array.isArray(value)) {
268
+ let changed = false;
269
+ const next = value.map((v) => {
270
+ const r = mapLinkRefsDeep(v, replacer);
271
+ if (r !== v) changed = true;
272
+ return r;
273
+ });
274
+ return changed ? next : value;
275
+ }
276
+ if (value !== null && typeof value === "object") {
277
+ let changed = false;
278
+ const out: Record<string, unknown> = {};
279
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
280
+ const r = mapLinkRefsDeep(v, replacer);
281
+ if (r !== v) changed = true;
282
+ out[k] = r;
283
+ }
284
+ return changed ? out : value;
285
+ }
286
+ return value;
287
+ }
288
+
289
+ /** Every reference anywhere inside a JSON value. */
290
+ export function collectLinkRefsDeep(value: unknown): LinkRef[] {
291
+ const out: LinkRef[] = [];
292
+ mapLinkRefsDeep(value, (ref, raw) => {
293
+ out.push(ref);
294
+ return raw;
295
+ });
296
+ return out;
297
+ }
298
+
299
+ // ─── Resolution ──────────────────────────────────────────────────────
300
+
301
+ /**
302
+ * Where a render goes. `preview` is the editor (draft state: every live
303
+ * page counts as available); `staging` / `production` are deploy builds,
304
+ * which ship published pages only.
305
+ */
306
+ export type LinkRenderEnv = "preview" | "staging" | "production";
307
+
308
+ /** The outcome of resolving one reference. */
309
+ export type LinkResolution =
310
+ /** The target exists and is served in this environment. */
311
+ | { readonly status: "resolved"; readonly url: string }
312
+ /**
313
+ * The target exists but is not published, so a deploy build will not
314
+ * contain it. Rendered with its URL; the caller applies the
315
+ * environment's policy ({@link linkIssueSeverity}).
316
+ */
317
+ | { readonly status: "unpublished"; readonly url: string }
318
+ /** No live target (never existed, deleted, another chat's branch). */
319
+ | { readonly status: "missing" };
320
+
321
+ /**
322
+ * Turns references into URLs for ONE rendering page. Synchronous: the
323
+ * template engine is a pure, sync function, so callers batch-load what
324
+ * the resolver needs ({@link createSiteLinkIndex}) before rendering.
325
+ */
326
+ export interface LinkResolver {
327
+ resolve(ref: LinkRef): LinkResolution;
328
+ }
329
+
330
+ /** One reference a render touched and what it resolved to. */
331
+ export interface ObservedLinkResolution {
332
+ readonly ref: LinkRef;
333
+ readonly resolution: LinkResolution;
334
+ }
335
+
336
+ /** A resolver that remembers every resolution it handed out. */
337
+ export interface RecordingLinkResolver extends LinkResolver {
338
+ /** Every resolution so far, in call order (duplicates kept). */
339
+ readonly observed: readonly ObservedLinkResolution[];
340
+ }
341
+
342
+ /**
343
+ * `missingSlots` marker for a reference whose target does not exist:
344
+ * `link-target-missing:page/<id>` / `link-target-missing:media/<id>`.
345
+ * The editor's missing-content surface and the static generator's
346
+ * render gate read these literal strings.
347
+ */
348
+ export const LINK_TARGET_MISSING = "link-target-missing";
349
+
350
+ /**
351
+ * `missingSlots` marker for a reference rendered by a caller that gave
352
+ * the engine no resolver — never legitimate output; a deploy refuses it.
353
+ */
354
+ export const LINK_RESOLVER_UNAVAILABLE = "link-resolver-unavailable";
355
+
356
+ /**
357
+ * True for every link failure marker: a missing target, a malformed
358
+ * reference and a render without a resolver. The static generator's
359
+ * render gate and the nested-module echo filter both key on this, so a
360
+ * new marker added here blocks a deploy everywhere at once.
361
+ */
362
+ export function isLinkFailureMarker(marker: string): boolean {
363
+ return (
364
+ marker.startsWith(`${LINK_TARGET_MISSING}:`) ||
365
+ marker.startsWith(`${LINK_REF_MALFORMED}:`) ||
366
+ marker.startsWith(`${LINK_RESOLVER_UNAVAILABLE}:`)
367
+ );
368
+ }
369
+
370
+ /** The link failure markers among `markers`, in order. */
371
+ export function linkMarkersOf(markers: readonly string[]): string[] {
372
+ return markers.filter(isLinkFailureMarker);
373
+ }
374
+
375
+ /**
376
+ * Resolve every reference embedded in rendered HTML. Unresolvable
377
+ * references stay in the output verbatim (loud-raw, CLAUDE.md §2) and
378
+ * push a marker onto `missing`; `unpublished` ones render their URL and
379
+ * push nothing — that is a per-environment policy, not a render failure.
380
+ *
381
+ * Text that starts like a reference but breaks the grammar stays raw
382
+ * and pushes {@link LINK_REF_MALFORMED}, with or without a resolver.
383
+ *
384
+ * @param resolver undefined = the caller has no resolver; every
385
+ * reference then fails with {@link LINK_RESOLVER_UNAVAILABLE}.
386
+ */
387
+ export function resolveLinkRefsInHtml(
388
+ html: string,
389
+ resolver: LinkResolver | undefined,
390
+ missing: string[],
391
+ ): string {
392
+ for (const raw of findMalformedLinkRefs(html)) missing.push(`${LINK_REF_MALFORMED}:${raw}`);
393
+ return replaceLinkRefs(html, (ref, raw) => {
394
+ if (!resolver) {
395
+ missing.push(`${LINK_RESOLVER_UNAVAILABLE}:${linkRefTargetKey(ref)}`);
396
+ return raw;
397
+ }
398
+ const r = resolver.resolve(ref);
399
+ if (r.status === "missing") {
400
+ missing.push(`${LINK_TARGET_MISSING}:${linkRefTargetKey(ref)}`);
401
+ return raw;
402
+ }
403
+ return r.url;
404
+ });
405
+ }
406
+
407
+ /**
408
+ * The per-environment policy for a reference that did not resolve
409
+ * cleanly (maintainer decision on #724): a missing target blocks
410
+ * everywhere; an unpublished one is a warning at Stage and blocks
411
+ * Publish live / promote. The editor preview shows drafts, so nothing
412
+ * there is an issue beyond a missing target.
413
+ */
414
+ export function linkIssueSeverity(
415
+ env: LinkRenderEnv,
416
+ status: LinkResolution["status"],
417
+ ): "block" | "warning" | null {
418
+ if (status === "missing") return "block";
419
+ if (status === "unpublished") {
420
+ if (env === "production") return "block";
421
+ if (env === "staging") return "warning";
422
+ }
423
+ return null;
424
+ }
425
+
426
+ /** A page as the link index needs it. */
427
+ export interface LinkIndexPage {
428
+ readonly id: string;
429
+ /**
430
+ * The page's composed public path (`pages.current_path`, or the
431
+ * branch view's dry-run path in a chat preview): leading slash, "/"
432
+ * for the root.
433
+ */
434
+ readonly path: string;
435
+ readonly status: "draft" | "published";
436
+ }
437
+
438
+ /** A media asset as the link index needs it. */
439
+ export interface LinkIndexMedia {
440
+ readonly id: string;
441
+ /** `media_assets.slug` — the URL the editor serves and the build rewrites. */
442
+ readonly slug: string;
443
+ }
444
+
445
+ /** What a render-wide link index is built from. */
446
+ export interface SiteLinkIndexInput {
447
+ /** Every live (non-deleted) page visible to the render's scope, drafts included. */
448
+ readonly pages: ReadonlyArray<LinkIndexPage>;
449
+ /** Every live (non-deleted) media asset. */
450
+ readonly media: ReadonlyArray<LinkIndexMedia>;
451
+ readonly env: LinkRenderEnv;
452
+ /**
453
+ * The serving target's page emission style, so a link carries exactly
454
+ * the URL the target serves (`/about/` vs `/about`) — no 301 hop per
455
+ * internal link, and byte-identical with the canonical (#590).
456
+ */
457
+ readonly pageUrlStyle: PageUrlStyle;
458
+ }
459
+
460
+ /**
461
+ * The render-wide lookup tables, from which a per-page resolver is cut.
462
+ * Built once per preview / per build.
463
+ */
464
+ export interface SiteLinkIndex {
465
+ readonly env: LinkRenderEnv;
466
+ /**
467
+ * The resolver for rendering page `renderingPageId`. Today every page
468
+ * gets the same answers; rollout D's `link-resolve` composition point
469
+ * hooks in here to map a target onto the rendering page's locale.
470
+ * Each call returns a fresh {@link RecordingLinkResolver}.
471
+ */
472
+ forPage(renderingPageId: string | null): RecordingLinkResolver;
473
+ /** The page's public path (no style applied), or null when unknown. */
474
+ pathOf(pageId: string): string | null;
475
+ }
476
+
477
+ /**
478
+ * The URL path a page is served at under `style`: `/` for the root,
479
+ * `/a/b/` (directory) or `/a/b` (no-extension) otherwise. Same shape as
480
+ * the path half of `resolveCanonicalUrl`.
481
+ */
482
+ export function pageHrefForStyle(path: string, style: PageUrlStyle): string {
483
+ const trimmed = trimSlashes(path);
484
+ if (trimmed.length === 0) return "/";
485
+ return style === "no-extension" ? `/${trimmed}` : `/${trimmed}/`;
486
+ }
487
+
488
+ /** Build the URL a resolved page reference renders as. */
489
+ function pageRefUrl(ref: PageLinkRef, path: string, style: PageUrlStyle): string {
490
+ return (
491
+ pageHrefForStyle(path, style) +
492
+ (ref.query ? `?${ref.query}` : "") +
493
+ (ref.anchor ? `#${ref.anchor}` : "")
494
+ );
495
+ }
496
+
497
+ /**
498
+ * Build the render-wide link index. Media references resolve to the
499
+ * editor-served media URL (`/_caelo/media/<slug>[/<variant>]`) — the
500
+ * same URL a literal embed carries — so the preview's responsive-image
501
+ * enrichment and the static generator's media pass (content-hashed
502
+ * `/_assets/…`, srcset ladders) apply to them unchanged (#162 parity).
503
+ */
504
+ export function createSiteLinkIndex(input: SiteLinkIndexInput): SiteLinkIndex {
505
+ const pages = new Map(input.pages.map((p) => [p.id, p]));
506
+ const media = new Map(input.media.map((m) => [m.id, m]));
507
+ const resolve = (ref: LinkRef): LinkResolution => {
508
+ if (ref.kind === "media") {
509
+ const asset = media.get(ref.mediaId);
510
+ if (!asset) return { status: "missing" };
511
+ return { status: "resolved", url: buildMediaUrl(asset.slug, ref.variant ?? "orig") };
512
+ }
513
+ const page = pages.get(ref.pageId);
514
+ if (!page) return { status: "missing" };
515
+ const url = pageRefUrl(ref, page.path, input.pageUrlStyle);
516
+ if (input.env !== "preview" && page.status !== "published") {
517
+ return { status: "unpublished", url };
518
+ }
519
+ return { status: "resolved", url };
520
+ };
521
+ return {
522
+ env: input.env,
523
+ pathOf: (pageId) => pages.get(pageId)?.path ?? null,
524
+ forPage: () => {
525
+ const observed: ObservedLinkResolution[] = [];
526
+ return {
527
+ observed,
528
+ resolve(ref) {
529
+ const resolution = resolve(ref);
530
+ observed.push({ ref, resolution });
531
+ return resolution;
532
+ },
533
+ };
534
+ },
535
+ };
536
+ }
537
+
538
+ // ─── Humanising (reads) ──────────────────────────────────────────────
539
+
540
+ /** What a read surface knows about reference targets. */
541
+ export interface LinkHumaniseLookup {
542
+ /** The page's public path (`/about`), or null when it does not exist. */
543
+ pagePath(pageId: string): string | null;
544
+ /** The media asset's slug, or null when it does not exist. */
545
+ mediaSlug(mediaId: string): string | null;
546
+ }
547
+
548
+ /**
549
+ * Turn stored references back into what an author writes: `/about`,
550
+ * `/about#team`, `/_caelo/media/hero`. Applied to every read that shows
551
+ * content to the AI or the operator, so neither ever has to learn the
552
+ * grammar (CLAUDE.md §1A); writing the humanised form back
553
+ * canonicalises to the same reference.
554
+ *
555
+ * A reference whose target is gone stays raw — it is broken, and a
556
+ * plausible-looking path would hide that.
557
+ */
558
+ export function humaniseLinkRefs<T>(value: T, lookup: LinkHumaniseLookup): T {
559
+ return mapLinkRefsDeep(value, (ref, raw) => {
560
+ if (ref.kind === "media") {
561
+ const slug = lookup.mediaSlug(ref.mediaId);
562
+ if (slug === null) return raw;
563
+ return buildMediaUrl(slug, ref.variant ?? "orig");
564
+ }
565
+ const path = lookup.pagePath(ref.pageId);
566
+ if (path === null) return raw;
567
+ return `${path}${ref.query ? `?${ref.query}` : ""}${ref.anchor ? `#${ref.anchor}` : ""}`;
568
+ }) as T;
569
+ }