@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.
Files changed (72) hide show
  1. package/dist/ai-tools.d.ts +19 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +19 -9
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auto-update.d.ts +15 -4
  6. package/dist/auto-update.d.ts.map +1 -1
  7. package/dist/auto-update.js +19 -14
  8. package/dist/auto-update.js.map +1 -1
  9. package/dist/google-oauth.d.ts +22 -0
  10. package/dist/google-oauth.d.ts.map +1 -0
  11. package/dist/google-oauth.js +25 -0
  12. package/dist/google-oauth.js.map +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/link-canonicalise.d.ts +109 -0
  18. package/dist/link-canonicalise.d.ts.map +1 -0
  19. package/dist/link-canonicalise.js +471 -0
  20. package/dist/link-canonicalise.js.map +1 -0
  21. package/dist/link-refs.d.ts +63 -8
  22. package/dist/link-refs.d.ts.map +1 -1
  23. package/dist/link-refs.js +80 -14
  24. package/dist/link-refs.js.map +1 -1
  25. package/dist/media.d.ts.map +1 -1
  26. package/dist/media.js +15 -0
  27. package/dist/media.js.map +1 -1
  28. package/dist/nested-module-render.d.ts +17 -0
  29. package/dist/nested-module-render.d.ts.map +1 -1
  30. package/dist/nested-module-render.js +45 -6
  31. package/dist/nested-module-render.js.map +1 -1
  32. package/dist/platform-updates.d.ts +20 -3
  33. package/dist/platform-updates.d.ts.map +1 -1
  34. package/dist/platform-updates.js +24 -1
  35. package/dist/platform-updates.js.map +1 -1
  36. package/dist/preview-compose.d.ts.map +1 -1
  37. package/dist/preview-compose.js +12 -2
  38. package/dist/preview-compose.js.map +1 -1
  39. package/dist/proposal-status.d.ts +1 -1
  40. package/dist/redirect-emit-gcp.d.ts +11 -1
  41. package/dist/redirect-emit-gcp.d.ts.map +1 -1
  42. package/dist/redirect-emit-gcp.js +16 -2
  43. package/dist/redirect-emit-gcp.js.map +1 -1
  44. package/dist/release-manifest.d.ts +14 -4
  45. package/dist/release-manifest.d.ts.map +1 -1
  46. package/dist/release-manifest.js +19 -3
  47. package/dist/release-manifest.js.map +1 -1
  48. package/dist/theme-normalize.d.ts.map +1 -1
  49. package/dist/theme-normalize.js +17 -2
  50. package/dist/theme-normalize.js.map +1 -1
  51. package/dist/version.d.ts +2 -2
  52. package/dist/version.js +1 -1
  53. package/package.json +1 -1
  54. package/src/ai-tools.ts +22 -11
  55. package/src/auto-update.test.ts +47 -8
  56. package/src/auto-update.ts +29 -14
  57. package/src/google-oauth.ts +27 -0
  58. package/src/index.ts +2 -0
  59. package/src/link-canonicalise.test.ts +387 -0
  60. package/src/link-canonicalise.ts +598 -0
  61. package/src/link-refs.test.ts +206 -0
  62. package/src/link-refs.ts +119 -21
  63. package/src/media.ts +16 -0
  64. package/src/nested-module-render.ts +59 -4
  65. package/src/platform-updates.ts +26 -1
  66. package/src/preview-compose.ts +11 -2
  67. package/src/redirect-emit-gcp.ts +16 -2
  68. package/src/redirect-emit.test.ts +6 -4
  69. package/src/release-manifest.ts +39 -7
  70. package/src/theme-normalize-typography-subfields.test.ts +54 -0
  71. package/src/theme-normalize.ts +19 -2
  72. package/src/version.ts +1 -1
@@ -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
- ["infra change", { facts: { ...FACTS, infraChange: true } }, "needs_cloud_shell"],
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 a security fix",
251
- { facts: { ...SECURITY, infraChange: true } },
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 still never installs a cloud-setup change before #819 (inert)", () => {
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("needs_cloud_shell");
278
- expect(d.reason).toContain("takes effect once Caelo can run them itself");
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 off says nothing about the Owner's choice", () => {
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.reason).not.toContain("takes effect");
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", () => {
@@ -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 → needs_cloud_shell (never automatic, even
29
- * with `autoConverge` on: that choice is inert until the converge job
30
- * runs these releases from Caelo, #819);
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
- if (facts.infraChange === true) {
232
- // FIXME(issue #819): once the converge job runs these releases from
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, which the admin may not do. It is never installed automatically: the Owner runs it once with their own account in Cloud Shell.${ownerChoice}`,
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
- if (!input.upgraderAvailable) {
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>&lt;a href="/nope"&gt;Docs&lt;/a&gt;</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>&lt;a href="/nope"&gt;Docs&lt;/a&gt;</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/&lt;id&gt; 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
+ });