@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.
- package/dist/auto-update.d.ts +5 -2
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +13 -6
- 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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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/markup-structure.d.ts +79 -0
- package/dist/markup-structure.d.ts.map +1 -0
- package/dist/markup-structure.js +274 -0
- package/dist/markup-structure.js.map +1 -0
- package/dist/media.d.ts +25 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +35 -0
- package/dist/media.js.map +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 +7 -3
- package/dist/platform-updates.d.ts.map +1 -1
- package/dist/platform-updates.js +13 -2
- package/dist/platform-updates.js.map +1 -1
- package/dist/preview-compose.d.ts +14 -3
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +60 -21
- package/dist/preview-compose.js.map +1 -1
- package/dist/publish-live-parity.d.ts +18 -0
- package/dist/publish-live-parity.d.ts.map +1 -0
- package/dist/publish-live-parity.js +21 -0
- package/dist/publish-live-parity.js.map +1 -0
- 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/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 +32 -1
- package/src/auto-update.ts +13 -6
- package/src/fonts.test.ts +37 -0
- package/src/fonts.ts +38 -1
- package/src/index.ts +4 -0
- package/src/link-refs.test.ts +552 -0
- package/src/link-refs.ts +569 -0
- package/src/markup-structure.test.ts +184 -0
- package/src/markup-structure.ts +304 -0
- package/src/media.ts +43 -0
- package/src/nested-module-render.ts +18 -1
- package/src/platform-updates.test.ts +5 -0
- package/src/platform-updates.ts +13 -2
- package/src/preview-compose-deferrals.test.ts +103 -0
- package/src/preview-compose.ts +79 -28
- package/src/publish-live-parity.ts +25 -0
- package/src/raw-text-escape.ts +44 -0
- 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
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tier-1 unit tests for the internal-reference grammar, parser and
|
|
5
|
+
* resolver (epic #724, rollout A). Markers are matched as literal
|
|
6
|
+
* strings: the editor's missing-content surface and the static
|
|
7
|
+
* generator's render gate read them verbatim.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
collectLinkRefsDeep,
|
|
13
|
+
createSiteLinkIndex,
|
|
14
|
+
findLinkRefs,
|
|
15
|
+
findMalformedLinkRefs,
|
|
16
|
+
formatLinkRef,
|
|
17
|
+
humaniseLinkRefs,
|
|
18
|
+
isLinkFailureMarker,
|
|
19
|
+
type LinkRef,
|
|
20
|
+
linkIssueSeverity,
|
|
21
|
+
linkMarkersOf,
|
|
22
|
+
linkRefTargetKey,
|
|
23
|
+
mapLinkRefsDeep,
|
|
24
|
+
pageHrefForStyle,
|
|
25
|
+
parseLinkRef,
|
|
26
|
+
resolveLinkRefsInHtml,
|
|
27
|
+
} from "./link-refs.js";
|
|
28
|
+
import { renderPlacedModule } from "./nested-module-render.js";
|
|
29
|
+
import { composePageWithLayout } from "./preview-compose.js";
|
|
30
|
+
import { renderTemplate } from "./template-engine.js";
|
|
31
|
+
|
|
32
|
+
const PAGE = "11111111-1111-4111-8111-111111111111";
|
|
33
|
+
const DRAFT = "22222222-2222-4222-8222-222222222222";
|
|
34
|
+
const GONE = "33333333-3333-4333-8333-333333333333";
|
|
35
|
+
const MEDIA = "44444444-4444-4444-8444-444444444444";
|
|
36
|
+
|
|
37
|
+
function index(env: "preview" | "staging" | "production" = "staging") {
|
|
38
|
+
return createSiteLinkIndex({
|
|
39
|
+
env,
|
|
40
|
+
pageUrlStyle: "directory",
|
|
41
|
+
pages: [
|
|
42
|
+
{ id: PAGE, path: "/company/about", status: "published" },
|
|
43
|
+
{ id: DRAFT, path: "/pricing", status: "draft" },
|
|
44
|
+
],
|
|
45
|
+
media: [{ id: MEDIA, slug: "hero-shot" }],
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("grammar — parse / format round trip", () => {
|
|
50
|
+
const cases: [string, LinkRef][] = [
|
|
51
|
+
[`caelo:page/${PAGE}`, { kind: "page", pageId: PAGE, query: null, anchor: null }],
|
|
52
|
+
[`caelo:page/${PAGE}#team`, { kind: "page", pageId: PAGE, query: null, anchor: "team" }],
|
|
53
|
+
[
|
|
54
|
+
`caelo:page/${PAGE}?ref=nav&x=1#team`,
|
|
55
|
+
{ kind: "page", pageId: PAGE, query: "ref=nav&x=1", anchor: "team" },
|
|
56
|
+
],
|
|
57
|
+
[`caelo:media/${MEDIA}`, { kind: "media", mediaId: MEDIA, variant: null }],
|
|
58
|
+
[`caelo:media/${MEDIA}/webp-800`, { kind: "media", mediaId: MEDIA, variant: "webp-800" }],
|
|
59
|
+
];
|
|
60
|
+
for (const [raw, ref] of cases) {
|
|
61
|
+
it(`parses and re-formats ${raw}`, () => {
|
|
62
|
+
expect(parseLinkRef(raw)).toEqual(ref);
|
|
63
|
+
expect(formatLinkRef(ref)).toBe(raw);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("rejects paths, external URLs, uppercase ids and embedded references", () => {
|
|
68
|
+
expect(parseLinkRef("/about")).toBeNull();
|
|
69
|
+
expect(parseLinkRef("https://example.com/about")).toBeNull();
|
|
70
|
+
expect(parseLinkRef("caelo:page/ABCDEF01-1111-4111-8111-111111111111")).toBeNull();
|
|
71
|
+
expect(parseLinkRef(`see caelo:page/${PAGE}`)).toBeNull();
|
|
72
|
+
expect(parseLinkRef("caelo:page/not-a-uuid")).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("finds references inside HTML attributes and srcset lists", () => {
|
|
76
|
+
const html = `<a href="caelo:page/${PAGE}#x">A</a><img srcset="caelo:media/${MEDIA}/webp-400 400w, caelo:media/${MEDIA}/webp-800 800w">`;
|
|
77
|
+
expect(findLinkRefs(html).map(linkRefTargetKey)).toEqual([
|
|
78
|
+
`page/${PAGE}`,
|
|
79
|
+
`media/${MEDIA}`,
|
|
80
|
+
`media/${MEDIA}`,
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("collects references from nested JSON values", () => {
|
|
85
|
+
const values = {
|
|
86
|
+
nav: [{ label: "About", href: `caelo:page/${PAGE}` }],
|
|
87
|
+
body: `<p><a href="caelo:page/${DRAFT}">x</a></p>`,
|
|
88
|
+
count: 3,
|
|
89
|
+
};
|
|
90
|
+
expect(collectLinkRefsDeep(values).map(linkRefTargetKey)).toEqual([
|
|
91
|
+
`page/${PAGE}`,
|
|
92
|
+
`page/${DRAFT}`,
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("resolution", () => {
|
|
98
|
+
it("renders page URLs in the serving target's style, keeping query and anchor", () => {
|
|
99
|
+
expect(pageHrefForStyle("/", "directory")).toBe("/");
|
|
100
|
+
expect(pageHrefForStyle("/a/b", "directory")).toBe("/a/b/");
|
|
101
|
+
expect(pageHrefForStyle("/a/b/", "no-extension")).toBe("/a/b");
|
|
102
|
+
const r = index().forPage(null);
|
|
103
|
+
expect(r.resolve(parseLinkRef(`caelo:page/${PAGE}?q=1#team`) as LinkRef)).toEqual({
|
|
104
|
+
status: "resolved",
|
|
105
|
+
url: "/company/about/?q=1#team",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("resolves media to the editor media URL the media pass rewrites", () => {
|
|
110
|
+
const r = index().forPage(null);
|
|
111
|
+
expect(r.resolve({ kind: "media", mediaId: MEDIA, variant: null })).toEqual({
|
|
112
|
+
status: "resolved",
|
|
113
|
+
url: "/_caelo/media/hero-shot",
|
|
114
|
+
});
|
|
115
|
+
expect(r.resolve({ kind: "media", mediaId: MEDIA, variant: "webp-800" })).toEqual({
|
|
116
|
+
status: "resolved",
|
|
117
|
+
url: "/_caelo/media/hero-shot/webp-800",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reports a draft target as unpublished in deploy envs, resolved in the preview", () => {
|
|
122
|
+
const ref: LinkRef = { kind: "page", pageId: DRAFT, query: null, anchor: null };
|
|
123
|
+
expect(index("staging").forPage(null).resolve(ref)).toEqual({
|
|
124
|
+
status: "unpublished",
|
|
125
|
+
url: "/pricing/",
|
|
126
|
+
});
|
|
127
|
+
expect(index("preview").forPage(null).resolve(ref)).toEqual({
|
|
128
|
+
status: "resolved",
|
|
129
|
+
url: "/pricing/",
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("records every resolution for the integrity check", () => {
|
|
134
|
+
const r = index().forPage(null);
|
|
135
|
+
resolveLinkRefsInHtml(`<a href="caelo:page/${GONE}">x</a>`, r, []);
|
|
136
|
+
expect(r.observed).toEqual([
|
|
137
|
+
{
|
|
138
|
+
ref: { kind: "page", pageId: GONE, query: null, anchor: null },
|
|
139
|
+
resolution: { status: "missing" },
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("applies the per-environment policy from the #724 decisions", () => {
|
|
145
|
+
expect(linkIssueSeverity("preview", "missing")).toBe("block");
|
|
146
|
+
expect(linkIssueSeverity("staging", "missing")).toBe("block");
|
|
147
|
+
expect(linkIssueSeverity("staging", "unpublished")).toBe("warning");
|
|
148
|
+
expect(linkIssueSeverity("production", "unpublished")).toBe("block");
|
|
149
|
+
expect(linkIssueSeverity("preview", "unpublished")).toBeNull();
|
|
150
|
+
expect(linkIssueSeverity("production", "resolved")).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("leaves a missing target raw and marks it", () => {
|
|
154
|
+
const missing: string[] = [];
|
|
155
|
+
const out = resolveLinkRefsInHtml(
|
|
156
|
+
`<a href="caelo:page/${GONE}#a">x</a>`,
|
|
157
|
+
index().forPage(null),
|
|
158
|
+
missing,
|
|
159
|
+
);
|
|
160
|
+
expect(out).toBe(`<a href="caelo:page/${GONE}#a">x</a>`);
|
|
161
|
+
expect(missing).toEqual([`link-target-missing:page/${GONE}`]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("marks every reference when the caller has no resolver", () => {
|
|
165
|
+
const missing: string[] = [];
|
|
166
|
+
resolveLinkRefsInHtml(`caelo:media/${MEDIA}`, undefined, missing);
|
|
167
|
+
expect(missing).toEqual([`link-resolver-unavailable:media/${MEDIA}`]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("template engine — references resolve wherever they come from", () => {
|
|
172
|
+
const resolver = () => index().forPage(null);
|
|
173
|
+
|
|
174
|
+
it("resolves module HTML attributes, values, defaults and link-list items", () => {
|
|
175
|
+
const r = renderTemplate({
|
|
176
|
+
html: `<a href="caelo:page/${PAGE}">Home</a><a href="{{cta_href}}">{{cta_label}}</a><img src="{{hero}}">{{#nav}}<a href="{{href}}">{{label}}</a>{{/nav}}<div>{{body}}</div>`,
|
|
177
|
+
fields: [
|
|
178
|
+
{ name: "cta_href", kind: "url", default: `caelo:page/${PAGE}#contact` },
|
|
179
|
+
{ name: "cta_label", kind: "text" },
|
|
180
|
+
{ name: "hero", kind: "image" },
|
|
181
|
+
{ name: "nav", kind: "link-list" },
|
|
182
|
+
{ name: "body", kind: "richtext" },
|
|
183
|
+
],
|
|
184
|
+
contentValues: {
|
|
185
|
+
cta_label: "Talk to us",
|
|
186
|
+
hero: `caelo:media/${MEDIA}`,
|
|
187
|
+
nav: [{ label: "Pricing", href: `caelo:page/${DRAFT}` }],
|
|
188
|
+
body: `<p>See <a href="caelo:page/${PAGE}?x=1">about</a></p>`,
|
|
189
|
+
},
|
|
190
|
+
linkResolver: resolver(),
|
|
191
|
+
});
|
|
192
|
+
expect(r.html).toBe(
|
|
193
|
+
'<a href="/company/about/">Home</a><a href="/company/about/#contact">Talk to us</a>' +
|
|
194
|
+
'<img src="/_caelo/media/hero-shot"><a href="/pricing/">Pricing</a>' +
|
|
195
|
+
'<div><p>See <a href="/company/about/?x=1">about</a></p></div>',
|
|
196
|
+
);
|
|
197
|
+
expect(r.missingSlots).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("flags a missing target in missingSlots and keeps the reference visible", () => {
|
|
201
|
+
const r = renderTemplate({
|
|
202
|
+
html: '<a href="{{link}}">x</a>',
|
|
203
|
+
fields: [{ name: "link", kind: "link" }],
|
|
204
|
+
contentValues: { link: `caelo:page/${GONE}` },
|
|
205
|
+
linkResolver: resolver(),
|
|
206
|
+
});
|
|
207
|
+
expect(r.html).toBe(`<a href="caelo:page/${GONE}">x</a>`);
|
|
208
|
+
expect(r.missingSlots).toEqual([`link-target-missing:page/${GONE}`]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("reports a broken reference in a nested module once, with its field trail", () => {
|
|
212
|
+
const CHILD_MOD = "55555555-5555-4555-8555-555555555555";
|
|
213
|
+
const CHILD_CI = "66666666-6666-4666-8666-666666666666";
|
|
214
|
+
const r = renderPlacedModule(
|
|
215
|
+
{
|
|
216
|
+
html: "<section>{{>card}}</section>",
|
|
217
|
+
fields: [{ name: "card", kind: "module" }],
|
|
218
|
+
values: { card: { moduleId: CHILD_MOD, contentInstanceId: CHILD_CI } },
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
getModule: (id) =>
|
|
222
|
+
id === CHILD_MOD
|
|
223
|
+
? {
|
|
224
|
+
moduleId: CHILD_MOD,
|
|
225
|
+
slug: "card",
|
|
226
|
+
html: '<a href="{{href}}">x</a>',
|
|
227
|
+
css: "",
|
|
228
|
+
js: "",
|
|
229
|
+
fields: [{ name: "href", kind: "url" }],
|
|
230
|
+
}
|
|
231
|
+
: null,
|
|
232
|
+
getContentInstance: (id) =>
|
|
233
|
+
id === CHILD_CI
|
|
234
|
+
? { id, moduleId: CHILD_MOD, values: { href: `caelo:page/${GONE}` }, deletedAt: null }
|
|
235
|
+
: null,
|
|
236
|
+
},
|
|
237
|
+
{ linkResolver: resolver() },
|
|
238
|
+
);
|
|
239
|
+
expect(r.failures).toEqual([{ field: "card", reason: `link-target-missing:page/${GONE}` }]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("composer — nav-menu sets and pre-rendered modules", () => {
|
|
244
|
+
it("resolves nav-menu item hrefs and attributes a broken one to the nav module", () => {
|
|
245
|
+
const out = composePageWithLayout({
|
|
246
|
+
layoutHtml:
|
|
247
|
+
'<html><head></head><body><caelo-slot name="header"></caelo-slot><caelo-slot name="content"></caelo-slot></body></html>',
|
|
248
|
+
layoutCss: "",
|
|
249
|
+
layoutBlocks: [
|
|
250
|
+
{
|
|
251
|
+
blockName: "header",
|
|
252
|
+
modules: [
|
|
253
|
+
{
|
|
254
|
+
moduleId: "m-nav",
|
|
255
|
+
slug: "nav-menu-main",
|
|
256
|
+
displayName: "Nav",
|
|
257
|
+
html: "",
|
|
258
|
+
css: "",
|
|
259
|
+
js: "",
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
templateHtml: '<caelo-slot name="content"></caelo-slot>',
|
|
265
|
+
templateCss: "",
|
|
266
|
+
blocks: [
|
|
267
|
+
{
|
|
268
|
+
blockName: "content",
|
|
269
|
+
modules: [
|
|
270
|
+
{
|
|
271
|
+
moduleId: "m-raw",
|
|
272
|
+
slug: "raw",
|
|
273
|
+
displayName: "Raw",
|
|
274
|
+
html: `<a href="caelo:page/${PAGE}">About</a>`,
|
|
275
|
+
css: "",
|
|
276
|
+
js: "",
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
structuredSets: {
|
|
282
|
+
byKindSlug: {
|
|
283
|
+
"nav-menu/main": [
|
|
284
|
+
{ label: "About", href: `caelo:page/${PAGE}` },
|
|
285
|
+
{ label: "Gone", href: `caelo:page/${GONE}` },
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
linkResolver: index().forPage(PAGE),
|
|
290
|
+
});
|
|
291
|
+
expect(out.html).toContain('<a href="/company/about/">About</a>');
|
|
292
|
+
expect(out.html).toContain(`href="caelo:page/${GONE}"`);
|
|
293
|
+
expect(out.moduleFailures).toEqual([
|
|
294
|
+
{
|
|
295
|
+
blockName: "header",
|
|
296
|
+
moduleId: "m-nav",
|
|
297
|
+
moduleSlug: "nav-menu-main",
|
|
298
|
+
field: "",
|
|
299
|
+
reason: `link-target-missing:page/${GONE}`,
|
|
300
|
+
},
|
|
301
|
+
]);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("grammar edge cases — boundaries, contexts, malformed references", () => {
|
|
306
|
+
const resolveIn = (html: string, resolver = index().forPage(null)) => {
|
|
307
|
+
const missing: string[] = [];
|
|
308
|
+
return { html: resolveLinkRefsInHtml(html, resolver, missing), missing };
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
it("formatLinkRef refuses a reference that would not parse back", () => {
|
|
312
|
+
expect(() =>
|
|
313
|
+
formatLinkRef({ kind: "page", pageId: "About", query: null, anchor: null }),
|
|
314
|
+
).toThrow(/not a valid internal reference/);
|
|
315
|
+
expect(() =>
|
|
316
|
+
formatLinkRef({ kind: "page", pageId: PAGE, query: "a b", anchor: null }),
|
|
317
|
+
).toThrow();
|
|
318
|
+
expect(() =>
|
|
319
|
+
formatLinkRef({ kind: "page", pageId: PAGE, query: null, anchor: "x#y" }),
|
|
320
|
+
).toThrow();
|
|
321
|
+
expect(() => formatLinkRef({ kind: "media", mediaId: MEDIA, variant: "WEBP" })).toThrow();
|
|
322
|
+
expect(formatLinkRef({ kind: "media", mediaId: MEDIA, variant: "webp-800" })).toBe(
|
|
323
|
+
`caelo:media/${MEDIA}/webp-800`,
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("does not treat `caelo:` inside another scheme or word as a reference", () => {
|
|
328
|
+
for (const text of [`xcaelo:page/${PAGE}`, `my-caelo:page/${PAGE}`, `a.caelo:media/${MEDIA}`]) {
|
|
329
|
+
expect(findLinkRefs(text)).toEqual([]);
|
|
330
|
+
expect(findMalformedLinkRefs(text)).toEqual([]);
|
|
331
|
+
expect(resolveIn(text)).toEqual({ html: text, missing: [] });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("leaves prose that mentions the grammar alone, but flags malformed refs in every link position", () => {
|
|
336
|
+
const prose = "<p>Stored as <code>caelo:page/<id></code> or caelo:media/<id>.</p>";
|
|
337
|
+
expect(resolveIn(prose)).toEqual({ html: prose, missing: [] });
|
|
338
|
+
const positions: [string, string][] = [
|
|
339
|
+
[`<a href='caelo:page/oops'>x</a>`, "caelo:page/oops"],
|
|
340
|
+
["<a href=caelo:page/oops>x</a>", "caelo:page/oops"],
|
|
341
|
+
[
|
|
342
|
+
`<img srcset="caelo:media/${MEDIA} 1x, caelo:media/${MEDIA}/BAD 2x">`,
|
|
343
|
+
`caelo:media/${MEDIA}/BAD`,
|
|
344
|
+
],
|
|
345
|
+
[`<img srcset="caelo:media/${MEDIA} 1x,caelo:media/x 2x">`, "caelo:media/x"],
|
|
346
|
+
["caelo:page/oops", "caelo:page/oops"],
|
|
347
|
+
];
|
|
348
|
+
for (const [html, raw] of positions) {
|
|
349
|
+
expect(resolveIn(html).missing).toEqual([`link-ref-malformed:${raw}`]);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("resolves references in every quoting and text context", () => {
|
|
354
|
+
const cases: [string, string][] = [
|
|
355
|
+
[`<a href="caelo:page/${PAGE}">x</a>`, '<a href="/company/about/">x</a>'],
|
|
356
|
+
[`<a href='caelo:page/${PAGE}#t'>x</a>`, "<a href='/company/about/#t'>x</a>"],
|
|
357
|
+
[`<a href=caelo:page/${PAGE}>x</a>`, "<a href=/company/about/>x</a>"],
|
|
358
|
+
[`<p>See caelo:page/${PAGE}.</p>`, "<p>See /company/about/.</p>"],
|
|
359
|
+
[`(caelo:page/${PAGE})`, "(/company/about/)"],
|
|
360
|
+
[
|
|
361
|
+
`<script type="application/ld+json">{"url":"caelo:page/${PAGE}"}</script>`,
|
|
362
|
+
'<script type="application/ld+json">{"url":"/company/about/"}</script>',
|
|
363
|
+
],
|
|
364
|
+
[`"caelo:page/${PAGE}"`, ""/company/about/""],
|
|
365
|
+
[
|
|
366
|
+
`<img srcset="caelo:media/${MEDIA}/webp-400 400w,caelo:media/${MEDIA} 800w">`,
|
|
367
|
+
'<img srcset="/_caelo/media/hero-shot/webp-400 400w,/_caelo/media/hero-shot 800w">',
|
|
368
|
+
],
|
|
369
|
+
];
|
|
370
|
+
for (const [input, expected] of cases)
|
|
371
|
+
expect(resolveIn(input)).toEqual({ html: expected, missing: [] });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("carries query and anchor verbatim, including characters outside the classes", () => {
|
|
375
|
+
expect(resolveIn(`caelo:page/${PAGE}?x=[1]#a`).html).toBe("/company/about/?x=[1]#a");
|
|
376
|
+
expect(resolveIn(`caelo:page/${PAGE}?`).html).toBe("/company/about/?");
|
|
377
|
+
expect(resolveIn(`caelo:page/${PAGE}#`).html).toBe("/company/about/#");
|
|
378
|
+
const longQuery = "a".repeat(700);
|
|
379
|
+
expect(resolveIn(`caelo:page/${PAGE}?${longQuery}#end`).html).toBe(
|
|
380
|
+
`/company/about/?${longQuery}#end`,
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("flags text that starts like a reference but breaks the grammar, and leaves it raw", () => {
|
|
385
|
+
const malformed = [
|
|
386
|
+
"caelo:page/not-a-uuid",
|
|
387
|
+
`caelo:page/${PAGE.slice(0, 30)}`,
|
|
388
|
+
"caelo:page/ABCDEF01-1111-4111-8111-111111111111",
|
|
389
|
+
`caelo:page/${PAGE}abc`,
|
|
390
|
+
`caelo:page/${PAGE}/team`,
|
|
391
|
+
`caelo:page/${PAGE}_x`,
|
|
392
|
+
`caelo:media/${MEDIA}/WEBP`,
|
|
393
|
+
`caelo:media/${MEDIA}/webp-800/x`,
|
|
394
|
+
`caelo:media/${MEDIA}/`,
|
|
395
|
+
"caelo:media/",
|
|
396
|
+
];
|
|
397
|
+
for (const raw of malformed) {
|
|
398
|
+
const html = `<a href="${raw}">x</a>`;
|
|
399
|
+
expect(findLinkRefs(html)).toEqual([]);
|
|
400
|
+
expect(resolveIn(html)).toEqual({ html, missing: [`link-ref-malformed:${raw}`] });
|
|
401
|
+
// Loud without a resolver too: the marker names the real problem.
|
|
402
|
+
const noResolver: string[] = [];
|
|
403
|
+
resolveLinkRefsInHtml(html, undefined, noResolver);
|
|
404
|
+
expect(noResolver).toEqual([`link-ref-malformed:${raw}`]);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("bounds the malformed snippet so a marker never carries a whole document", () => {
|
|
409
|
+
const { missing } = resolveIn(`caelo:page/${"x".repeat(5000)}`);
|
|
410
|
+
expect(missing).toHaveLength(1);
|
|
411
|
+
expect((missing[0] as string).length).toBeLessThan(140);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("is idempotent: a resolved render holds no reference to resolve again", () => {
|
|
415
|
+
const once = resolveIn(`<a href="caelo:page/${PAGE}#a">x</a><img src="caelo:media/${MEDIA}">`);
|
|
416
|
+
expect(findLinkRefs(once.html)).toEqual([]);
|
|
417
|
+
expect(resolveIn(once.html)).toEqual({ html: once.html, missing: [] });
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("returns the same object from mapLinkRefsDeep when nothing changed", () => {
|
|
421
|
+
const value = { a: ["/about", { b: 1, c: null }], d: "text" };
|
|
422
|
+
expect(mapLinkRefsDeep(value, () => "/x")).toBe(value);
|
|
423
|
+
const changed = mapLinkRefsDeep({ a: [`caelo:page/${PAGE}`] }, () => "/x");
|
|
424
|
+
expect(changed).toEqual({ a: ["/x"] });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("treats an unpublished target as a blocker only in production builds", () => {
|
|
428
|
+
const ref: LinkRef = { kind: "page", pageId: DRAFT, query: null, anchor: null };
|
|
429
|
+
expect(index("production").forPage(null).resolve(ref).status).toBe("unpublished");
|
|
430
|
+
// Not a render failure — the environment policy decides.
|
|
431
|
+
const missing: string[] = [];
|
|
432
|
+
expect(
|
|
433
|
+
resolveLinkRefsInHtml(`caelo:page/${DRAFT}`, index("production").forPage(null), missing),
|
|
434
|
+
).toBe("/pricing/");
|
|
435
|
+
expect(missing).toEqual([]);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("reports a malformed reference in a nested module once", () => {
|
|
439
|
+
const CHILD_MOD = "77777777-7777-4777-8777-777777777777";
|
|
440
|
+
const CHILD_CI = "88888888-8888-4888-8888-888888888888";
|
|
441
|
+
const r = renderPlacedModule(
|
|
442
|
+
{
|
|
443
|
+
html: "<section>{{>card}}</section>",
|
|
444
|
+
fields: [{ name: "card", kind: "module" }],
|
|
445
|
+
values: { card: { moduleId: CHILD_MOD, contentInstanceId: CHILD_CI } },
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
getModule: (id) =>
|
|
449
|
+
id === CHILD_MOD
|
|
450
|
+
? {
|
|
451
|
+
moduleId: CHILD_MOD,
|
|
452
|
+
slug: "card",
|
|
453
|
+
html: '<a href="{{href}}">x</a>',
|
|
454
|
+
css: "",
|
|
455
|
+
js: "",
|
|
456
|
+
fields: [{ name: "href", kind: "url" }],
|
|
457
|
+
}
|
|
458
|
+
: null,
|
|
459
|
+
getContentInstance: (id) =>
|
|
460
|
+
id === CHILD_CI
|
|
461
|
+
? { id, moduleId: CHILD_MOD, values: { href: "caelo:page/oops" }, deletedAt: null }
|
|
462
|
+
: null,
|
|
463
|
+
},
|
|
464
|
+
{ linkResolver: index().forPage(null) },
|
|
465
|
+
);
|
|
466
|
+
expect(r.failures).toEqual([{ field: "card", reason: "link-ref-malformed:caelo:page/oops" }]);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("scanner stays linear on adversarial input (CodeQL js/polynomial-redos)", () => {
|
|
471
|
+
// Same budget rationale as __tests__/redos-hardening.test.ts: a
|
|
472
|
+
// polynomial regression costs seconds on 100k chars, linear costs ms.
|
|
473
|
+
const BUDGET_MS = 250;
|
|
474
|
+
const time = (fn: () => void) => {
|
|
475
|
+
const t0 = performance.now();
|
|
476
|
+
fn();
|
|
477
|
+
return performance.now() - t0;
|
|
478
|
+
};
|
|
479
|
+
const inputs = [
|
|
480
|
+
"caelo:page/".repeat(10_000),
|
|
481
|
+
"caelo:media/".repeat(10_000),
|
|
482
|
+
`caelo:media/${MEDIA}/${"a".repeat(100_000)}`,
|
|
483
|
+
`caelo:page/${PAGE}?${"a".repeat(100_000)}`,
|
|
484
|
+
`caelo:page/${PAGE}#${"a".repeat(100_000)}`,
|
|
485
|
+
`${"caelo:page/11111111-1111-4111-8111-".repeat(3_000)}`,
|
|
486
|
+
`${`caelo:page/${PAGE}`.repeat(2_000)}`,
|
|
487
|
+
];
|
|
488
|
+
for (const [i, input] of inputs.entries()) {
|
|
489
|
+
it(`input ${i} (${input.length} chars) completes under budget`, () => {
|
|
490
|
+
expect(
|
|
491
|
+
time(() => {
|
|
492
|
+
resolveLinkRefsInHtml(input, index().forPage(null), []);
|
|
493
|
+
findLinkRefs(input);
|
|
494
|
+
}),
|
|
495
|
+
).toBeLessThan(BUDGET_MS);
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe("humanise — reads show paths, never ids", () => {
|
|
501
|
+
it("maps references back to authorable paths and keeps broken ones raw", () => {
|
|
502
|
+
const lookup = {
|
|
503
|
+
pagePath: (id: string) => (id === PAGE ? "/company/about" : null),
|
|
504
|
+
mediaSlug: (id: string) => (id === MEDIA ? "hero-shot" : null),
|
|
505
|
+
};
|
|
506
|
+
expect(
|
|
507
|
+
humaniseLinkRefs(
|
|
508
|
+
{
|
|
509
|
+
a: `caelo:page/${PAGE}#team`,
|
|
510
|
+
b: [`<img src="caelo:media/${MEDIA}/webp-800">`],
|
|
511
|
+
c: `caelo:page/${GONE}`,
|
|
512
|
+
},
|
|
513
|
+
lookup,
|
|
514
|
+
),
|
|
515
|
+
).toEqual({
|
|
516
|
+
a: "/company/about#team",
|
|
517
|
+
b: ['<img src="/_caelo/media/hero-shot/webp-800">'],
|
|
518
|
+
c: `caelo:page/${GONE}`,
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("link failure markers — what the build gate and the preview report", () => {
|
|
524
|
+
it("covers missing targets, malformed references and resolver-less renders", () => {
|
|
525
|
+
const missing: string[] = [];
|
|
526
|
+
resolveLinkRefsInHtml(
|
|
527
|
+
`<a href="caelo:page/${GONE}">a</a><a href="caelo:page/${PAGE}/team">b</a>`,
|
|
528
|
+
index().forPage(null),
|
|
529
|
+
missing,
|
|
530
|
+
);
|
|
531
|
+
resolveLinkRefsInHtml(`<a href="caelo:page/${PAGE}">c</a>`, undefined, missing);
|
|
532
|
+
expect(missing).toEqual([
|
|
533
|
+
`link-ref-malformed:caelo:page/${PAGE}/team`,
|
|
534
|
+
`link-target-missing:page/${GONE}`,
|
|
535
|
+
`link-resolver-unavailable:page/${PAGE}`,
|
|
536
|
+
]);
|
|
537
|
+
expect(missing.every(isLinkFailureMarker)).toBe(true);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("leaves every other marker alone, in order", () => {
|
|
541
|
+
expect(isLinkFailureMarker("field-not-declared:x")).toBe(false);
|
|
542
|
+
expect(isLinkFailureMarker("link-list-malformed:nav[0]")).toBe(false);
|
|
543
|
+
expect(
|
|
544
|
+
linkMarkersOf([
|
|
545
|
+
"theme-asset-unbound:logo",
|
|
546
|
+
"link-ref-malformed:caelo:page/x",
|
|
547
|
+
"module-ref-malformed:hero",
|
|
548
|
+
`link-target-missing:media/${MEDIA}`,
|
|
549
|
+
]),
|
|
550
|
+
).toEqual(["link-ref-malformed:caelo:page/x", `link-target-missing:media/${MEDIA}`]);
|
|
551
|
+
});
|
|
552
|
+
});
|