@caelo-cms/shared 0.10.55 → 0.10.58
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/ai-tools.d.ts +19 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +19 -9
- package/dist/ai-tools.js.map +1 -1
- package/dist/auto-update.d.ts +15 -4
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +19 -14
- package/dist/auto-update.js.map +1 -1
- package/dist/google-oauth.d.ts +22 -0
- package/dist/google-oauth.d.ts.map +1 -0
- package/dist/google-oauth.js +25 -0
- package/dist/google-oauth.js.map +1 -0
- 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-canonicalise.d.ts +109 -0
- package/dist/link-canonicalise.d.ts.map +1 -0
- package/dist/link-canonicalise.js +471 -0
- package/dist/link-canonicalise.js.map +1 -0
- package/dist/link-refs.d.ts +63 -8
- package/dist/link-refs.d.ts.map +1 -1
- package/dist/link-refs.js +80 -14
- package/dist/link-refs.js.map +1 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +15 -0
- package/dist/media.js.map +1 -1
- package/dist/nested-module-render.d.ts +17 -0
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +45 -6
- package/dist/nested-module-render.js.map +1 -1
- package/dist/platform-updates.d.ts +20 -3
- package/dist/platform-updates.d.ts.map +1 -1
- package/dist/platform-updates.js +24 -1
- package/dist/platform-updates.js.map +1 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +12 -2
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +1 -1
- 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/theme-normalize.d.ts.map +1 -1
- package/dist/theme-normalize.js +17 -2
- package/dist/theme-normalize.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +22 -11
- package/src/auto-update.test.ts +47 -8
- package/src/auto-update.ts +29 -14
- package/src/google-oauth.ts +27 -0
- package/src/index.ts +2 -0
- package/src/link-canonicalise.test.ts +387 -0
- package/src/link-canonicalise.ts +598 -0
- package/src/link-refs.test.ts +206 -0
- package/src/link-refs.ts +119 -21
- package/src/media.ts +16 -0
- package/src/nested-module-render.ts +59 -4
- package/src/platform-updates.ts +26 -1
- package/src/preview-compose.ts +11 -2
- 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/theme-normalize-typography-subfields.test.ts +54 -0
- package/src/theme-normalize.ts +19 -2
- package/src/version.ts +1 -1
package/src/auto-update.test.ts
CHANGED
|
@@ -57,6 +57,7 @@ function input(over: Partial<AutoUpdateInput> & { version?: string; publishedHou
|
|
|
57
57
|
release: { version, publishedAt: hoursAgo(publishedHoursAgo) },
|
|
58
58
|
facts: FACTS,
|
|
59
59
|
upgraderAvailable: true,
|
|
60
|
+
convergeAvailable: true,
|
|
60
61
|
now: NOW,
|
|
61
62
|
...rest,
|
|
62
63
|
} satisfies AutoUpdateInput;
|
|
@@ -245,10 +246,12 @@ describe("decideAutoUpdate — pause", () => {
|
|
|
245
246
|
|
|
246
247
|
describe("decideAutoUpdate — releases that never install automatically", () => {
|
|
247
248
|
const cases: [string, Partial<AutoUpdateInput>, string][] = [
|
|
248
|
-
|
|
249
|
+
// autoConverge is off by default: the Owner installs it (#819).
|
|
250
|
+
["infra change", { facts: { ...FACTS, infraChange: true } }, "manual"],
|
|
251
|
+
["infra change on a security fix", { facts: { ...SECURITY, infraChange: true } }, "manual"],
|
|
249
252
|
[
|
|
250
|
-
"infra change on
|
|
251
|
-
{ facts: { ...
|
|
253
|
+
"infra change, no converge job on this install",
|
|
254
|
+
{ facts: { ...FACTS, infraChange: true }, convergeAvailable: false },
|
|
252
255
|
"needs_cloud_shell",
|
|
253
256
|
],
|
|
254
257
|
["destructive migrations", { facts: { ...FACTS, contractMigrations: true } }, "manual"],
|
|
@@ -260,7 +263,7 @@ describe("decideAutoUpdate — releases that never install automatically", () =>
|
|
|
260
263
|
expect(decideAutoUpdate({ ...input({}), ...over }).action).toBe(action);
|
|
261
264
|
});
|
|
262
265
|
|
|
263
|
-
it("auto_converge on
|
|
266
|
+
it("auto_converge on installs a cloud-setup change through the converge job (#819)", () => {
|
|
264
267
|
const everything = {
|
|
265
268
|
...POLICY,
|
|
266
269
|
autoSecurity: true,
|
|
@@ -274,15 +277,51 @@ describe("decideAutoUpdate — releases that never install automatically", () =>
|
|
|
274
277
|
]) {
|
|
275
278
|
for (const version of [VERSIONS.patch, VERSIONS.minor]) {
|
|
276
279
|
const d = decideAutoUpdate(input({ policy: everything, facts, version }));
|
|
277
|
-
expect(d.action).toBe("
|
|
278
|
-
expect(d.
|
|
280
|
+
expect(d.action).toBe("install");
|
|
281
|
+
expect(d.action === "install" && d.route).toBe("converge");
|
|
279
282
|
}
|
|
280
283
|
}
|
|
281
284
|
});
|
|
282
285
|
|
|
283
|
-
it("auto_converge
|
|
286
|
+
it("auto_converge only widens WHERE a release may install, never WHICH kinds", () => {
|
|
287
|
+
const d = decideAutoUpdate(
|
|
288
|
+
input({
|
|
289
|
+
policy: { ...POLICY, autoPatch: false, autoSecurity: false, autoConverge: true },
|
|
290
|
+
facts: { ...FACTS, infraChange: true },
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
expect(d.action).toBe("notify");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("auto_converge still waits for the soak period and never takes destructive migrations", () => {
|
|
297
|
+
const on = { ...POLICY, autoPatch: true, autoConverge: true };
|
|
298
|
+
const soaking = decideAutoUpdate(
|
|
299
|
+
input({ policy: on, facts: { ...FACTS, infraChange: true }, publishedHoursAgo: 1 }),
|
|
300
|
+
);
|
|
301
|
+
expect(soaking.action).toBe("wait");
|
|
302
|
+
expect(soaking.action === "wait" && soaking.route).toBe("converge");
|
|
303
|
+
const destructive = decideAutoUpdate(
|
|
304
|
+
input({ policy: on, facts: { ...FACTS, infraChange: true, contractMigrations: true } }),
|
|
305
|
+
);
|
|
306
|
+
expect(destructive.action).toBe("manual");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("a converge-route install needs the converge job, not the upgrader", () => {
|
|
310
|
+
const on = { ...POLICY, autoPatch: true, autoConverge: true };
|
|
311
|
+
const d = decideAutoUpdate(
|
|
312
|
+
input({ policy: on, facts: { ...FACTS, infraChange: true }, upgraderAvailable: false }),
|
|
313
|
+
);
|
|
314
|
+
expect(d.action).toBe("install");
|
|
315
|
+
const none = decideAutoUpdate(
|
|
316
|
+
input({ policy: on, facts: { ...FACTS, infraChange: true }, convergeAvailable: false }),
|
|
317
|
+
);
|
|
318
|
+
expect(none.action).toBe("needs_cloud_shell");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("auto_converge off: the Owner installs it from Settings → Updates", () => {
|
|
284
322
|
const d = decideAutoUpdate(input({ facts: { ...FACTS, infraChange: true } }));
|
|
285
|
-
expect(d.
|
|
323
|
+
expect(d.action).toBe("manual");
|
|
324
|
+
expect(d.reason).toContain("Settings → Updates");
|
|
286
325
|
});
|
|
287
326
|
|
|
288
327
|
it("auto_converge does not touch releases without a cloud-setup change", () => {
|
package/src/auto-update.ts
CHANGED
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
* 2. off the stable channel, or the policy only notifies → notify;
|
|
26
26
|
* 3. major → notify (never automatic before 1.0);
|
|
27
27
|
* 4. release information unreadable → blocked (fails loudly, no guess);
|
|
28
|
-
* 5. changes the cloud setup
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* 5. changes the cloud setup (#819): with no converge job on this
|
|
29
|
+
* install → needs_cloud_shell (the terminal path); with one but
|
|
30
|
+
* `autoConverge` off → manual (the Owner installs it from Settings →
|
|
31
|
+
* Updates); with `autoConverge` on → on through the rules below, and
|
|
32
|
+
* an install it decides goes to the converge job (`route: converge`);
|
|
31
33
|
* 6. destructive database changes → manual (never automatic);
|
|
32
34
|
* 7. the kind is not switched on (security / patch / minor) → notify;
|
|
33
35
|
* 8. no upgrader on this install → blocked;
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
|
|
40
42
|
import {
|
|
41
43
|
inMaintenanceWindow,
|
|
44
|
+
type PlatformUpdateRoute,
|
|
42
45
|
type StoredUpdatePolicy,
|
|
43
46
|
type UpdateKind,
|
|
44
47
|
updateKind,
|
|
@@ -109,6 +112,11 @@ export interface AutoUpdateInput {
|
|
|
109
112
|
readonly facts: ReleaseFacts | { readonly unavailable: string };
|
|
110
113
|
/** This install has an upgrader the admin can hand a request to. */
|
|
111
114
|
readonly upgraderAvailable: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* This install has a converge job the admin can start (#819): releases
|
|
117
|
+
* that change the cloud setup can install from Caelo at all.
|
|
118
|
+
*/
|
|
119
|
+
readonly convergeAvailable: boolean;
|
|
112
120
|
/** Why it has none right now, in plain words (null: none set up at all). */
|
|
113
121
|
readonly upgraderProblem?: string | null;
|
|
114
122
|
readonly now: Date;
|
|
@@ -128,6 +136,8 @@ export type AutoUpdateDecision =
|
|
|
128
136
|
readonly version: string;
|
|
129
137
|
readonly kind: UpdateKind;
|
|
130
138
|
readonly basis: AutoUpdateBasis;
|
|
139
|
+
/** Which job installs it once the wait is over. */
|
|
140
|
+
readonly route: PlatformUpdateRoute;
|
|
131
141
|
readonly reason: string;
|
|
132
142
|
/** When it becomes installable; null = until the Owner resumes. */
|
|
133
143
|
readonly until: Date | null;
|
|
@@ -139,6 +149,8 @@ export type AutoUpdateDecision =
|
|
|
139
149
|
readonly version: string;
|
|
140
150
|
readonly kind: UpdateKind;
|
|
141
151
|
readonly basis: AutoUpdateBasis;
|
|
152
|
+
/** Which job installs it: the upgrader, or the converge job (#819). */
|
|
153
|
+
readonly route: PlatformUpdateRoute;
|
|
142
154
|
readonly reason: string;
|
|
143
155
|
};
|
|
144
156
|
|
|
@@ -228,20 +240,22 @@ export function decideAutoUpdate(input: AutoUpdateInput): AutoUpdateDecision {
|
|
|
228
240
|
};
|
|
229
241
|
}
|
|
230
242
|
const facts = input.facts;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// Caelo, `policy.autoConverge` decides here like autoPatch does below.
|
|
234
|
-
// Until then the Owner's "install automatically" choice is stored but
|
|
235
|
-
// has no effect, and the reason says so.
|
|
236
|
-
const ownerChoice = policy.autoConverge
|
|
237
|
-
? " Your setting to install these automatically takes effect once Caelo can run them itself."
|
|
238
|
-
: "";
|
|
243
|
+
const converge = facts.infraChange === true;
|
|
244
|
+
if (converge && !input.convergeAvailable) {
|
|
239
245
|
return {
|
|
240
246
|
action: "needs_cloud_shell",
|
|
241
247
|
...base,
|
|
242
|
-
reason: `v${v} changes this install's cloud setup,
|
|
248
|
+
reason: `v${v} changes this install's cloud setup, and this install has no converge job to do that from Caelo. The Owner installs it once with their own account in Cloud Shell or from the computer they installed from.`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (converge && !policy.autoConverge) {
|
|
252
|
+
return {
|
|
253
|
+
action: "manual",
|
|
254
|
+
...base,
|
|
255
|
+
reason: `v${v} changes this install's cloud setup. The update policy does not install those automatically; the Owner installs it from Settings → Updates.`,
|
|
243
256
|
};
|
|
244
257
|
}
|
|
258
|
+
const route: PlatformUpdateRoute = converge ? "converge" : "app_roll";
|
|
245
259
|
if (facts.contractMigrations) {
|
|
246
260
|
return {
|
|
247
261
|
action: "manual",
|
|
@@ -265,7 +279,8 @@ export function decideAutoUpdate(input: AutoUpdateInput): AutoUpdateDecision {
|
|
|
265
279
|
reason: `v${v} is a ${what}; the update policy does not install those automatically. The Owner installs it.`,
|
|
266
280
|
};
|
|
267
281
|
}
|
|
268
|
-
|
|
282
|
+
// A converge-route install needs the converge job (checked above), not the upgrader.
|
|
283
|
+
if (!converge && !input.upgraderAvailable) {
|
|
269
284
|
return {
|
|
270
285
|
action: "blocked",
|
|
271
286
|
...base,
|
|
@@ -274,7 +289,7 @@ export function decideAutoUpdate(input: AutoUpdateInput): AutoUpdateDecision {
|
|
|
274
289
|
: `v${v} could install automatically, but this install has no upgrader the admin can start. Run the upgrade command from Settings → Updates once; it sets the upgrader up.`,
|
|
275
290
|
};
|
|
276
291
|
}
|
|
277
|
-
const auto = { ...base, basis } as const;
|
|
292
|
+
const auto = { ...base, basis, route } as const;
|
|
278
293
|
if (isPausedAt(policy, now)) {
|
|
279
294
|
const until = policy.pausedUntil === null ? null : new Date(policy.pausedUntil);
|
|
280
295
|
const who = policy.pausedByOwner ? "by the Owner" : "after a failed automatic install";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #914 — the shape of a Google OAuth client and the redirect URI IAP
|
|
5
|
+
* needs on it. Shared so the admin's server-side validation and the setup
|
|
6
|
+
* page (which shows IAP's redirect URI as the Owner types the client ID)
|
|
7
|
+
* use one definition.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Google OAuth 2.0 client ID (`<project number>-<random>.apps.googleusercontent.com`).
|
|
12
|
+
* Same pattern as the `operator_oauth_client` CHECK constraint (migration 0323).
|
|
13
|
+
*/
|
|
14
|
+
export const GOOGLE_OAUTH_CLIENT_ID = /^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* IAP's redirect URI for a custom OAuth client: Google sends a signed-in
|
|
18
|
+
* visitor back to IAP here, so the client must list it among its authorized
|
|
19
|
+
* redirect URIs (cloud.google.com/iap/docs/custom-oauth-configuration).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* iapRedirectUri("123-abc.apps.googleusercontent.com")
|
|
23
|
+
* // → "https://iap.googleapis.com/v1/oauth/clientIds/123-abc.apps.googleusercontent.com:handleRedirect"
|
|
24
|
+
*/
|
|
25
|
+
export function iapRedirectUri(clientId: string): string {
|
|
26
|
+
return `https://iap.googleapis.com/v1/oauth/clientIds/${clientId}:handleRedirect`;
|
|
27
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,8 +23,10 @@ export * from "./fonts.js";
|
|
|
23
23
|
export * from "./genesis.js";
|
|
24
24
|
export * from "./genesis-inventory.js";
|
|
25
25
|
export * from "./google-models.js";
|
|
26
|
+
export * from "./google-oauth.js";
|
|
26
27
|
export * from "./image-capabilities.js";
|
|
27
28
|
export * from "./interactions.js";
|
|
29
|
+
export * from "./link-canonicalise.js";
|
|
28
30
|
export * from "./link-refs.js";
|
|
29
31
|
export {
|
|
30
32
|
type LogContext,
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tier-1 unit tests for write-time link canonicalisation (epic #724,
|
|
5
|
+
* rollout C): which positions are links, what becomes a reference, what
|
|
6
|
+
* stays literal, and what is an issue.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
canonicaliseContentValues,
|
|
12
|
+
canonicaliseFieldDefaults,
|
|
13
|
+
canonicaliseHtmlLinks,
|
|
14
|
+
canonicaliseJsonLinks,
|
|
15
|
+
canonicaliseUrl,
|
|
16
|
+
collectLinkLiterals,
|
|
17
|
+
describeLinkIssues,
|
|
18
|
+
type LinkTargetLookup,
|
|
19
|
+
linkCanonicaliseRun,
|
|
20
|
+
} from "./link-canonicalise.js";
|
|
21
|
+
|
|
22
|
+
const ABOUT = "11111111-1111-4111-8111-111111111111";
|
|
23
|
+
const HOME = "22222222-2222-4222-8222-222222222222";
|
|
24
|
+
const GONE = "33333333-3333-4333-8333-333333333333";
|
|
25
|
+
const HERO = "44444444-4444-4444-8444-444444444444";
|
|
26
|
+
|
|
27
|
+
const lookup: LinkTargetLookup = {
|
|
28
|
+
pageIdForPath: (p) =>
|
|
29
|
+
(
|
|
30
|
+
({
|
|
31
|
+
"/": HOME,
|
|
32
|
+
"/company/about": ABOUT,
|
|
33
|
+
"/old-about": ABOUT,
|
|
34
|
+
// Old URLs an import redirected (301) to the page.
|
|
35
|
+
"/about.html": ABOUT,
|
|
36
|
+
"/about/index.html": ABOUT,
|
|
37
|
+
}) as Record<string, string>
|
|
38
|
+
)[p] ?? null,
|
|
39
|
+
mediaIdForSlug: (s) => (s === "hero" ? HERO : null),
|
|
40
|
+
pageExists: (id) => id === ABOUT || id === HOME,
|
|
41
|
+
mediaExists: (id) => id === HERO,
|
|
42
|
+
isOwnHost: (h) => h === "example.com",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const run = (grandfathered: string[] = []) =>
|
|
46
|
+
linkCanonicaliseRun(lookup, { grandfathered: new Set(grandfathered) });
|
|
47
|
+
|
|
48
|
+
describe("canonicaliseUrl", () => {
|
|
49
|
+
it("turns internal paths, own-domain URLs and media URLs into references", () => {
|
|
50
|
+
const r = run();
|
|
51
|
+
expect(canonicaliseUrl("/company/about", r, "x")).toBe(`caelo:page/${ABOUT}`);
|
|
52
|
+
expect(canonicaliseUrl("/company/about/#team", r, "x")).toBe(`caelo:page/${ABOUT}#team`);
|
|
53
|
+
expect(canonicaliseUrl("/company/about?ref=nav", r, "x")).toBe(`caelo:page/${ABOUT}?ref=nav`);
|
|
54
|
+
expect(canonicaliseUrl("/", r, "x")).toBe(`caelo:page/${HOME}`);
|
|
55
|
+
expect(canonicaliseUrl("https://example.com/company/about", r, "x")).toBe(
|
|
56
|
+
`caelo:page/${ABOUT}`,
|
|
57
|
+
);
|
|
58
|
+
expect(canonicaliseUrl("/old-about", r, "x")).toBe(`caelo:page/${ABOUT}`);
|
|
59
|
+
expect(canonicaliseUrl("/_caelo/media/hero", r, "x")).toBe(`caelo:media/${HERO}`);
|
|
60
|
+
expect(canonicaliseUrl("/_caelo/media/hero/webp-800", r, "x")).toBe(
|
|
61
|
+
`caelo:media/${HERO}/webp-800`,
|
|
62
|
+
);
|
|
63
|
+
expect(canonicaliseUrl(`/_caelo/media/${HERO}/webp-400`, r, "x")).toBe(
|
|
64
|
+
`caelo:media/${HERO}/webp-400`,
|
|
65
|
+
);
|
|
66
|
+
expect(r.issues).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("leaves external, special and non-page URLs literal", () => {
|
|
70
|
+
const r = run();
|
|
71
|
+
for (const v of [
|
|
72
|
+
"https://other.org/about",
|
|
73
|
+
"//cdn.example.com/x.js",
|
|
74
|
+
"mailto:hi@example.com",
|
|
75
|
+
"tel:+491234",
|
|
76
|
+
"#pricing",
|
|
77
|
+
"{{cta_href}}",
|
|
78
|
+
"/robots.txt",
|
|
79
|
+
"/sitemap.xml",
|
|
80
|
+
"/_assets/app.css",
|
|
81
|
+
"/api/forms/contact",
|
|
82
|
+
"relative/path",
|
|
83
|
+
"",
|
|
84
|
+
]) {
|
|
85
|
+
expect(canonicaliseUrl(v, r, "x")).toBe(v);
|
|
86
|
+
}
|
|
87
|
+
expect(r.issues).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reports unknown paths, unknown media and dangling references", () => {
|
|
91
|
+
const r = run();
|
|
92
|
+
expect(canonicaliseUrl("/nope", r, "values.cta_href")).toBe("/nope");
|
|
93
|
+
expect(canonicaliseUrl("/_caelo/media/missing", r, "html")).toBe("/_caelo/media/missing");
|
|
94
|
+
expect(canonicaliseUrl(`caelo:page/${GONE}`, r, "items[0].href")).toBe(`caelo:page/${GONE}`);
|
|
95
|
+
expect(canonicaliseUrl(`caelo:page/${ABOUT}`, r, "ok")).toBe(`caelo:page/${ABOUT}`);
|
|
96
|
+
expect(r.issues.map((i) => [i.kind, i.value, i.where])).toEqual([
|
|
97
|
+
["unknown-page-path", "/nope", "values.cta_href"],
|
|
98
|
+
["unknown-media", "/_caelo/media/missing", "html"],
|
|
99
|
+
["dangling-page-ref", `caelo:page/${GONE}`, "items[0].href"],
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("resolves file-shaped old URLs a page or redirect answers before treating them as files", () => {
|
|
104
|
+
const r = run();
|
|
105
|
+
expect(canonicaliseUrl("/about.html", r, "x")).toBe(`caelo:page/${ABOUT}`);
|
|
106
|
+
expect(canonicaliseUrl("/about/index.html#team", r, "x")).toBe(`caelo:page/${ABOUT}#team`);
|
|
107
|
+
expect(r.issues).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("reports a file path nothing serves (strict) and keeps it literal (report)", () => {
|
|
111
|
+
const strict = run();
|
|
112
|
+
expect(canonicaliseUrl("/files/guide.pdf", strict, "html")).toBe("/files/guide.pdf");
|
|
113
|
+
expect(canonicaliseUrl("/index.php", strict, "html")).toBe("/index.php");
|
|
114
|
+
expect(strict.issues.map((i) => [i.kind, i.value])).toEqual([
|
|
115
|
+
["unknown-file", "/files/guide.pdf"],
|
|
116
|
+
["unknown-file", "/index.php"],
|
|
117
|
+
]);
|
|
118
|
+
const report = linkCanonicaliseRun(lookup, { mode: "report" });
|
|
119
|
+
expect(canonicaliseUrl("/files/guide.pdf", report, "html")).toBe("/files/guide.pdf");
|
|
120
|
+
expect(report.issues.map((i) => i.kind)).toEqual(["unknown-file"]);
|
|
121
|
+
expect(describeLinkIssues(strict.issues)).toContain("media library");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("refuses a media link with a query or anchor instead of dropping it", () => {
|
|
125
|
+
const r = run();
|
|
126
|
+
expect(canonicaliseUrl("/_caelo/media/hero#page=3", r, "x")).toBe("/_caelo/media/hero#page=3");
|
|
127
|
+
expect(canonicaliseUrl("/_caelo/media/hero?download=1", r, "y")).toBe(
|
|
128
|
+
"/_caelo/media/hero?download=1",
|
|
129
|
+
);
|
|
130
|
+
expect(r.issues.map((i) => [i.kind, i.where])).toEqual([
|
|
131
|
+
["unsupported-link-form", "x"],
|
|
132
|
+
["unsupported-link-form", "y"],
|
|
133
|
+
]);
|
|
134
|
+
const report = linkCanonicaliseRun(lookup, { mode: "report" });
|
|
135
|
+
expect(canonicaliseUrl("/_caelo/media/hero#page=3", report, "x")).toBe(
|
|
136
|
+
"/_caelo/media/hero#page=3",
|
|
137
|
+
);
|
|
138
|
+
expect(report.issues.map((i) => i.kind)).toEqual(["unsupported-link-form"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("stores the orig variant as the plain media reference", () => {
|
|
142
|
+
const r = run();
|
|
143
|
+
expect(canonicaliseUrl("/_caelo/media/hero/orig", r, "x")).toBe(`caelo:media/${HERO}`);
|
|
144
|
+
expect(canonicaliseUrl(`/_caelo/media/${HERO}/orig`, r, "x")).toBe(`caelo:media/${HERO}`);
|
|
145
|
+
expect(r.issues).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("grandfathers links already stored", () => {
|
|
149
|
+
const r = run(["/nope", `caelo:page/${GONE}`]);
|
|
150
|
+
canonicaliseUrl("/nope", r, "x");
|
|
151
|
+
canonicaliseUrl(`caelo:page/${GONE}`, r, "x");
|
|
152
|
+
expect(r.issues).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("restricts media positions to media", () => {
|
|
156
|
+
const r = run();
|
|
157
|
+
expect(canonicaliseUrl("/company/about", r, "x", "media")).toBe("/company/about");
|
|
158
|
+
expect(r.issues).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps a link to a planned page literal and pending, never an issue", () => {
|
|
162
|
+
const planned = new Set(["/later", "/old/page.html"]);
|
|
163
|
+
const r = linkCanonicaliseRun({ ...lookup, isPendingPagePath: (p) => planned.has(p) });
|
|
164
|
+
expect(canonicaliseUrl("/later/#x", r, "values.cta_href")).toBe("/later/#x");
|
|
165
|
+
expect(canonicaliseUrl("https://example.com/old/page.html", r, "html")).toBe(
|
|
166
|
+
"https://example.com/old/page.html",
|
|
167
|
+
);
|
|
168
|
+
// An existing page still resolves; an unplanned path is still an issue.
|
|
169
|
+
expect(canonicaliseUrl("/company/about", r, "x")).toBe(`caelo:page/${ABOUT}`);
|
|
170
|
+
expect(canonicaliseUrl("/typo", r, "y")).toBe("/typo");
|
|
171
|
+
expect(r.pending).toEqual([
|
|
172
|
+
{ value: "/later/#x", path: "/later", where: "values.cta_href" },
|
|
173
|
+
{ value: "https://example.com/old/page.html", path: "/old/page.html", where: "html" },
|
|
174
|
+
]);
|
|
175
|
+
expect(r.issues.map((i) => [i.kind, i.value])).toEqual([["unknown-page-path", "/typo"]]);
|
|
176
|
+
// Without an import run nothing is pending.
|
|
177
|
+
const strict = run();
|
|
178
|
+
canonicaliseUrl("/later", strict, "x");
|
|
179
|
+
expect(strict.pending).toEqual([]);
|
|
180
|
+
expect(strict.issues.map((i) => i.kind)).toEqual(["unknown-page-path"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("reports a malformed reference instead of passing it through", () => {
|
|
184
|
+
const r = run();
|
|
185
|
+
const bad = [
|
|
186
|
+
"caelo:page/1111", // truncated id
|
|
187
|
+
`caelo:page/${ABOUT}/team`, // stray path after the id
|
|
188
|
+
"caelo:page/ABCDEF01-1111-4111-8111-111111111111", // ids are lowercase
|
|
189
|
+
`caelo:media/${HERO}/WEBP`, // variant outside the grammar
|
|
190
|
+
];
|
|
191
|
+
for (const v of bad) expect(canonicaliseUrl(v, r, "values.cta_href")).toBe(v);
|
|
192
|
+
expect(r.issues.map((i) => [i.kind, i.value])).toEqual(
|
|
193
|
+
bad.map((v) => ["malformed-link-ref", v]),
|
|
194
|
+
);
|
|
195
|
+
expect(describeLinkIssues(r.issues)).toContain("write the page's path");
|
|
196
|
+
// Grandfathered like any other stored literal.
|
|
197
|
+
const g = run(["caelo:page/1111"]);
|
|
198
|
+
canonicaliseUrl("caelo:page/1111", g, "x");
|
|
199
|
+
expect(g.issues).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("turns a query or anchor outside the grammar into an issue, never a throw", () => {
|
|
203
|
+
// `formatLinkRef` throws on a suffix it cannot parse back; the
|
|
204
|
+
// canonicaliser must validate with the grammar's own patterns first.
|
|
205
|
+
const r = run();
|
|
206
|
+
for (const v of [
|
|
207
|
+
"/company/about#a b",
|
|
208
|
+
"/company/about#x#y",
|
|
209
|
+
"/company/about?a=1?b=2",
|
|
210
|
+
'/company/about?q="x"',
|
|
211
|
+
`/company/about#${"a".repeat(257)}`,
|
|
212
|
+
`/company/about?${"q".repeat(513)}`,
|
|
213
|
+
"/company/about#über",
|
|
214
|
+
]) {
|
|
215
|
+
expect(() => canonicaliseUrl(v, r, "x")).not.toThrow();
|
|
216
|
+
expect(canonicaliseUrl(v, run(), "x")).toBe(v);
|
|
217
|
+
}
|
|
218
|
+
expect(r.issues.every((i) => i.kind === "unsupported-link-form")).toBe(true);
|
|
219
|
+
expect(r.issues).toHaveLength(7);
|
|
220
|
+
// The longest suffixes the grammar allows still become references.
|
|
221
|
+
const ok = run();
|
|
222
|
+
const anchor = "a".repeat(256);
|
|
223
|
+
expect(canonicaliseUrl(`/company/about#${anchor}`, ok, "x")).toBe(
|
|
224
|
+
`caelo:page/${ABOUT}#${anchor}`,
|
|
225
|
+
);
|
|
226
|
+
expect(ok.issues).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("HTML and JSON positions", () => {
|
|
231
|
+
it("canonicalises href, src, srcset and poster in HTML", () => {
|
|
232
|
+
const r = run();
|
|
233
|
+
const html =
|
|
234
|
+
`<a class="b" href="/company/about#x">A</a><a href='https://other.org/'>O</a>` +
|
|
235
|
+
`<img src="/_caelo/media/hero" srcset="/_caelo/media/hero/webp-400 400w, /_caelo/media/hero/webp-800 800w">` +
|
|
236
|
+
`<img data-src="/company/about" src="/company/about"><a href="{{href}}">t</a>`;
|
|
237
|
+
expect(canonicaliseHtmlLinks(html, r, "html")).toBe(
|
|
238
|
+
`<a class="b" href="caelo:page/${ABOUT}#x">A</a><a href='https://other.org/'>O</a>` +
|
|
239
|
+
`<img src="caelo:media/${HERO}" srcset="caelo:media/${HERO}/webp-400 400w, caelo:media/${HERO}/webp-800 800w">` +
|
|
240
|
+
`<img data-src="/company/about" src="/company/about"><a href="{{href}}">t</a>`,
|
|
241
|
+
);
|
|
242
|
+
expect(r.issues).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("reads attributes only inside start tags, not in escaped code samples", () => {
|
|
246
|
+
const r = run();
|
|
247
|
+
const html =
|
|
248
|
+
'<pre><code><a href="/nope">Docs</a></code></pre>' +
|
|
249
|
+
`<a title="a > b" href="/company/about">x</a><img alt='it"s' src="/_caelo/media/hero">`;
|
|
250
|
+
expect(canonicaliseHtmlLinks(html, r, "html")).toBe(
|
|
251
|
+
'<pre><code><a href="/nope">Docs</a></code></pre>' +
|
|
252
|
+
`<a title="a > b" href="caelo:page/${ABOUT}">x</a><img alt='it"s' src="caelo:media/${HERO}">`,
|
|
253
|
+
);
|
|
254
|
+
expect(r.issues).toEqual([]);
|
|
255
|
+
// An unterminated quote does not hide the tags before it.
|
|
256
|
+
expect(canonicaliseHtmlLinks('<a href="/company/about">x</a><b title="oops>', run(), "h")).toBe(
|
|
257
|
+
`<a href="caelo:page/${ABOUT}">x</a><b title="oops>`,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("reports malformed references in any HTML link position, once each", () => {
|
|
262
|
+
const r = run();
|
|
263
|
+
const html =
|
|
264
|
+
`<a href="caelo:page/${ABOUT}/team">A</a>` +
|
|
265
|
+
`<div data-href="caelo:page/1234">B</div>` +
|
|
266
|
+
`<img srcset="caelo:media/${HERO} 1x, caelo:media/abc 2x">` +
|
|
267
|
+
`<a href="caelo:page/${ABOUT}#ok">C</a>` +
|
|
268
|
+
"<p>Write caelo:page/<id> in prose: not a link.</p>";
|
|
269
|
+
expect(canonicaliseHtmlLinks(html, r, "html")).toBe(html);
|
|
270
|
+
expect(r.issues.map((i) => [i.kind, i.value])).toEqual([
|
|
271
|
+
["malformed-link-ref", `caelo:page/${ABOUT}/team`],
|
|
272
|
+
["malformed-link-ref", "caelo:media/abc"],
|
|
273
|
+
["malformed-link-ref", "caelo:page/1234"],
|
|
274
|
+
]);
|
|
275
|
+
// HTML without any attribute is still checked (a value that starts
|
|
276
|
+
// with the broken reference); element text is not a link position.
|
|
277
|
+
const t = run();
|
|
278
|
+
canonicaliseHtmlLinks("caelo:page/1234 <b>caelo:page/5678</b>", t, "html");
|
|
279
|
+
expect(t.issues.map((i) => i.value)).toEqual(["caelo:page/1234"]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("reports a malformed reference in a plain text value", () => {
|
|
283
|
+
const r = run();
|
|
284
|
+
expect(canonicaliseJsonLinks({ label: "caelo:page/1234" }, r, "values")).toEqual({
|
|
285
|
+
label: "caelo:page/1234",
|
|
286
|
+
});
|
|
287
|
+
expect(r.issues.map((i) => [i.kind, i.where])).toEqual([
|
|
288
|
+
["malformed-link-ref", "values.label"],
|
|
289
|
+
]);
|
|
290
|
+
// A stored malformed value is grandfathered by the next write.
|
|
291
|
+
const g = linkCanonicaliseRun(lookup, {
|
|
292
|
+
grandfathered: collectLinkLiterals({ label: "caelo:page/1234" }),
|
|
293
|
+
});
|
|
294
|
+
canonicaliseJsonLinks({ label: "caelo:page/1234" }, g, "values");
|
|
295
|
+
expect(g.issues).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("treats link-named keys as URLs and plain text as text", () => {
|
|
299
|
+
const r = run();
|
|
300
|
+
const items = [
|
|
301
|
+
{ label: "About", href: "/company/about", children: [{ label: "Home", href: "/" }] },
|
|
302
|
+
{ label: "/month", note: "/month" },
|
|
303
|
+
];
|
|
304
|
+
expect(canonicaliseJsonLinks(items, r, "items")).toEqual([
|
|
305
|
+
{
|
|
306
|
+
label: "About",
|
|
307
|
+
href: `caelo:page/${ABOUT}`,
|
|
308
|
+
children: [{ label: "Home", href: `caelo:page/${HOME}` }],
|
|
309
|
+
},
|
|
310
|
+
{ label: "/month", note: "/month" },
|
|
311
|
+
]);
|
|
312
|
+
expect(r.issues).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("canonicalises content values by field kind and name", () => {
|
|
316
|
+
const r = run();
|
|
317
|
+
const out = canonicaliseContentValues(
|
|
318
|
+
[
|
|
319
|
+
{ name: "cta", kind: "url" },
|
|
320
|
+
{ name: "hero", kind: "image" },
|
|
321
|
+
{ name: "price_suffix", kind: "text" },
|
|
322
|
+
{ name: "nav", kind: "link-list" },
|
|
323
|
+
{ name: "body", kind: "richtext" },
|
|
324
|
+
{ name: "card", kind: "module" },
|
|
325
|
+
],
|
|
326
|
+
{
|
|
327
|
+
cta: "/company/about",
|
|
328
|
+
hero: "/_caelo/media/hero",
|
|
329
|
+
price_suffix: "/month",
|
|
330
|
+
nav: [{ label: "Home", href: "/" }],
|
|
331
|
+
body: '<p><a href="/company/about">us</a></p>',
|
|
332
|
+
card: { moduleId: ABOUT, contentInstanceId: HOME },
|
|
333
|
+
legacy_href: "/company/about",
|
|
334
|
+
},
|
|
335
|
+
r,
|
|
336
|
+
);
|
|
337
|
+
expect(out).toEqual({
|
|
338
|
+
cta: `caelo:page/${ABOUT}`,
|
|
339
|
+
hero: `caelo:media/${HERO}`,
|
|
340
|
+
price_suffix: "/month",
|
|
341
|
+
nav: [{ label: "Home", href: `caelo:page/${HOME}` }],
|
|
342
|
+
body: `<p><a href="caelo:page/${ABOUT}">us</a></p>`,
|
|
343
|
+
card: { moduleId: ABOUT, contentInstanceId: HOME },
|
|
344
|
+
legacy_href: `caelo:page/${ABOUT}`,
|
|
345
|
+
});
|
|
346
|
+
expect(r.issues).toEqual([]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("canonicalises field defaults (layout chrome)", () => {
|
|
350
|
+
const r = run();
|
|
351
|
+
const fields = canonicaliseFieldDefaults(
|
|
352
|
+
[
|
|
353
|
+
{
|
|
354
|
+
name: "links",
|
|
355
|
+
kind: "link-list",
|
|
356
|
+
label: "L",
|
|
357
|
+
default: [{ label: "A", href: "/company/about" }],
|
|
358
|
+
},
|
|
359
|
+
{ name: "title", kind: "text", label: "T", default: "/company/about" },
|
|
360
|
+
],
|
|
361
|
+
r,
|
|
362
|
+
);
|
|
363
|
+
expect(fields[0]?.default).toEqual([{ label: "A", href: `caelo:page/${ABOUT}` }]);
|
|
364
|
+
expect(fields[1]?.default).toBe("/company/about");
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("grandfathering + messages", () => {
|
|
369
|
+
it("collects every link literal from a stored state", () => {
|
|
370
|
+
const literals = collectLinkLiterals({
|
|
371
|
+
html: '<a href="/nope">x</a>',
|
|
372
|
+
values: { cta: "/gone", text: "hello" },
|
|
373
|
+
});
|
|
374
|
+
expect(literals.has("/nope")).toBe(true);
|
|
375
|
+
expect(literals.has("/gone")).toBe(true);
|
|
376
|
+
expect(literals.has("hello")).toBe(false);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("names the value, the place and the next step", () => {
|
|
380
|
+
const msg = describeLinkIssues([
|
|
381
|
+
{ kind: "unknown-page-path", value: "/pricing", where: "values.cta_href" },
|
|
382
|
+
]);
|
|
383
|
+
expect(msg).toContain('values.cta_href: "/pricing" is not the path of any page on this site');
|
|
384
|
+
expect(msg).toContain("list_pages");
|
|
385
|
+
expect(msg).toContain("create it first");
|
|
386
|
+
});
|
|
387
|
+
});
|