@caelo-cms/shared 0.10.57 → 0.10.59

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 (64) hide show
  1. package/dist/ai-tools.d.ts +30 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +26 -9
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/bug-intake.d.ts +176 -0
  6. package/dist/bug-intake.d.ts.map +1 -0
  7. package/dist/bug-intake.js +200 -0
  8. package/dist/bug-intake.js.map +1 -0
  9. package/dist/content.js +7 -7
  10. package/dist/edit-markers.d.ts +49 -0
  11. package/dist/edit-markers.d.ts.map +1 -0
  12. package/dist/edit-markers.js +225 -0
  13. package/dist/edit-markers.js.map +1 -0
  14. package/dist/google-oauth.d.ts +22 -0
  15. package/dist/google-oauth.d.ts.map +1 -0
  16. package/dist/google-oauth.js +25 -0
  17. package/dist/google-oauth.js.map +1 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/link-canonicalise.d.ts +109 -0
  23. package/dist/link-canonicalise.d.ts.map +1 -0
  24. package/dist/link-canonicalise.js +471 -0
  25. package/dist/link-canonicalise.js.map +1 -0
  26. package/dist/link-refs.d.ts +63 -8
  27. package/dist/link-refs.d.ts.map +1 -1
  28. package/dist/link-refs.js +80 -14
  29. package/dist/link-refs.js.map +1 -1
  30. package/dist/media.d.ts.map +1 -1
  31. package/dist/media.js +15 -0
  32. package/dist/media.js.map +1 -1
  33. package/dist/nested-module-render.d.ts +17 -0
  34. package/dist/nested-module-render.d.ts.map +1 -1
  35. package/dist/nested-module-render.js +45 -6
  36. package/dist/nested-module-render.js.map +1 -1
  37. package/dist/page-log.d.ts +1 -1
  38. package/dist/preview-compose.d.ts.map +1 -1
  39. package/dist/preview-compose.js +12 -2
  40. package/dist/preview-compose.js.map +1 -1
  41. package/dist/theme-normalize.d.ts.map +1 -1
  42. package/dist/theme-normalize.js +17 -2
  43. package/dist/theme-normalize.js.map +1 -1
  44. package/dist/version.d.ts +2 -2
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
  47. package/src/ai-tools.ts +29 -11
  48. package/src/bug-intake.test.ts +154 -0
  49. package/src/bug-intake.ts +256 -0
  50. package/src/content.ts +7 -7
  51. package/src/edit-markers.test.ts +111 -0
  52. package/src/edit-markers.ts +244 -0
  53. package/src/google-oauth.ts +27 -0
  54. package/src/index.ts +4 -0
  55. package/src/link-canonicalise.test.ts +387 -0
  56. package/src/link-canonicalise.ts +598 -0
  57. package/src/link-refs.test.ts +206 -0
  58. package/src/link-refs.ts +119 -21
  59. package/src/media.ts +16 -0
  60. package/src/nested-module-render.ts +59 -4
  61. package/src/preview-compose.ts +11 -2
  62. package/src/theme-normalize-typography-subfields.test.ts +54 -0
  63. package/src/theme-normalize.ts +19 -2
  64. package/src/version.ts +1 -1
@@ -123,6 +123,7 @@ describe("resolution", () => {
123
123
  expect(index("staging").forPage(null).resolve(ref)).toEqual({
124
124
  status: "unpublished",
125
125
  url: "/pricing/",
126
+ pageId: DRAFT,
126
127
  });
127
128
  expect(index("preview").forPage(null).resolve(ref)).toEqual({
128
129
  status: "resolved",
@@ -300,6 +301,27 @@ describe("composer — nav-menu sets and pre-rendered modules", () => {
300
301
  },
301
302
  ]);
302
303
  });
304
+
305
+ it("resolves references in the layout's own HTML and flags a broken one", () => {
306
+ // Rollout C stores layout HTML links as references; the page frame
307
+ // must resolve them like module HTML, never ship `caelo:` hrefs.
308
+ const out = composePageWithLayout({
309
+ layoutHtml:
310
+ `<html><head></head><body><a class="home" href="caelo:page/${PAGE}#top">Home</a>` +
311
+ `<a class="old" href="caelo:page/${GONE}">Old</a><caelo-slot name="content"></caelo-slot></body></html>`,
312
+ layoutCss: "",
313
+ layoutBlocks: [],
314
+ templateHtml: '<caelo-slot name="content"></caelo-slot>',
315
+ templateCss: "",
316
+ blocks: [],
317
+ structuredSets: { byKindSlug: {} },
318
+ linkResolver: index().forPage(PAGE),
319
+ });
320
+ expect(out.html).toContain('<a class="home" href="/company/about/#top">Home</a>');
321
+ expect(out.html).toContain(`<a class="old" href="caelo:page/${GONE}">Old</a>`);
322
+ expect(out.missingSlots).toContain(`link-target-missing:page/${GONE}`);
323
+ expect(out.moduleFailures).toEqual([]);
324
+ });
303
325
  });
