@caelo-cms/shared 0.10.53 → 0.10.57
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/dist/auto-update.d.ts +17 -3
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +21 -9
- package/dist/auto-update.js.map +1 -1
- package/dist/fonts.d.ts +12 -0
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +37 -1
- package/dist/fonts.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/link-refs.d.ts +250 -0
- package/dist/link-refs.d.ts.map +1 -0
- package/dist/link-refs.js +416 -0
- package/dist/link-refs.js.map +1 -0
- package/dist/media.d.ts +1 -1
- package/dist/nested-module-render.d.ts +4 -1
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +14 -2
- package/dist/nested-module-render.js.map +1 -1
- package/dist/platform-updates.d.ts +27 -6
- package/dist/platform-updates.d.ts.map +1 -1
- package/dist/platform-updates.js +37 -3
- package/dist/platform-updates.js.map +1 -1
- package/dist/preview-compose.d.ts +10 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +58 -20
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +1 -1
- package/dist/raw-text-escape.d.ts +18 -0
- package/dist/raw-text-escape.d.ts.map +1 -0
- package/dist/raw-text-escape.js +40 -0
- package/dist/raw-text-escape.js.map +1 -0
- package/dist/redirect-emit-gcp.d.ts +11 -1
- package/dist/redirect-emit-gcp.d.ts.map +1 -1
- package/dist/redirect-emit-gcp.js +16 -2
- package/dist/redirect-emit-gcp.js.map +1 -1
- package/dist/release-manifest.d.ts +14 -4
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +19 -3
- package/dist/release-manifest.js.map +1 -1
- package/dist/responsive-images.d.ts.map +1 -1
- package/dist/responsive-images.js +3 -3
- package/dist/responsive-images.js.map +1 -1
- package/dist/seo.js +1 -1
- package/dist/seo.js.map +1 -1
- package/dist/template-balance.d.ts +3 -0
- package/dist/template-balance.d.ts.map +1 -0
- package/dist/template-balance.js +214 -0
- package/dist/template-balance.js.map +1 -0
- package/dist/template-engine.d.ts +11 -0
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +12 -0
- package/dist/template-engine.js.map +1 -1
- package/dist/theme-render.d.ts +2 -2
- package/dist/theme-render.d.ts.map +1 -1
- package/dist/theme-render.js +29 -5
- package/dist/theme-render.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/auto-update.test.ts +74 -4
- package/src/auto-update.ts +31 -9
- package/src/fonts.test.ts +37 -0
- package/src/fonts.ts +38 -1
- package/src/index.ts +2 -0
- package/src/link-refs.test.ts +552 -0
- package/src/link-refs.ts +569 -0
- package/src/nested-module-render.ts +18 -1
- package/src/platform-updates.test.ts +5 -0
- package/src/platform-updates.ts +39 -3
- package/src/preview-compose-deferrals.test.ts +103 -0
- package/src/preview-compose.ts +73 -24
- package/src/raw-text-escape.ts +44 -0
- package/src/redirect-emit-gcp.ts +16 -2
- package/src/redirect-emit.test.ts +6 -4
- package/src/release-manifest.ts +39 -7
- package/src/responsive-images.test.ts +10 -0
- package/src/responsive-images.ts +6 -3
- package/src/seo.test.ts +7 -0
- package/src/seo.ts +1 -1
- package/src/template-balance.test.ts +97 -0
- package/src/template-balance.ts +216 -0
- package/src/template-engine.ts +23 -0
- package/src/theme-render-sanitize.test.ts +40 -0
- package/src/theme-render.ts +40 -8
- package/src/version.ts +1 -1
package/src/link-refs.ts
ADDED
|
@@ -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, `&`-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/<id></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
|
+
}
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
*/
|
|
58
58
|
|
|
59
59
|
import type { ModuleFieldKind } from "./content.js";
|
|
60
|
+
import { isLinkFailureMarker, type LinkResolver } from "./link-refs.js";
|
|
60
61
|
import { caeloMissingComment, renderTemplate, type TemplateField } from "./template-engine.js";
|
|
61
62
|
|
|
62
63
|
const MAX_RECURSION_DEPTH = 8;
|
|
@@ -158,6 +159,8 @@ interface RenderContext {
|
|
|
158
159
|
* module: a switcher can sit inside a nested chrome module. */
|
|
159
160
|
readonly dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
|
|
160
161
|
readonly dormantDataLists: Readonly<Record<string, string>>;
|
|
162
|
+
/** Epic #724 — resolves internal references in every nested module. */
|
|
163
|
+
readonly linkResolver: LinkResolver | undefined;
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
function isNestedRef(v: unknown): v is NestedRefValue {
|
|
@@ -193,6 +196,7 @@ export function renderModuleWithContent(
|
|
|
193
196
|
readonly dataLists: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
|
|
194
197
|
readonly dormantDataLists: Readonly<Record<string, string>>;
|
|
195
198
|
},
|
|
199
|
+
linkResolver?: LinkResolver,
|
|
196
200
|
): NestedRenderResult {
|
|
197
201
|
const touched = new Set<string>();
|
|
198
202
|
const missing: string[] = [];
|
|
@@ -208,6 +212,7 @@ export function renderModuleWithContent(
|
|
|
208
212
|
themeAssets,
|
|
209
213
|
dataLists: pluginLists?.dataLists ?? {},
|
|
210
214
|
dormantDataLists: pluginLists?.dormantDataLists ?? {},
|
|
215
|
+
linkResolver,
|
|
211
216
|
});
|
|
212
217
|
return { html, touchedModuleIds: touched, missingSlots: missing, failures };
|
|
213
218
|
}
|
|
@@ -255,6 +260,7 @@ function renderInner(moduleId: string, contentInstanceId: string, ctx: RenderCon
|
|
|
255
260
|
themeAssets: ctx.themeAssets,
|
|
256
261
|
dataLists: ctx.dataLists,
|
|
257
262
|
dormantDataLists: ctx.dormantDataLists,
|
|
263
|
+
linkResolver: ctx.linkResolver,
|
|
258
264
|
};
|
|
259
265
|
|
|
260
266
|
return substituteWithRecursion(mod.html, mod.fields, ci.values, childCtx);
|
|
@@ -310,8 +316,16 @@ function substituteWithRecursion(
|
|
|
310
316
|
themeAssets: ctx.themeAssets,
|
|
311
317
|
dataLists: ctx.dataLists,
|
|
312
318
|
dormantDataLists: ctx.dormantDataLists,
|
|
319
|
+
linkResolver: ctx.linkResolver,
|
|
313
320
|
});
|
|
314
|
-
for (const m of result.missingSlots)
|
|
321
|
+
for (const m of result.missingSlots) {
|
|
322
|
+
// A broken reference inside a nested module stays raw in the partial
|
|
323
|
+
// HTML (loud-raw), so the enclosing module's render meets it again.
|
|
324
|
+
// The nested render already reported it with the precise field
|
|
325
|
+
// trail; drop the echo instead of reporting one broken link twice.
|
|
326
|
+
if (isLinkFailureMarker(m) && ctx.missing.includes(m)) continue;
|
|
327
|
+
fail(ctx, m);
|
|
328
|
+
}
|
|
315
329
|
return result.html;
|
|
316
330
|
}
|
|
317
331
|
|
|
@@ -381,6 +395,8 @@ export function renderPlacedModule(
|
|
|
381
395
|
readonly themeAssets?: NestedRenderThemeAssets;
|
|
382
396
|
readonly dataLists?: Readonly<Record<string, ReadonlyArray<Readonly<Record<string, string>>>>>;
|
|
383
397
|
readonly dormantDataLists?: Readonly<Record<string, string>>;
|
|
398
|
+
/** Epic #724 — resolves internal references (see `link-refs.ts`). */
|
|
399
|
+
readonly linkResolver?: LinkResolver;
|
|
384
400
|
},
|
|
385
401
|
): NestedRenderResult {
|
|
386
402
|
const touched = new Set<string>();
|
|
@@ -400,6 +416,7 @@ export function renderPlacedModule(
|
|
|
400
416
|
themeAssets: options.themeAssets,
|
|
401
417
|
dataLists: options.dataLists ?? {},
|
|
402
418
|
dormantDataLists: options.dormantDataLists ?? {},
|
|
419
|
+
linkResolver: options.linkResolver,
|
|
403
420
|
});
|
|
404
421
|
return { html, touchedModuleIds: touched, missingSlots: missing, failures };
|
|
405
422
|
}
|
|
@@ -61,6 +61,7 @@ describe("autoInstallRefusal", () => {
|
|
|
61
61
|
autoSecurity: true,
|
|
62
62
|
autoPatch: false,
|
|
63
63
|
autoMinor: false,
|
|
64
|
+
autoConverge: false,
|
|
64
65
|
channel: "stable",
|
|
65
66
|
windowStart: null,
|
|
66
67
|
windowEnd: null,
|
|
@@ -123,6 +124,7 @@ describe("updatePolicySchema", () => {
|
|
|
123
124
|
autoSecurity: true,
|
|
124
125
|
autoPatch: false,
|
|
125
126
|
autoMinor: false,
|
|
127
|
+
autoConverge: false,
|
|
126
128
|
channel: "stable",
|
|
127
129
|
windowStart: "02:00",
|
|
128
130
|
windowEnd: "04:00",
|
|
@@ -140,6 +142,9 @@ describe("updatePolicySchema", () => {
|
|
|
140
142
|
{ ...base, soakHours: 1000 },
|
|
141
143
|
{ ...base, securitySoakHours: 48 },
|
|
142
144
|
{ ...base, extra: true },
|
|
145
|
+
// "Don't tell me" is gone (#862): security releases are always announced.
|
|
146
|
+
{ ...base, mode: "off" },
|
|
147
|
+
{ ...base, autoConverge: undefined },
|
|
143
148
|
]) {
|
|
144
149
|
expect(updatePolicySchema.safeParse(bad).success).toBe(false);
|
|
145
150
|
}
|