@caelo-cms/shared 0.10.68 → 0.10.69

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.
@@ -244,6 +244,60 @@ export function outermostPatterns(roots: readonly MarkupNode[]): FoundPattern[]
244
244
  return found;
245
245
  }
246
246
 
247
+ /**
248
+ * Copies of one pattern side by side at which free markup reads as a
249
+ * hand-written list (#1068): three cards in a row are a list of items, two
250
+ * may still be a deliberate pair (a before/after, a two-column split).
251
+ */
252
+ export const MIN_SIBLING_REPEATS = 3;
253
+
254
+ /** A pattern repeated side by side in one piece of markup. */
255
+ export interface RepeatedSiblings {
256
+ /** The first copy. */
257
+ readonly node: MarkupNode;
258
+ readonly shape: SubtreeShape;
259
+ /** How many siblings share its signature. */
260
+ readonly count: number;
261
+ }
262
+
263
+ /**
264
+ * Patterns that repeat {@link MIN_SIBLING_REPEATS}+ times as siblings: the
265
+ * same component written out once per item (83 tool cards in one richtext
266
+ * value) instead of a list field rendering one module per item. Roots of
267
+ * the forest that share a parent are siblings too (the free regions of one
268
+ * rendered module). A repeated set is reported once and not searched
269
+ * inside; anything else is searched all the way down. Nested module roots
270
+ * are skipped: their own module owns them.
271
+ */
272
+ export function repeatedSiblingPatterns(roots: readonly MarkupNode[]): RepeatedSiblings[] {
273
+ const shapes = new Map<MarkupNode, SubtreeShape>();
274
+ for (const r of roots) if (r.moduleId === null) shapeTree(r, shapes);
275
+ const found: RepeatedSiblings[] = [];
276
+ const visitSiblings = (siblings: readonly MarkupNode[]) => {
277
+ const bySig = new Map<string, MarkupNode[]>();
278
+ for (const n of siblings) {
279
+ if (n.moduleId !== null) continue;
280
+ const shape = shapes.get(n);
281
+ if (!shape || !isPattern(shape)) continue;
282
+ bySig.set(shape.signature, [...(bySig.get(shape.signature) ?? []), n]);
283
+ }
284
+ const reported = new Set<MarkupNode>();
285
+ for (const copies of bySig.values()) {
286
+ if (copies.length < MIN_SIBLING_REPEATS) continue;
287
+ const first = copies[0] as MarkupNode;
288
+ found.push({ node: first, shape: shapes.get(first) as SubtreeShape, count: copies.length });
289
+ for (const c of copies) reported.add(c);
290
+ }
291
+ for (const n of siblings) {
292
+ if (n.moduleId === null && !reported.has(n)) visitSiblings(n.children);
293
+ }
294
+ };
295
+ const byParent = new Map<MarkupNode | null, MarkupNode[]>();
296
+ for (const r of roots) byParent.set(r.parent, [...(byParent.get(r.parent) ?? []), r]);
297
+ for (const group of byParent.values()) visitSiblings(group);
298
+ return found;
299
+ }
300
+
247
301
  /** The shape of a whole module template (its first element), or null. */
248
302
  export function templateShape(templateHtml: string): SubtreeShape | null {
249
303
  const root = parseMarkup(stripMustache(templateHtml))[0];
@@ -211,6 +211,83 @@ describe("renderModuleWithContent — nested module slots", () => {
211
211
  });
212
212
  });
213
213
 