304
326
 
305
327
  describe("grammar edge cases — boundaries, contexts, malformed references", () => {
@@ -497,6 +519,190 @@ describe("scanner stays linear on adversarial input (CodeQL js/polynomial-redos)
497
519
  }
498
520
  });
499
521
 
522
+ // Rollout D — the `link-resolve` composition point's answer: pages in
523
+ // variants (locales) grouped by "versions of each other".
524
+ const EN_ABOUT = "a1111111-1111-4111-8111-111111111111";
525
+ const DE_ABOUT = "a2222222-2222-4222-8222-222222222222";
526
+ const EN_PRICING = "b1111111-1111-4111-8111-111111111111";
527
+ const DE_PRICING = "b2222222-2222-4222-8222-222222222222";
528
+ const EN_SOLO = "c1111111-1111-4111-8111-111111111111";
529
+ const EN_HOME = "d1111111-1111-4111-8111-111111111111";
530
+ const DE_HOME = "d2222222-2222-4222-8222-222222222222";
531
+
532
+ function variantIndex(env: "preview" | "staging" = "staging") {
533
+ return createSiteLinkIndex({
534
+ env,
535
+ pageUrlStyle: "directory",
536
+ pages: [
537
+ { id: EN_ABOUT, path: "/about", status: "published" },
538
+ { id: DE_ABOUT, path: "/de/ueber", status: "published" },
539
+ { id: EN_PRICING, path: "/pricing", status: "published" },
540
+ { id: DE_PRICING, path: "/de/preise", status: "draft" },
541
+ { id: EN_SOLO, path: "/solo", status: "published" },
542
+ { id: EN_HOME, path: "/", status: "published" },
543
+ { id: DE_HOME, path: "/de", status: "published" },
544
+ ],
545
+ media: [{ id: MEDIA, slug: "hero-shot" }],
546
+ pageVariants: new Map([
547
+ [EN_ABOUT, { variant: "en", group: "g-about" }],
548
+ [DE_ABOUT, { variant: "de", group: "g-about" }],
549
+ [EN_PRICING, { variant: "en", group: "g-pricing" }],
550
+ [DE_PRICING, { variant: "de", group: "g-pricing" }],
551
+ [EN_SOLO, { variant: "en", group: null }],
552
+ [EN_HOME, { variant: "en", group: "g-home" }],
553
+ [DE_HOME, { variant: "de", group: "g-home" }],
554
+ ]),
555
+ });
556
+ }
557
+
558
+ const pageRef = (pageId: string, anchor: string | null = null): LinkRef => ({
559
+ kind: "page",
560
+ pageId,
561
+ query: null,
562
+ anchor,
563
+ });
564
+
565
+ describe("page variants (link-resolve) — links go to the rendering page's variant", () => {
566
+ it("maps a reference to the same-variant sibling, keeping the anchor", () => {
567
+ const de = variantIndex().forPage(DE_HOME);
568
+ expect(de.resolve(pageRef(EN_ABOUT, "team"))).toEqual({
569
+ status: "resolved",
570
+ url: "/de/ueber/#team",
571
+ });
572
+ // Already in the rendering variant: unchanged.
573
+ expect(de.resolve(pageRef(DE_ABOUT))).toEqual({ status: "resolved", url: "/de/ueber/" });
574
+ // A source-variant page renders its own variant's page.
575
+ const en = variantIndex().forPage(EN_HOME);
576
+ expect(en.resolve(pageRef(DE_ABOUT))).toEqual({ status: "resolved", url: "/about/" });
577
+ expect(en.resolve(pageRef(EN_ABOUT))).toEqual({ status: "resolved", url: "/about/" });
578
+ });
579
+
580
+ it("refuses two pages claiming the same variant in one group", () => {
581
+ expect(() =>
582
+ createSiteLinkIndex({
583
+ env: "staging",
584
+ pageUrlStyle: "directory",
585
+ pages: [
586
+ { id: EN_ABOUT, path: "/about", status: "published" },
587
+ { id: DE_ABOUT, path: "/de/ueber", status: "published" },
588
+ ],
589
+ media: [],
590
+ pageVariants: new Map([
591
+ [EN_ABOUT, { variant: "de", group: "g-about" }],
592
+ [DE_ABOUT, { variant: "de", group: "g-about" }],
593
+ ]),
594
+ }),
595
+ ).toThrow(/both the "de" version in variant group g-about/);
596
+ });
597
+
598
+ it("applies the environment policy to the SIBLING (an unpublished German version)", () => {
599
+ expect(variantIndex("staging").forPage(DE_HOME).resolve(pageRef(EN_PRICING))).toEqual({
600
+ status: "unpublished",
601
+ url: "/de/preise/",
602
+ pageId: DE_PRICING,
603
+ });
604
+ expect(variantIndex("preview").forPage(DE_HOME).resolve(pageRef(EN_PRICING))).toEqual({
605
+ status: "resolved",
606
+ url: "/de/preise/",
607
+ });
608
+ });
609
+
610
+ it("refuses a target without a sibling in the rendering variant, loudly", () => {
611
+ const missing: string[] = [];
612
+ const out = resolveLinkRefsInHtml(
613
+ `<a href="caelo:page/${EN_SOLO}">Solo</a>`,
614
+ variantIndex().forPage(DE_HOME),
615
+ missing,
616
+ );
617
+ expect(out).toBe(`<a href="caelo:page/${EN_SOLO}">Solo</a>`);
618
+ expect(missing).toEqual([`link-variant-missing:page/${EN_SOLO}:de`]);
619
+ expect(linkIssueSeverity("preview", "no-variant")).toBe("block");
620
+ expect(linkIssueSeverity("staging", "no-variant")).toBe("block");
621
+ });
622
+
623
+ it("maps nothing for a page no plugin has an opinion about, or a render outside a page", () => {
624
+ expect(variantIndex().forPage(PAGE).resolve(pageRef(EN_SOLO))).toEqual({
625
+ status: "resolved",
626
+ url: "/solo/",
627
+ });
628
+ expect(variantIndex().forPage(null).resolve(pageRef(DE_ABOUT))).toEqual({
629
+ status: "resolved",
630
+ url: "/de/ueber/",
631
+ });
632
+ });
633
+
634
+ it("covers HTML href, rich text, link field and link-list in one render", () => {
635
+ const r = renderTemplate({
636
+ html: `<a href="caelo:page/${EN_HOME}">Home</a><a href="{{cta_href}}">Go</a>{{#nav}}<a href="{{href}}">{{label}}</a>{{/nav}}<div>{{body}}</div>`,
637
+ fields: [
638
+ { name: "cta_href", kind: "link" },
639
+ { name: "nav", kind: "link-list" },
640
+ { name: "body", kind: "richtext" },
641
+ ],
642
+ contentValues: {
643
+ cta_href: `caelo:page/${EN_PRICING}#plans`,
644
+ nav: [
645
+ { label: "About", href: `caelo:page/${EN_ABOUT}` },
646
+ { label: "Solo", href: `caelo:page/${EN_SOLO}` },
647
+ ],
648
+ body: `<p><a href="caelo:page/${EN_ABOUT}?x=1">about</a></p>`,
649
+ },
650
+ linkResolver: variantIndex("preview").forPage(DE_PRICING),
651
+ });
652
+ expect(r.html).toBe(
653
+ '<a href="/de/">Home</a><a href="/de/preise/#plans">Go</a>' +
654
+ `<a href="/de/ueber/">About</a><a href="caelo:page/${EN_SOLO}">Solo</a>` +
655
+ '<div><p><a href="/de/ueber/?x=1">about</a></p></div>',
656
+ );
657
+ expect(r.missingSlots).toEqual([`link-variant-missing:page/${EN_SOLO}:de`]);
658
+ });
659
+
660
+ it("covers nav-menu sets in the composer and attributes a missing sibling to the nav", () => {
661
+ const out = composePageWithLayout({
662
+ layoutHtml:
663
+ '<html><head></head><body><caelo-slot name="header"></caelo-slot><caelo-slot name="content"></caelo-slot></body></html>',
664
+ layoutCss: "",
665
+ layoutBlocks: [
666
+ {
667
+ blockName: "header",
668
+ modules: [
669
+ {
670
+ moduleId: "m-nav",
671
+ slug: "nav-menu-main",
672
+ displayName: "Nav",
673
+ html: "",
674
+ css: "",
675
+ js: "",
676
+ },
677
+ ],
678
+ },
679
+ ],
680
+ templateHtml: '<caelo-slot name="content"></caelo-slot>',
681
+ templateCss: "",
682
+ blocks: [],
683
+ structuredSets: {
684
+ byKindSlug: {
685
+ "nav-menu/main": [
686
+ { label: "About", href: `caelo:page/${EN_ABOUT}` },
687
+ { label: "Solo", href: `caelo:page/${EN_SOLO}` },
688
+ ],
689
+ },
690
+ },
691
+ linkResolver: variantIndex().forPage(DE_ABOUT),
692
+ });
693
+ expect(out.html).toContain('<a href="/de/ueber/">About</a>');
694
+ expect(out.moduleFailures).toEqual([
695
+ {
696
+ blockName: "header",
697
+ moduleId: "m-nav",
698
+ moduleSlug: "nav-menu-main",
699
+ field: "",
700
+ reason: `link-variant-missing:page/${EN_SOLO}:de`,
701
+ },
702
+ ]);
703
+ });
704
+ });
705
+
500
706
  describe("humanise — reads show paths, never ids", () => {
501
707
  it("maps references back to authorable paths and keeps broken ones raw", () => {
502
708
  const lookup = {
package/src/link-refs.ts CHANGED
@@ -40,11 +40,18 @@
40
40
  * environment's policy (warn at Stage, block at Publish live — the
41
41
  * blocking check is rollout F).
42
42
  *
43
+ * Page variants (rollout D): a plugin that splits the site into page
44
+ * variants — international-site: one variant per language — answers
45
+ * the `link-resolve` composition point with each page's variant and
46
+ * variant group ({@link LinkPageVariant}). {@link SiteLinkIndex.forPage}
47
+ * then maps every page reference onto the target's sibling in the
48
+ * RENDERING page's variant, so a German page's links go to German pages
49
+ * wherever the link lives (module HTML, rich text, link fields, link
50
+ * lists, nav-menu sets, chrome). A target with no sibling in that
51
+ * variant is a loud failure (`link-variant-missing:page/<id>:<variant>`),
52
+ * never a silent link into another language.
53
+ *
43
54
  * Seams for the later rollout steps:
44
- * - D (locale composition point): {@link SiteLinkIndex.forPage} is the
45
- * per-rendering-page hook. A `link-resolve` contribution maps the
46
- * target to the rendering page's same-locale sibling there; nothing
47
- * in the engine or composer changes.
48
55
  * - E (data migration): {@link mapLinkRefsDeep} / the canonicaliser's
49
56
  * pure core walk the same shapes the migration rewrites.
50
57
  * - F (reference integrity): {@link RecordingLinkResolver.observed}
@@ -104,6 +111,21 @@ const QUERY_SRC = "[A-Za-z0-9_\\-.~%=&;+/:@!$()*]{1,512}";
104
111
  // Fragment: what an HTML id / URL fragment realistically carries.
105
112
  const ANCHOR_SRC = "[A-Za-z0-9_\\-.~%:]{1,256}";
106
113
  const VARIANT_SRC = "[a-z][a-z0-9-]{0,63}";
114
+
115
+ /**
116
+ * Whole-string tests for the query, anchor and media-variant parts of a
117
+ * reference — built from the grammar's own sources, so a writer that
118
+ * validates author input with them (write-time canonicalisation, #724
119
+ * rollout C) accepts exactly what {@link formatLinkRef} accepts and a bad
120
+ * suffix is a validation issue, never a thrown error.
121
+ */
122
+ export const LINK_REF_PART_PATTERNS: Readonly<{ query: RegExp; anchor: RegExp; variant: RegExp }> =
123
+ {
124
+ query: new RegExp(`^${QUERY_SRC}$`),
125
+ anchor: new RegExp(`^${ANCHOR_SRC}$`),
126
+ variant: new RegExp(`^${VARIANT_SRC}$`),
127
+ };
128
+
107
129
  // `caelo:` is a scheme only where a scheme can start: `xcaelo:page/…`
108
130
  // is some other scheme's text, not a reference.
109
131
  const REF_START_SRC = "(?<![A-Za-z0-9+.\\-])caelo:";
@@ -312,11 +334,20 @@ export type LinkResolution =
312
334
  /**
313
335
  * The target exists but is not published, so a deploy build will not
314
336
  * contain it. Rendered with its URL; the caller applies the
315
- * environment's policy ({@link linkIssueSeverity}).
337
+ * environment's policy ({@link linkIssueSeverity}). `pageId` is the
338
+ * page the URL points at — the referenced page, or its sibling in the
339
+ * rendering page's variant.
316
340
  */
317
- | { readonly status: "unpublished"; readonly url: string }
341
+ | { readonly status: "unpublished"; readonly url: string; readonly pageId: string }
318
342
  /** No live target (never existed, deleted, another chat's branch). */
319
- | { readonly status: "missing" };
343
+ | { readonly status: "missing" }
344
+ /**
345
+ * The target exists, but has no sibling in the rendering page's
346
+ * variant (a German page linking to a page that has no German
347
+ * version). Not rendered — linking into another variant instead is
348
+ * exactly the silent fallback CLAUDE.md §2 forbids.
349
+ */
350
+ | { readonly status: "no-variant"; readonly variant: string };
320
351
 
321
352
  /**
322
353
  * Turns references into URLs for ONE rendering page. Synchronous: the
@@ -354,14 +385,25 @@ export const LINK_TARGET_MISSING = "link-target-missing";
354
385
  export const LINK_RESOLVER_UNAVAILABLE = "link-resolver-unavailable";
355
386
 
356
387
  /**
357
- * True for every link failure marker: a missing target, a malformed
358
- * reference and a render without a resolver. The static generator's
388
+ * `missingSlots` marker for a page reference whose target has no sibling
389
+ * in the rendering page's variant:
390
+ * `link-variant-missing:page/<targetId>:<variant>` (variant = the
391
+ * rendering page's, e.g. a locale code). Blocks a deploy like a missing
392
+ * target.
393
+ */
394
+ export const LINK_VARIANT_MISSING = "link-variant-missing";
395
+
396
+ /**
397
+ * True for every link failure marker: a missing target, a target with
398
+ * no version in the rendering page's variant, a malformed reference and
399
+ * a render without a resolver. The static generator's
359
400
  * render gate and the nested-module echo filter both key on this, so a
360
401
  * new marker added here blocks a deploy everywhere at once.
361
402
  */
362
403
  export function isLinkFailureMarker(marker: string): boolean {
363
404
  return (
364
405
  marker.startsWith(`${LINK_TARGET_MISSING}:`) ||
406
+ marker.startsWith(`${LINK_VARIANT_MISSING}:`) ||
365
407
  marker.startsWith(`${LINK_REF_MALFORMED}:`) ||
366
408
  marker.startsWith(`${LINK_RESOLVER_UNAVAILABLE}:`)
367
409
  );
@@ -400,6 +442,10 @@ export function resolveLinkRefsInHtml(
400
442
  missing.push(`${LINK_TARGET_MISSING}:${linkRefTargetKey(ref)}`);
401
443
  return raw;
402
444
  }
445
+ if (r.status === "no-variant") {
446
+ missing.push(`${LINK_VARIANT_MISSING}:${linkRefTargetKey(ref)}:${r.variant}`);
447
+ return raw;
448
+ }
403
449
  return r.url;
404
450
  });
405
451
  }
@@ -409,13 +455,15 @@ export function resolveLinkRefsInHtml(
409
455
  * cleanly (maintainer decision on #724): a missing target blocks
410
456
  * everywhere; an unpublished one is a warning at Stage and blocks
411
457
  * Publish live / promote. The editor preview shows drafts, so nothing
412
- * there is an issue beyond a missing target.
458
+ * there is an issue beyond a missing target. A target without a sibling
459
+ * in the rendering page's variant counts as missing: there is no page
460
+ * the link could go to.
413
461
  */
414
462
  export function linkIssueSeverity(
415
463
  env: LinkRenderEnv,
416
464
  status: LinkResolution["status"],
417
465
  ): "block" | "warning" | null {
418
- if (status === "missing") return "block";
466
+ if (status === "missing" || status === "no-variant") return "block";
419
467
  if (status === "unpublished") {
420
468
  if (env === "production") return "block";
421
469
  if (env === "staging") return "warning";
@@ -442,6 +490,17 @@ export interface LinkIndexMedia {
442
490
  readonly slug: string;
443
491
  }
444
492
 
493
+ /**
494
+ * One page's place in a plugin's page-variant partition (the
495
+ * `link-resolve` composition point): its `variant` (international-site:
496
+ * the locale code) and the `group` of pages that are versions of each
497
+ * other (null = the page has no versions besides itself).
498
+ */
499
+ export interface LinkPageVariant {
500
+ readonly variant: string;
501
+ readonly group: string | null;
502
+ }
503
+
445
504
  /** What a render-wide link index is built from. */
446
505
  export interface SiteLinkIndexInput {
447
506
  /** Every live (non-deleted) page visible to the render's scope, drafts included. */
@@ -455,6 +514,14 @@ export interface SiteLinkIndexInput {
455
514
  * internal link, and byte-identical with the canonical (#590).
456
515
  */
457
516
  readonly pageUrlStyle: PageUrlStyle;
517
+ /**
518
+ * The `link-resolve` composition point's answer: page id → its variant
519
+ * and variant group. Absent or empty = no plugin partitions the site,
520
+ * every page reference resolves to the referenced page. A page missing
521
+ * from a non-empty map is one no plugin has an opinion about: links
522
+ * FROM it resolve as referenced, and a link TO it is not remapped.
523
+ */
524
+ readonly pageVariants?: ReadonlyMap<string, LinkPageVariant>;
458
525
  }
459
526
 
460
527
  /**
@@ -464,10 +531,11 @@ export interface SiteLinkIndexInput {
464
531
  export interface SiteLinkIndex {
465
532
  readonly env: LinkRenderEnv;
466
533
  /**
467
- * The resolver for rendering page `renderingPageId`. Today every page
468
- * gets the same answers; rollout D's `link-resolve` composition point
469
- * hooks in here to map a target onto the rendering page's locale.
470
- * Each call returns a fresh {@link RecordingLinkResolver}.
534
+ * The resolver for rendering page `renderingPageId`: page references
535
+ * resolve to the target's sibling in the rendering page's variant
536
+ * ({@link SiteLinkIndexInput.pageVariants}); null = a render outside
537
+ * any page, which maps nothing. Each call returns a fresh
538
+ * {@link RecordingLinkResolver}.
471
539
  */
472
540
  forPage(renderingPageId: string | null): RecordingLinkResolver;
473
541
  /** The page's public path (no style applied), or null when unknown. */
@@ -504,29 +572,59 @@ function pageRefUrl(ref: PageLinkRef, path: string, style: PageUrlStyle): string
504
572
  export function createSiteLinkIndex(input: SiteLinkIndexInput): SiteLinkIndex {
505
573
  const pages = new Map(input.pages.map((p) => [p.id, p]));
506
574
  const media = new Map(input.media.map((m) => [m.id, m]));
507
- const resolve = (ref: LinkRef): LinkResolution => {
575
+ const variants = input.pageVariants ?? new Map<string, LinkPageVariant>();
576
+ // group → variant → page: the sibling lookup. Only pages the index
577
+ // knows count — a sibling deleted or on another chat's branch is no
578
+ // sibling.
579
+ const siblings = new Map<string, Map<string, string>>();
580
+ for (const [pageId, v] of variants) {
581
+ if (v.group === null || !pages.has(pageId)) continue;
582
+ const members = siblings.get(v.group) ?? new Map<string, string>();
583
+ const clash = members.get(v.variant);
584
+ if (clash !== undefined) {
585
+ // Two versions of one page in one variant: a link could go to
586
+ // either, and picking one silently hides the broken partition.
587
+ throw new Error(
588
+ `link index: pages ${clash} and ${pageId} are both the "${v.variant}" version in variant group ${v.group}. A group holds at most one page per variant — unlink one of them from the group.`,
589
+ );
590
+ }
591
+ members.set(v.variant, pageId);
592
+ siblings.set(v.group, members);
593
+ }
594
+ /** The page a link to `targetId` goes to when rendered in `variant`. */
595
+ const pageInVariant = (targetId: string, variant: string | null): string | null => {
596
+ const target = variants.get(targetId);
597
+ if (variant === null || target === undefined || target.variant === variant) return targetId;
598
+ if (target.group === null) return null;
599
+ return siblings.get(target.group)?.get(variant) ?? null;
600
+ };
601
+ const resolve = (ref: LinkRef, variant: string | null): LinkResolution => {
508
602
  if (ref.kind === "media") {
509
603
  const asset = media.get(ref.mediaId);
510
604
  if (!asset) return { status: "missing" };
511
605
  return { status: "resolved", url: buildMediaUrl(asset.slug, ref.variant ?? "orig") };
512
606
  }
513
- const page = pages.get(ref.pageId);
514
- if (!page) return { status: "missing" };
607
+ if (!pages.has(ref.pageId)) return { status: "missing" };
608
+ const pageId = pageInVariant(ref.pageId, variant);
609
+ const page = pageId === null ? undefined : pages.get(pageId);
610
+ if (pageId === null || !page) return { status: "no-variant", variant: variant as string };
515
611
  const url = pageRefUrl(ref, page.path, input.pageUrlStyle);
516
612
  if (input.env !== "preview" && page.status !== "published") {
517
- return { status: "unpublished", url };
613
+ return { status: "unpublished", url, pageId };
518
614
  }
519
615
  return { status: "resolved", url };
520
616
  };
521
617
  return {
522
618
  env: input.env,
523
619
  pathOf: (pageId) => pages.get(pageId)?.path ?? null,
524
- forPage: () => {
620
+ forPage: (renderingPageId) => {
525
621
  const observed: ObservedLinkResolution[] = [];
622
+ const variant =
623
+ renderingPageId === null ? null : (variants.get(renderingPageId)?.variant ?? null);
526
624
  return {
527
625
  observed,
528
626
  resolve(ref) {
529
- const resolution = resolve(ref);
627
+ const resolution = resolve(ref, variant);
530
628
  observed.push({ ref, resolution });
531
629
  return resolution;
532
630
  },
package/src/media.ts CHANGED
@@ -227,9 +227,25 @@ export function extractMediaRefs(html: string): MediaRef[] {
227
227
  seen.add(key);
228
228
  out.push({ ref, isSlug, variant });
229
229
  }
230
+ // Epic #724 — stored references `caelo:media/<id>[/<variant>]` name the
231
+ // asset by id, like the legacy form, so usage tracking and the delete
232
+ // guards keep seeing embeds after write-time canonicalisation. (The
233
+ // grammar lives in link-refs.ts, which imports this module; the pattern
234
+ // is repeated here instead of importing back.)
235
+ for (const m of html.matchAll(mediaRefPattern)) {
236
+ const ref = m[1] as string;
237
+ const variant = m[2] ?? "orig";
238
+ const key = `i:${ref}/${variant}`;
239
+ if (seen.has(key)) continue;
240
+ seen.add(key);
241
+ out.push({ ref, isSlug: false, variant });
242
+ }
230
243
  return out;
231
244
  }
232
245
 
246
+ const mediaRefPattern =
247
+ /caelo:media\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/([a-z][a-z0-9-]{0,63}))?/g;
248
+
233
249
  // ---------------------------------------------------------------------
234
250
  // Zod schemas — exposed at the Query-API boundary.
235
251
  // ---------------------------------------------------------------------
@@ -341,11 +341,25 @@ function renderPartial(
341
341
  values: Readonly<Record<string, unknown>>,
342
342
  ctx: RenderContext,
343
343
  ): string | undefined {
344
+ const hit = nestedRefForPartial(key, fieldByName, values);
345
+ if (!hit) return undefined;
346
+ return renderInner(hit.ref.moduleId, hit.ref.contentInstanceId, descend(ctx, hit.step));
347
+ }
348
+
349
+ /**
350
+ * The nested ref behind partial `key`, with its field trail step
351
+ * (`name` or `name[i]`); `undefined` when the key names no well-formed
352
+ * ref. The one place that maps the engine's partial keys to values.
353
+ */
354
+ function nestedRefForPartial(
355
+ key: string,
356
+ fieldByName: ReadonlyMap<string, TemplateField>,
357
+ values: Readonly<Record<string, unknown>>,
358
+ ): { readonly ref: NestedRefValue; readonly field: string; readonly step: string } | undefined {
344
359
  const single = fieldByName.get(key);
345
360
  if (single?.kind === "module") {
346
361
  const ref = values[key];
347
- if (!isNestedRef(ref)) return undefined;
348
- return renderInner(ref.moduleId, ref.contentInstanceId, descend(ctx, key));
362
+ return isNestedRef(ref) ? { ref, field: key, step: key } : undefined;
349
363
  }
350
364
  const sep = key.lastIndexOf("__");
351
365
  const index = key.slice(sep + 2);
@@ -357,8 +371,49 @@ function renderPartial(
357
371
  if (!Array.isArray(raw)) return undefined;
358
372
  const i = Number(index);
359
373
  const el = raw[i];
360
- if (!isNestedRef(el)) return undefined;
361
- return renderInner(el.moduleId, el.contentInstanceId, descend(ctx, `${name}[${i}]`));
374
+ return isNestedRef(el) ? { ref: el, field: name, step: `${name}[${i}]` } : undefined;
375
+ }
376
+
377
+ /** A nested ref a module's template dispatches (see {@link dispatchedNestedRefs}). */
378
+ export interface DispatchedNestedRef extends NestedRefValue {
379
+ /** The `module` / `module-list` field holding it. */
380
+ readonly field: string;
381
+ }
382
+
383
+ /**
384
+ * Epic #724 (rollout F) — the nested refs that render when this module
385
+ * renders with these values: exactly the refs {@link renderPlacedModule}
386
+ * recurses into. Rendering is lazy (a declared `module` / `module-list`
387
+ * field the HTML never dispatches with `{{>name}}` / `{{#name}}` is not
388
+ * rendered), so a checker that walks every declared field would report
389
+ * links that never ship. This asks the template engine itself which
390
+ * partials it dispatches, so it follows the grammar exactly.
391
+ *
392
+ * @returns one entry per dispatched ref, in render order, deduplicated.
393
+ */
394
+ export function dispatchedNestedRefs(
395
+ html: string,
396
+ fields: readonly TemplateField[],
397
+ values: Readonly<Record<string, unknown>>,
398
+ ): DispatchedNestedRef[] {
399
+ const fieldByName = new Map(fields.map((f) => [f.name, f]));
400
+ const out: DispatchedNestedRef[] = [];
401
+ const asked = new Set<string>();
402
+ renderTemplate({
403
+ html,
404
+ fields,
405
+ contentValues: values,
406
+ partials: (key) => {
407
+ const hit = nestedRefForPartial(key, fieldByName, values);
408
+ if (!hit) return undefined;
409
+ if (!asked.has(key)) {
410
+ asked.add(key);
411
+ out.push({ ...hit.ref, field: hit.field });
412
+ }
413
+ return "";
414
+ },
415
+ });
416
+ return out;
362
417
  }
363
418
 
364
419
  /** The context for rendering the nested module at `step` under ctx's module. */
@@ -918,8 +918,16 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
918
918
  jsParts.push(NAV_TOGGLE_JS);
919
919
  }
920
920
 
921
- // 3. Render the layout HTML, substituting all named slots.
922
- const replaced = applySlotReplacements(input.layoutHtml, {
921
+ // 3. Render the layout HTML, substituting all named slots. The
922
+ // layout's own markup (the page frame around the blocks) stores
923
+ // internal links as references too (#724 rollout C canonicalises
924
+ // layouts.create / .update), so they resolve here like module HTML
925
+ // — BEFORE the slots are filled, so already-resolved block HTML is
926
+ // not scanned twice. A failure lands in `missingSlots` (the
927
+ // preview's missing-content surface; the static build refuses it).
928
+ const layoutLinkMarkers: string[] = [];
929
+ const layoutHtml = resolveLinkRefsInHtml(input.layoutHtml, input.linkResolver, layoutLinkMarkers);
930
+ const replaced = applySlotReplacements(layoutHtml, {
923
931
  contentByName: layoutContentByName,
924
932
  });
925
933
  let html = replaced.html;
@@ -971,6 +979,7 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
971
979
  ]);
972
980
  const missingSet = new Set<string>([...renderedTemplate.missingSlots, ...replaced.missingSlots]);
973
981
  for (const name of replacedSet) missingSet.delete(name);
982
+ for (const marker of layoutLinkMarkers) missingSet.add(marker);
974
983
  return {
975
984
  html,
976
985
  replacedSlots: [...replacedSet],
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Regression: saving the Typography tab posts every sub-field of a named
4
+ * style as its own `typography.<style>.<sub>` key, with lineHeight /
5
+ * fontWeight coerced to numbers. Numbers used to skip the composite wrap
6
+ * (so `$value` became a bare `1.6`) and sibling sub-fields overwrote each
7
+ * other on the shared composite path.
8
+ */
9
+ import { describe, expect, it } from "bun:test";
10
+ import { normalizeTokens } from "./theme-normalize.js";
11
+ import { applyDtcgWrites, themeDocument } from "./themes.js";
12
+
13
+ describe("normalizeTokens typography sub-fields", () => {
14
+ it("wraps numeric sub-field values into the composite", () => {
15
+ const r = normalizeTokens({ "typography.body.lineHeight": 1.6 });
16
+ expect(r.set["typography.body"]).toEqual({ lineHeight: 1.6 });
17
+ });
18
+
19
+ it("merges several sub-fields of one style into one composite", () => {
20
+ const r = normalizeTokens({
21
+ fontHeading: "Inter, sans-serif",
22
+ "typography.heading.fontSize": "3rem",
23
+ "typography.heading.fontWeight": 650,
24
+ "typography.heading.lineHeight": 1.08,
25
+ });
26
+ expect(r.set["typography.heading"]).toEqual({
27
+ fontFamily: "Inter, sans-serif",
28
+ fontSize: "3rem",
29
+ fontWeight: 650,
30
+ lineHeight: 1.08,
31
+ });
32
+ expect(r.canonicalPaths).toEqual(["typography.heading"]);
33
+ });
34
+
35
+ it("produces a valid document when applied over an existing theme", () => {
36
+ const current = {
37
+ typography: {
38
+ body: { $type: "typography", $value: { fontFamily: "Inter", fontSize: "1rem" } },
39
+ },
40
+ };
41
+ const r = normalizeTokens({
42
+ "typography.body.fontWeight": 400,
43
+ "typography.body.lineHeight": 1.6,
44
+ });
45
+ const next = applyDtcgWrites(current as never, r.set, r.types);
46
+ expect(themeDocument.safeParse(next).success).toBe(true);
47
+ expect((next.typography as Record<string, { $value: unknown }>).body?.$value).toEqual({
48
+ fontFamily: "Inter",
49
+ fontSize: "1rem",
50
+ fontWeight: 400,
51
+ lineHeight: 1.6,
52
+ });
53
+ });
54
+ });