@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.
- package/dist/ai-tools.d.ts +30 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +26 -9
- package/dist/ai-tools.js.map +1 -1
- package/dist/bug-intake.d.ts +176 -0
- package/dist/bug-intake.d.ts.map +1 -0
- package/dist/bug-intake.js +200 -0
- package/dist/bug-intake.js.map +1 -0
- package/dist/content.js +7 -7
- package/dist/edit-markers.d.ts +49 -0
- package/dist/edit-markers.d.ts.map +1 -0
- package/dist/edit-markers.js +225 -0
- package/dist/edit-markers.js.map +1 -0
- 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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/link-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/page-log.d.ts +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/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 +29 -11
- package/src/bug-intake.test.ts +154 -0
- package/src/bug-intake.ts +256 -0
- package/src/content.ts +7 -7
- package/src/edit-markers.test.ts +111 -0
- package/src/edit-markers.ts +244 -0
- package/src/google-oauth.ts +27 -0
- package/src/index.ts +4 -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/preview-compose.ts +11 -2
- 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/link-refs.test.ts
CHANGED
|
@@ -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
|
-
*
|
|
358
|
-
*
|
|
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
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
* Each call returns a fresh
|
|
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
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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. */
|
package/src/preview-compose.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|