214
+ describe("renderModuleWithContent — content braces through nesting (#1053)", () => {
215
+ it("a nested richtext value with {{Page URL}} reaches the page literally", () => {
216
+ const resolver = buildResolver(
217
+ [
218
+ {
219
+ moduleId: PARENT_MOD_ID,
220
+ slug: "parent",
221
+ html: "<section>{{>child}}</section>",
222
+ css: "",
223
+ js: "",
224
+ fields: [{ name: "child", kind: "module" }],
225
+ },
226
+ {
227
+ moduleId: CHILD_MOD_ID,
228
+ slug: "child",
229
+ html: "<div>{{{body_html}}}</div>",
230
+ css: "",
231
+ js: "",
232
+ fields: [{ name: "body_html", kind: "richtext" }],
233
+ },
234
+ ],
235
+ [
236
+ {
237
+ id: PARENT_CI_ID,
238
+ moduleId: PARENT_MOD_ID,
239
+ values: { child: { moduleId: CHILD_MOD_ID, contentInstanceId: CHILD_CI_ID } },
240
+ deletedAt: null,
241
+ },
242
+ {
243
+ id: CHILD_CI_ID,
244
+ moduleId: CHILD_MOD_ID,
245
+ values: { body_html: "<code>link_en.href = '{{Page URL}}';</code>" },
246
+ deletedAt: null,
247
+ },
248
+ ],
249
+ );
250
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
251
+ expect(r.html).toBe(
252
+ `<section><div ${tag(CHILD_MOD_ID)}><code>link_en.href = '{{Page URL}}';</code></div></section>`,
253
+ );
254
+ expect(r.missingSlots).toEqual([]);
255
+ });
256
+ });
257
+
258
+ describe("renderModuleWithContent — empty optional module field (#1041)", () => {
259
+ it("an unset or null `module` field renders an empty slot without a marker", () => {
260
+ for (const values of [{}, { after: null }]) {
261
+ const resolver = buildResolver(
262
+ [
263
+ {
264
+ moduleId: PARENT_MOD_ID,
265
+ slug: "card-grid",
266
+ html: "<section><h2>{{title}}</h2>{{>after}}</section>",
267
+ css: "",
268
+ js: "",
269
+ fields: [
270
+ { name: "title", kind: "text" },
271
+ { name: "after", kind: "module" },
272
+ ],
273
+ },
274
+ ],
275
+ [
276
+ {
277
+ id: PARENT_CI_ID,
278
+ moduleId: PARENT_MOD_ID,
279
+ values: { title: "T", ...values },
280
+ deletedAt: null,
281
+ },
282
+ ],
283
+ );
284
+ const r = renderModuleWithContent(PARENT_MOD_ID, PARENT_CI_ID, resolver);
285
+ expect(r.html).toBe("<section><h2>T</h2></section>");
286
+ expect(r.missingSlots).toEqual([]);
287
+ }
288
+ });
289
+ });
290
+
214
291
  describe("renderModuleWithContent — list-of-primitive slots", () => {
215
292
  it("text-list iterates {{.}} per string element", () => {
216
293
  const resolver = buildResolver(
package/src/seo.test.ts CHANGED
@@ -208,6 +208,56 @@ describe("injectSeoIntoHead", () => {
208
208
  expect(out).toContain("<title>PageTitle</title>\n</HEAD>");
209
209
  expect(out).toContain("<svg><title>IconTitle</title></svg>");
210
210
  });
211
+
212
+ // #1017 — the block goes early, before the inline theme CSS.
213
+ const THEME = '<style data-source="theme">body{}</style><link rel="stylesheet" href="/a.css">';
214
+
215
+ it("goes after charset and viewport, before the first style and stylesheet", () => {
216
+ const html = `<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">${THEME}<title>Old</title></head><body><svg><title>Icon</title></svg></body></html>`;
217
+ const out = injectSeoIntoHead(html, "<title>Page</title>");
218
+ expect(out).toStartWith(
219
+ '<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">\n<title>Page</title>\n<style',
220
+ );
221
+ expect(out.indexOf("<title>Page</title>")).toBeLessThan(out.indexOf("<style"));
222
+ expect(out.indexOf("<title>Page</title>")).toBeLessThan(out.indexOf('<link rel="stylesheet"'));
223
+ expect(out).not.toContain("Old");
224
+ expect(out).toContain("<svg><title>Icon</title></svg>");
225
+ });
226
+
227
+ it("goes after the charset when there is no viewport", () => {
228
+ const out = injectSeoIntoHead(
229
+ `<html><head><meta charset="utf-8">${THEME}</head><body></body></html>`,
230
+ "<title>Page</title>",
231
+ );
232
+ expect(out).toStartWith('<html><head><meta charset="utf-8">\n<title>Page</title>\n<style');
233
+ });
234
+
235
+ it("goes right after <head> when there is neither", () => {
236
+ const out = injectSeoIntoHead(
237
+ `<html><head lang="en">${THEME}</head><body></body></html>`,
238
+ "<title>Page</title>",
239
+ );
240
+ expect(out).toStartWith('<html><head lang="en">\n<title>Page</title>\n<style');
241
+ });
242
+
243
+ it("ignores a viewport-looking meta in the body", () => {
244
+ const out = injectSeoIntoHead(
245
+ '<html><head><meta charset="utf-8"></head><body><meta name="viewport"></body></html>',
246
+ "<title>Page</title>",
247
+ );
248
+ expect(out).toStartWith('<html><head><meta charset="utf-8">\n<title>Page</title>');
249
+ });
250
+
251
+ it("ignores meta tags inside a comment or a script string", () => {
252
+ const script = `<script>const sample = '<meta name="viewport" content="width=device-width">';</script>`;
253
+ const comment = '<!-- <meta name="viewport" content="x"> -->';
254
+ const html = `<html><head><meta charset="utf-8">${comment}${script}${THEME}</head><body></body></html>`;
255
+ const out = injectSeoIntoHead(html, "<title>Page</title>");
256
+ expect(out).toStartWith('<html><head><meta charset="utf-8">\n<title>Page</title>\n<!--');
257
+ // Comment and script come through byte-for-byte, nothing inserted in them.
258
+ expect(out).toContain(comment);
259
+ expect(out).toContain(script);
260
+ });
211
261
  });
212
262
 
213
263
  describe("schemas", () => {
package/src/seo.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { z } from "zod";
11
+ import { blankNonMarkup } from "./html-blank.js";
11
12
  import { trimSlashes } from "./url.js";
12
13
 
13
14
  export const CHANGEFREQ_VALUES = [
@@ -333,11 +334,18 @@ export function renderSeoHead(input: SeoMetaInput): string {
333
334
  }
334
335
 
335
336
  /**
336
- * Inject `renderSeoHead`'s output just before `</head>`. Replaces any
337
- * existing `<title>` so the page-level title wins over a layout-
338
- * supplied default. Pre-existing meta tags from the layout are left
339
- * alone — authors can still drop e.g. `<meta name="theme-color">` in
340
- * the layout HTML.
337
+ * Inject `renderSeoHead`'s output early in `<head>`: right after the last
338
+ * `<meta charset>` / `<meta name="viewport">`, or right after `<head>`
339
+ * when the layout has neither (#1017). Layouts inline the theme CSS, so a
340
+ * block appended before `</head>` put the title tens of kilobytes into
341
+ * the page, past what byte-limited crawlers and link-preview fetchers
342
+ * read; charset and viewport stay first, as the HTML spec wants the
343
+ * charset in the first 1024 bytes.
344
+ *
345
+ * Replaces any existing `<title>` so the page-level title wins over a
346
+ * layout-supplied default. Pre-existing meta tags from the layout are
347
+ * left alone — authors can still drop e.g. `<meta name="theme-color">`
348
+ * in the layout HTML.
341
349
  */
342
350
  export function injectSeoIntoHead(html: string, headBlock: string): string {
343
351
  const headCloseMatch = /<\/head\s*>/i.exec(html);
@@ -345,11 +353,35 @@ export function injectSeoIntoHead(html: string, headBlock: string): string {
345
353
  const headEnd = headCloseMatch.index;
346
354
  // Strip a layout-supplied <title> inside <head> only (never an SVG
347
355
  // <title> in <body>); we re-emit the page title in headBlock.
348
- const beforeClose = html.slice(0, headEnd).replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
349
- const afterClose = html.slice(headEnd);
350
- return `${beforeClose}${headBlock}\n${afterClose}`;
356
+ const head = html.slice(0, headEnd).replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
357
+ const at = seoInsertionPoint(head);
358
+ return `${head.slice(0, at)}\n${headBlock}\n${head.slice(at)}${html.slice(headEnd)}`;
351
359
  }
352
360
  // No closing head tag — prepend the block to the document.
353
361
  const titleStripped = html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/i, "");
354
362
  return `${headBlock}\n${titleStripped}`;
355
363
  }
364
+
365
+ /**
366
+ * Where the SEO block goes inside `head` (the document up to `</head>`):
367
+ * after the last charset / viewport meta of the head, else after the
368
+ * `<head>` tag, else (a document with `</head>` but no `<head>` tag) at
369
+ * the end, where it always went.
370
+ */
371
+ function seoInsertionPoint(head: string): number {
372
+ // Only real elements count: a meta tag inside a comment or a script
373
+ // string would otherwise put the block inside that comment or string.
374
+ const markup = blankNonMarkup(head);
375
+ const open = /<head\b[^>]*>/i.exec(markup);
376
+ if (!open) return head.length;
377
+ const start = open.index + open[0].length;
378
+ let at = start;
379
+ const metaRe = /<meta\b[^>]*>/gi;
380
+ metaRe.lastIndex = start;
381
+ for (let m = metaRe.exec(markup); m !== null; m = metaRe.exec(markup)) {
382
+ if (/\bcharset\s*=/i.test(m[0]) || /\bname\s*=\s*["']?viewport\b/i.test(m[0])) {
383
+ at = m.index + m[0].length;
384
+ }
385
+ }
386
+ return at;
387
+ }
@@ -269,6 +269,90 @@ describe("renderTemplate — single nested {{>name}}", () => {
269
269
  });
270
270
  });
271
271
 
272
+ describe("renderTemplate — empty single module field (#1041)", () => {
273
+ const moduleField: TemplateField = { name: "after", kind: "module" };
274
+
275
+ it("an unset module field renders empty and is not a failure", () => {
276
+ const r = renderTemplate({
277
+ html: "<aside>{{>after}}</aside>",
278
+ fields: [moduleField],
279
+ contentValues: {},
280
+ partials: {},
281
+ });
282
+ expect(r.html).toBe("<aside></aside>");
283
+ expect(r.missingSlots).toEqual([]);
284
+ });
285
+
286
+ it("an explicit null module field renders empty and is not a failure", () => {
287
+ const r = renderTemplate({
288
+ html: "<aside>{{>after}}</aside>",
289
+ fields: [moduleField],
290
+ contentValues: { after: null },
291
+ partials: {},
292
+ });
293
+ expect(r.html).toBe("<aside></aside>");
294
+ expect(r.missingSlots).toEqual([]);
295
+ });
296
+
297
+ it("a present non-ref value is still module-ref-malformed", () => {
298
+ const r = renderTemplate({
299
+ html: "<aside>{{>after}}</aside>",
300
+ fields: [moduleField],
301
+ contentValues: { after: { moduleId: "m" } },
302
+ });
303
+ expect(r.missingSlots).toEqual(["module-ref-malformed:after"]);
304
+ });
305
+ });
306
+
307
+ describe("renderTemplate — literal braces in content are data (#1053)", () => {
308
+ it("a text-list item keeps a literal {{Page URL}}", () => {
309
+ const r = renderTemplate({
310
+ html: "<ul>{{#items}}<li>{{.}}</li>{{/items}}</ul>",
311
+ fields: [{ name: "items", kind: "text-list" }],
312
+ contentValues: { items: ["link.href = '{{Page URL}}';", "{{title}}"] },
313
+ });
314
+ expect(r.html).toBe("<ul><li>link.href = '{{Page URL}}';</li><li>{{title}}</li></ul>");
315
+ expect(r.missingSlots).toEqual([]);
316
+ });
317
+
318
+ it("a link-list label is never re-substituted as a placeholder", () => {
319
+ const r = renderTemplate({
320
+ html: '<nav>{{#nav}}<a href="{{href}}">{{label}}</a>{{/nav}}</nav>',
321
+ fields: [{ name: "nav", kind: "link-list" }],
322
+ contentValues: { nav: [{ label: "{{href}} {{Click URL}}", href: "/x" }] },
323
+ });
324
+ expect(r.html).toBe('<nav><a href="/x">{{href}} {{Click URL}}</a></nav>');
325
+ });
326
+
327
+ it("rendered nested module HTML is pasted verbatim, not parsed again", () => {
328
+ const r = renderTemplate({
329
+ html: "<section>{{>body}}</section>",
330
+ fields: [{ name: "body", kind: "module" }],
331
+ contentValues: { body: { moduleId: "m", contentInstanceId: "c" } },
332
+ partials: { body: "<code>{{Page URL}}</code>" },
333
+ });
334
+ expect(r.html).toBe("<section><code>{{Page URL}}</code></section>");
335
+ });
336
+
337
+ it("a top-level {{{body_html}}} value with braces still renders as written", () => {
338
+ const r = renderTemplate({
339
+ html: "<article>{{{body_html}}}</article>",
340
+ fields: [{ name: "body_html", kind: "richtext" }],
341
+ contentValues: { body_html: "<p><code>{{Page URL}}</code></p>" },
342
+ });
343
+ expect(r.html).toBe("<article><p><code>{{Page URL}}</code></p></article>");
344
+ });
345
+
346
+ it("a non-identifier tag in module HTML stays literal instead of rendering empty", () => {
347
+ const r = renderTemplate({
348
+ html: "<script>dataLayer.push({ url: '{{Page URL}}' })</script>",
349
+ fields: [],
350
+ });
351
+ expect(r.html).toBe("<script>dataLayer.push({ url: '{{Page URL}}' })</script>");
352
+ expect(r.missingSlots).toEqual([]);
353
+ });
354
+ });
355
+
272
356
  describe("renderTemplate — loud-raw on unknown sections (AC #3)", () => {
273
357
  it("unknown {{#unknown}}…{{/unknown}} leaves raw markers + tracks field-not-declared", () => {
274
358
  const r = renderTemplate({
@@ -187,6 +187,12 @@ const SECTION_RE =
187
187
  /\{\{\s*#\s*([a-z][a-z0-9_]*)\s*\}\}((?:(?!\{\{\s*\/\s*\1\s*\}\})[\s\S])*)\{\{\s*\/\s*\1\s*\}\}/g;
188
188
  const PARTIAL_RE = /\{\{\s*>\s*([a-z][a-z0-9_]*)\s*\}\}/g;
189
189
  const PRIMITIVE_RE = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
190
+ // A plain tag (no Mustache sigil) of any name, and the identifier test
191
+ // that tells a field placeholder from literal text such as `{{Page URL}}`.
192
+ // No `\s*` before the close: it would overlap `[^{}]*` (polynomial
193
+ // backtracking); the callback trims instead.
194
+ const NON_IDENTIFIER_TAG_RE = /\{\{\s*([^#^/>!&={}\s][^{}]*)\}\}/g;
195
+ const IDENTIFIER_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
190
196
 
191
197
  // Section-dispatch kinds (the engine's `{{#name}}` operand). Imported
192
198
  // from content.ts so new list-shaped kinds wire through automatically
@@ -284,6 +290,14 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
284
290
  // pattern into their HTML can't break loud-raw restore by
285
291
  // intercepting the sentinel for a known-key sentinel — they would
286
292
  // have to guess the UUID minted at render time.
293
+ //
294
+ // The same sentinels also carry DATA through the render (#1053): every
295
+ // value the engine pastes into the template before Mustache runs — a
296
+ // list item, a link label/href, a conditional's `{{.}}`, a plugin list
297
+ // value, a rendered nested module — is wrapped, so literal `{{…}}` in
298
+ // content (a GTM `{{Page URL}}` in a code example) is never parsed as a
299
+ // tag a second time. Values Mustache substitutes itself are never
300
+ // re-parsed, so only these pre-pasted ones need it.
287
301
  const sentinels = new Map<string, string>();
288
302
  const sentinelPrefix = `__CAELO_TPL_${globalThis.crypto.randomUUID()}_`;
289
303
  const mkSentinel = (original: string): string => {
@@ -380,15 +394,28 @@ export function renderTemplate(input: RenderTemplateInput): RenderTemplateOutput
380
394
  return mkSentinel(match);
381
395
  });
382
396
 
397
+ // 3b. A tag whose name is not a field identifier (`{{Page URL}}`, a
398
+ // Google Tag Manager variable in a snippet) can never be a
399
+ // placeholder — field names are snake_case. Mustache would look it
400
+ // up and silently render "", so it stays literal (#1053).
401
+ html = html.replace(NON_IDENTIFIER_TAG_RE, (match, name: string) =>
402
+ IDENTIFIER_RE.test(name.trim()) ? match : mkSentinel(match),
403
+ );
404
+
383
405
  // 4. Render. The view holds only lowercase keys; sentinels survive
384
406
  // untouched; sections + partials are already substituted.
385
407
  const rendered = Mustache.render(html, view);
386
408
 
387
- // 5. Restore loud-raw sentinels.
388
- let final = rendered;
389
- for (const [sentinel, original] of sentinels) {
390
- final = final.split(sentinel).join(original);
391
- }
409
+ // 5. Restore sentinels in one pass. A single replace never re-scans
410
+ // the text it inserts, so restored content cannot be mistaken for a
411
+ // sentinel (and a list of N items costs one scan, not N).
412
+ let final =
413
+ sentinels.size === 0
414
+ ? rendered
415
+ : rendered.replace(
416
+ new RegExp(`${sentinelPrefix}\\d+__`, "g"),
417
+ (key) => sentinels.get(key) ?? key,
418
+ );
392
419
 
393
420
  // 6. Resolve internal references (epic #724). Last, so values,
394
421
  // defaults and list items substituted above are covered; nested
@@ -417,7 +444,7 @@ function renderSection(
417
444
  // fields are looked up FIRST by construction, so a plugin can never
418
445
  // shadow something the module's author declared.
419
446
  const items = dataLists[name];
420
- if (items) return renderDataList(inner, items);
447
+ if (items) return renderDataList(inner, items, mkSentinel);
421
448
  // Declared by an installed plugin that is NOT running. Naming the
422
449
  // plugin turns "unknown field" (hunt for a typo) into "switch that
423
450
  // plugin back on", which is the actual fix.
@@ -430,13 +457,13 @@ function renderSection(
430
457
  return mkSentinel(match);
431
458
  }
432
459
  if (field.kind === "module-list") {
433
- return renderModuleList(name, field, cvs, partials, missing);
460
+ return renderModuleList(name, field, cvs, partials, missing, mkSentinel);
434
461
  }
435
462
  if (field.kind === "text-list") {
436
- return renderTextList(name, inner, field, cvs, missing);
463
+ return renderTextList(name, inner, field, cvs, missing, mkSentinel);
437
464
  }
438
465
  if (field.kind === "link-list") {
439
- return renderLinkList(name, inner, field, cvs, missing);
466
+ return renderLinkList(name, inner, field, cvs, missing, mkSentinel);
440
467
  }
441
468
  // Any NON-list field used as a `{{#name}}…{{/name}}` section is a
442
469
  // CONDITIONAL (Mustache semantics): render the inner block once when the
@@ -449,7 +476,7 @@ function renderSection(
449
476
  // partial pass); `{{.}}`/`{{item}}` are substituted here for parity with
450
477
  // the *-list renderers. An empty conditional is intentional, not
451
478
  // "missing" — nothing is pushed to `missing`.
452
- return renderConditionalSection(name, inner, field, cvs);
479
+ return renderConditionalSection(name, inner, field, cvs, mkSentinel);
453
480
  }
454
481
 
455
482
  /**
@@ -461,11 +488,14 @@ function renderConditionalSection(
461
488
  inner: string,
462
489
  field: TemplateField,
463
490
  cvs: Readonly<Record<string, unknown>>,
491
+ mkSentinel: (original: string) => string,
464
492
  ): string {
465
493
  const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
466
494
  if (!isPresentSectionValue(raw)) return "";
467
495
  const value = scalarSectionString(raw);
468
- return value.length > 0 ? inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => value) : inner;
496
+ return value.length > 0
497
+ ? inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => mkSentinel(value))
498
+ : inner;
469
499
  }
470
500
 
471
501
  /**
@@ -498,6 +528,7 @@ function renderTextList(
498
528
  field: TemplateField,
499
529
  cvs: Readonly<Record<string, unknown>>,
500
530
  missing: string[],
531
+ mkSentinel: (original: string) => string,
501
532
  ): string {
502
533
  const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
503
534
  if (!Array.isArray(raw)) return "";
@@ -510,7 +541,7 @@ function renderTextList(
510
541
  continue;
511
542
  }
512
543
  const value = String(el);
513
- parts.push(inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => value));
544
+ parts.push(inner.replace(/\{\{\s*(?:\.|item)\s*\}\}/g, () => mkSentinel(value)));
514
545
  }
515
546
  return parts.join("");
516
547
  }
@@ -529,13 +560,16 @@ function renderTextList(
529
560
  function renderDataList(
530
561
  inner: string,
531
562
  items: ReadonlyArray<Readonly<Record<string, string>>>,
563
+ mkSentinel: (original: string) => string,
532
564
  ): string {
533
565
  const parts: string[] = [];
534
566
  for (const item of items) {
535
567
  let rendered = inner;
536
568
  for (const [key, value] of Object.entries(item)) {
537
569
  const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
538
- rendered = rendered.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}`, "g"), () => value);
570
+ rendered = rendered.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}`, "g"), () =>
571
+ mkSentinel(value),
572
+ );
539
573
  }
540
574
  parts.push(rendered);
541
575
  }
@@ -548,6 +582,7 @@ function renderLinkList(
548
582
  field: TemplateField,
549
583
  cvs: Readonly<Record<string, unknown>>,
550
584
  missing: string[],
585
+ mkSentinel: (original: string) => string,
551
586
  ): string {
552
587
  const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
553
588
  if (!Array.isArray(raw)) return "";
@@ -566,7 +601,9 @@ function renderLinkList(
566
601
  }
567
602
  const { label, href } = el as { label: string; href: string };
568
603
  parts.push(
569
- inner.replace(/\{\{\s*label\s*\}\}/g, () => label).replace(/\{\{\s*href\s*\}\}/g, () => href),
604
+ inner
605
+ .replace(/\{\{\s*label\s*\}\}/g, () => mkSentinel(label))
606
+ .replace(/\{\{\s*href\s*\}\}/g, () => mkSentinel(href)),
570
607
  );
571
608
  }
572
609
  return parts.join("");
@@ -578,6 +615,7 @@ function renderModuleList(
578
615
  cvs: Readonly<Record<string, unknown>>,
579
616
  partials: PartialResolver,
580
617
  missing: string[],
618
+ mkSentinel: (original: string) => string,
581
619
  ): string {
582
620
  const raw = Object.hasOwn(cvs, name) ? cvs[name] : field.default;
583
621
  if (!Array.isArray(raw)) return "";
@@ -601,7 +639,7 @@ function renderModuleList(
601
639
  parts.push(`<!-- caelo:module-list ${name} needs recursive renderer (compose path) -->`);
602
640
  continue;
603
641
  }
604
- parts.push(partialHtml);
642
+ parts.push(mkSentinel(partialHtml));
605
643
  }
606
644
  return parts.join("");
607
645
  }
@@ -625,7 +663,14 @@ function renderPartialRef(
625
663
  missing.push(reason);
626
664
  return comment(reason);
627
665
  }
628
- const ref = cvs[name];
666
+ const ref = Object.hasOwn(cvs, name) ? cvs[name] : undefined;
667
+ // An unset or explicitly null single `module` field is an EMPTY slot,
668
+ // the same as an empty module-list / text-list: it renders nothing and
669
+ // is not a failure (#1041). The static generator refuses a Stage on any
670
+ // marker, so flagging "empty" here made an optional nested slot
671
+ // impossible to leave blank. A present value that is not a ref is still
672
+ // malformed and stays loud.
673
+ if (ref === undefined || ref === null) return "";
629
674
  if (!isNestedRef(ref)) {
630
675
  missing.push(`module-ref-malformed:${name}`);
631
676
  return comment(`module-ref-malformed ${name}`);
@@ -636,5 +681,5 @@ function renderPartialRef(
636
681
  missing.push(`${NESTED_RENDERER_UNAVAILABLE}:${name}`);
637
682
  return `<!-- caelo:module ${name} needs recursive renderer (compose path) -->`;
638
683
  }
639
- return partialHtml;
684
+ return mkSentinel(partialHtml);
640
685
  }
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.10.68";
25
+ export const CAELO_VERSION = "0.10.69";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this