@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.
- package/dist/html-blank.d.ts +11 -0
- package/dist/html-blank.d.ts.map +1 -0
- package/dist/html-blank.js +17 -0
- package/dist/html-blank.js.map +1 -0
- package/dist/link-canonicalise.d.ts +29 -4
- package/dist/link-canonicalise.d.ts.map +1 -1
- package/dist/link-canonicalise.js +96 -8
- package/dist/link-canonicalise.js.map +1 -1
- package/dist/markup-structure.d.ts +24 -0
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +52 -0
- package/dist/markup-structure.js.map +1 -1
- package/dist/seo.d.ts +12 -5
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +40 -8
- package/dist/seo.js.map +1 -1
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +54 -22
- package/dist/template-engine.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/html-blank.ts +21 -0
- package/src/link-canonicalise.test.ts +27 -0
- package/src/link-canonicalise.ts +100 -5
- package/src/markup-structure.test.ts +21 -0
- package/src/markup-structure.ts +54 -0
- package/src/nested-module-render.test.ts +77 -0
- package/src/seo.test.ts +50 -0
- package/src/seo.ts +40 -8
- package/src/template-engine.test.ts +84 -0
- package/src/template-engine.ts +62 -17
- package/src/version.ts +1 -1
package/src/markup-structure.ts
CHANGED
|
@@ -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
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* the
|
|
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
|
|
349
|
-
const
|
|
350
|
-
return `${
|
|
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({
|
package/src/template-engine.ts
CHANGED
|
@@ -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
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
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"), () =>
|
|
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
|
|
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
|
}
|