@jini-ai/cms 0.5.2 → 0.5.4
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/CHANGELOG.md +15 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- package/dist/forms/delete-submission.d.ts +38 -0
- package/dist/forms/delete-submission.js +25 -0
- package/dist/forms/duplicate-slug.d.ts +81 -0
- package/dist/forms/duplicate-slug.js +115 -0
- package/dist/forms/errors.d.ts +53 -0
- package/dist/forms/errors.js +25 -0
- package/dist/forms/express/handler.d.ts +59 -0
- package/dist/forms/express/handler.js +217 -0
- package/dist/forms/express/index.d.ts +2 -0
- package/dist/forms/express/index.js +2 -0
- package/dist/forms/fields-json.d.ts +14 -0
- package/dist/forms/fields-json.js +11 -0
- package/dist/forms/forms.d.ts +59 -0
- package/dist/forms/forms.js +213 -0
- package/dist/forms/html/authoring-adapter.d.ts +34 -0
- package/dist/forms/html/authoring-adapter.js +48 -0
- package/dist/forms/html/html-authoring.d.ts +12 -0
- package/dist/forms/html/html-authoring.js +133 -0
- package/dist/forms/html/html-submission-adapter.d.ts +10 -0
- package/dist/forms/html/html-submission-adapter.js +15 -0
- package/dist/forms/html/index.d.ts +4 -0
- package/dist/forms/html/index.js +4 -0
- package/dist/forms/html-render.d.ts +33 -0
- package/dist/forms/html-render.js +31 -0
- package/dist/forms/index.d.ts +17 -0
- package/dist/forms/index.js +17 -0
- package/dist/forms/notify-subscriber.d.ts +32 -0
- package/dist/forms/notify-subscriber.js +60 -0
- package/dist/forms/ports.d.ts +168 -0
- package/dist/forms/ports.js +2 -0
- package/dist/forms/rate-limit-key.d.ts +8 -0
- package/dist/forms/rate-limit-key.js +7 -0
- package/dist/forms/repo.memory.d.ts +120 -0
- package/dist/forms/repo.memory.js +188 -0
- package/dist/forms/sql/index.d.ts +5 -0
- package/dist/forms/sql/index.js +5 -0
- package/dist/forms/sql/repo.d.ts +94 -0
- package/dist/forms/sql/repo.js +175 -0
- package/dist/forms/sql/repo.rows.d.ts +33 -0
- package/dist/forms/sql/repo.rows.js +64 -0
- package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
- package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
- package/dist/forms/sql/tables.d.ts +32 -0
- package/dist/forms/sql/tables.js +2 -0
- package/dist/forms/submission-attempts.d.ts +70 -0
- package/dist/forms/submission-attempts.js +70 -0
- package/dist/forms/submission-email-validation.d.ts +8 -0
- package/dist/forms/submission-email-validation.js +34 -0
- package/dist/forms/submission-ip-retention.d.ts +30 -0
- package/dist/forms/submission-ip-retention.js +27 -0
- package/dist/forms/submit-service.d.ts +78 -0
- package/dist/forms/submit-service.js +109 -0
- package/dist/forms/types.d.ts +49 -0
- package/dist/forms/types.js +2 -0
- package/dist/forms/write-service.d.ts +83 -0
- package/dist/forms/write-service.js +271 -0
- package/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- package/package.json +220 -12
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The ONE parser for embed markers (`data-embed-config`), shared by every consumer.
|
|
3
|
+
*
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Before the 2026-08-10 marker-spine unification there were four independent scanners, each with its
|
|
6
|
+
* own regex, matching two different attribute vocabularies that had drifted apart:
|
|
7
|
+
*
|
|
8
|
+
* - `widgets/html-embeds.ts` — `data-embed-type`/`data-embed-id` in a Page's `body_html`
|
|
9
|
+
* - `features/theme/static-render.ts` — the same pair for menu/post markers, PLUS
|
|
10
|
+
* `data-tovu-slot`/`data-nav-current`/`data-slot-variant` for theme partials
|
|
11
|
+
* - `core/entry-refs/extractor.ts` — the same pair again, for reference extraction
|
|
12
|
+
*
|
|
13
|
+
* Four regexes for one concept is four places a fix has to land and four ways they can disagree
|
|
14
|
+
* (they already did: the widgets pipeline only matched a self-closing `<div></div>`, while the theme
|
|
15
|
+
* path deliberately matched a marker WITH authored fallback content inside it). This module is the
|
|
16
|
+
* single definition. Consumers ask it what a piece of HTML references; none of them owns a pattern.
|
|
17
|
+
*
|
|
18
|
+
* The vocabulary is one attribute carrying JSON:
|
|
19
|
+
*
|
|
20
|
+
* <div data-embed-config='{"type":"partial","id":"nav","current":"index"}'></div>
|
|
21
|
+
* <nav data-embed-config='{"type":"menu","id":"docs-nav","variant":"tree"}'>fallback</nav>
|
|
22
|
+
*
|
|
23
|
+
* Forms may request plain output with `{"type":"form","id":"contact","mode":"html"}`;
|
|
24
|
+
* `mode` is resolver-owned config, so the parser carries it through unchanged.
|
|
25
|
+
*
|
|
26
|
+
* `type` and `id` are ordinary keys, not separate attributes, so a new key never requires a new
|
|
27
|
+
* attribute name. The attribute is single-quoted precisely so the JSON's own double quotes need no
|
|
28
|
+
* escaping.
|
|
29
|
+
*
|
|
30
|
+
* Architectural role:
|
|
31
|
+
* PURE. No I/O, no DOM, no resolution — it reports what a marker *says*, never what it resolves to.
|
|
32
|
+
* Lives in `core/` because all three consumers above sit in different features and `core/entry-refs`
|
|
33
|
+
* (the integrity path) is already here.
|
|
34
|
+
*
|
|
35
|
+
* ## Why regex and not a parser
|
|
36
|
+
*
|
|
37
|
+
* Same reason the code it replaces used one: this runs server-side against theme-authored markup and
|
|
38
|
+
* Page bodies with no DOM available, and every consumer needs the marker's *source offsets* to
|
|
39
|
+
* substitute in place. A real HTML parse would also have to round-trip the document back to a string
|
|
40
|
+
* byte-identically, which is a much harder guarantee than locating one attribute.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Matches the OPEN tag of one element carrying `data-embed-config`, capturing its tag name and its
|
|
44
|
+
* attribute text. It stops at the open tag's own `>` and makes no assumption about what closes it —
|
|
45
|
+
* {@link scanEmbedMarkers} locates the matching close tag separately, via {@link findBalancedClose}'s
|
|
46
|
+
* depth count over the masked html.
|
|
47
|
+
*
|
|
48
|
+
* Before 2026-09-23 this pattern captured inner content itself, up to a backreferenced `<\/\1>`. That
|
|
49
|
+
* was only safe for a marker with no same-named descendant — true for every marker convention in this
|
|
50
|
+
* codebase at the time, and the same assumption `injectMenuEmbed` documented before this module
|
|
51
|
+
* existed — but it meant `<div data-embed-config='...'><div>…</div></div>` (an inner element sharing
|
|
52
|
+
* the marker's own tag name) closed at the FIRST `</div>`, truncating the marker's real inner content.
|
|
53
|
+
* {@link findBalancedClose} removes that assumption by counting depth instead of matching nearest text.
|
|
54
|
+
*
|
|
55
|
+
* The tag name pattern (`[a-z][a-z0-9-]*`) accepts any HTML element name, including a custom element
|
|
56
|
+
* (`<my-card>`), not just the historical letters-only set.
|
|
57
|
+
*
|
|
58
|
+
* Carries the `d` (`hasIndices`) flag so {@link scanEmbedMarkers} can read every field back out of
|
|
59
|
+
* the ORIGINAL html at each capture group's own offsets, rather than out of whatever string was
|
|
60
|
+
* actually scanned (see {@link maskNonRenderableRegions}) — see that function's doc for why the two
|
|
61
|
+
* must never be the same string.
|
|
62
|
+
*
|
|
63
|
+
* Accepts the value single-quoted (group 3, the authored convention) OR double-quoted (group 4).
|
|
64
|
+
* Before 2026-09-26 only the single-quoted form matched, but every DOM serializer — the admin's
|
|
65
|
+
* Interactive canvas (GrapesJS), `innerHTML`, parse5 — re-emits the SAME attribute as
|
|
66
|
+
* `data-embed-config="{"type":…}"`, so one canvas save silently turned a working marker into
|
|
67
|
+
* inert markup (the live /pages "That marker, live:" contact-form example rendered an empty box).
|
|
68
|
+
* A double-quoted value is HTML-decoded before parsing ({@link decodeAttributeValue}); a single-quoted
|
|
69
|
+
* one is still read verbatim, as it always was.
|
|
70
|
+
*/
|
|
71
|
+
const MARKER_PATTERN = /<([a-z][a-z0-9-]*)((?:\s+[^>]*?)?\sdata-embed-config=(?:'([^']*)'|"([^"]*)")(?:\s+[^>]*?)?)\s*>/gid;
|
|
72
|
+
/** The `data-embed-config` attribute itself, either quoting — for the rebuild helpers that rewrite or
|
|
73
|
+
* drop it inside a marker's {@link EmbedMarker.attrs}. Group 1 is set only for the double-quoted form. */
|
|
74
|
+
const MARKER_ATTRIBUTE_PATTERN = /\s+data-embed-config=(?:'[^']*'|("[^"]*"))/;
|
|
75
|
+
/** Named character references a serializer actually emits inside an attribute value. Any other named
|
|
76
|
+
* reference is left as written rather than guessed at. */
|
|
77
|
+
const NAMED_CHARACTER_REFERENCES = { quot: '"', amp: "&", apos: "'", lt: "<", gt: ">", nbsp: " " };
|
|
78
|
+
/**
|
|
79
|
+
* A double-quoted attribute value as a browser reads it: `"`/`&`/`'`/`<`/`>`/
|
|
80
|
+
* ` ` and decimal/hex numeric references decoded. Kept local (this module stays import-free).
|
|
81
|
+
*
|
|
82
|
+
* @complexity O(n) over `value`.
|
|
83
|
+
*/
|
|
84
|
+
function decodeAttributeValue(value) {
|
|
85
|
+
return value.replace(/&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-z]+));/g, (whole, dec, hex, name) => {
|
|
86
|
+
if (name !== undefined)
|
|
87
|
+
return NAMED_CHARACTER_REFERENCES[name] ?? whole;
|
|
88
|
+
const codePoint = dec !== undefined ? Number.parseInt(dec, 10) : Number.parseInt(hex, 16);
|
|
89
|
+
return codePoint > 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : whole;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** The inverse of {@link decodeAttributeValue} for a value written back double-quoted. */
|
|
93
|
+
function encodeDoubleQuotedValue(value) {
|
|
94
|
+
return value.replace(/&/g, "&").replace(/"/g, """);
|
|
95
|
+
}
|
|
96
|
+
/** Every character of `text` replaced by a space, except newlines (left alone so a masked span
|
|
97
|
+
* cannot change how many lines the surrounding string has). Same length in, same length out. */
|
|
98
|
+
function blank(text) {
|
|
99
|
+
return text.replace(/[^\n]/g, " ");
|
|
100
|
+
}
|
|
101
|
+
/** An HTML comment, open to close, non-greedy so two separate comments never merge into one span. */
|
|
102
|
+
const HTML_COMMENT_PATTERN = /<!--[\s\S]*?-->/g;
|
|
103
|
+
/** A `<script>` or `<style>` element, capturing its tag name (for the backreferenced close) and its
|
|
104
|
+
* raw-text content — the part a browser never parses as markup. */
|
|
105
|
+
const RAW_TEXT_ELEMENT_PATTERN = /<(script|style)\b[^>]*>([\s\S]*?)<\/\1>/gi;
|
|
106
|
+
/**
|
|
107
|
+
* Produce a same-length copy of `html` with every HTML comment, and the raw-text content of every
|
|
108
|
+
* `<script>`/`<style>` element, replaced by space filler. {@link scanEmbedMarkers} runs
|
|
109
|
+
* {@link MARKER_PATTERN} against this copy — never the original — so a well-formed
|
|
110
|
+
* `data-embed-config` marker written inside an authoring note (`<!-- ... -->`) or inside a
|
|
111
|
+
* `<style>` block's CSS comment can never be matched: neither is ever parsed as an element by a
|
|
112
|
+
* browser, so the scanner must not treat either as one either. A marker outside both is untouched
|
|
113
|
+
* here and matches exactly as before.
|
|
114
|
+
*
|
|
115
|
+
* Same length is load-bearing, not cosmetic: every offset {@link scanEmbedMarkers} reports (and
|
|
116
|
+
* {@link substituteMarkers}'s index-based splice back into the ORIGINAL html) depends on a masked
|
|
117
|
+
* span occupying exactly the same positions as what it replaces. Deleting the comment/raw-text
|
|
118
|
+
* content instead of blanking it would shift every later offset out from under those callers.
|
|
119
|
+
*
|
|
120
|
+
* EXPORTED (2026-09-09) for `features/pages/regions.ts`, which scans the SAME page bodies for
|
|
121
|
+
* `data-agent-element` region elements and needs the identical "text inside a comment or inside a
|
|
122
|
+
* <style> block is not markup" rule with the identical offset-preserving guarantee. A second copy
|
|
123
|
+
* of this masking is exactly the four-scanners-for-one-concept drift this module's own header
|
|
124
|
+
* exists to close: region scanning is a different question about the same bytes, not a second
|
|
125
|
+
* vocabulary.
|
|
126
|
+
*/
|
|
127
|
+
export function maskNonRenderableRegions({ html }, _optional = {}) {
|
|
128
|
+
const withoutComments = html.replace(HTML_COMMENT_PATTERN, blank);
|
|
129
|
+
return withoutComments.replace(RAW_TEXT_ELEMENT_PATTERN, (whole, tagName, content) => {
|
|
130
|
+
const closingTagLength = tagName.length + 3; // "</" + tagName + ">"
|
|
131
|
+
const openTagLength = whole.length - content.length - closingTagLength;
|
|
132
|
+
return whole.slice(0, openTagLength) + blank(content) + whole.slice(openTagLength + content.length);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* One capture group's `[start, end)` offsets from a `d`-flagged match's `indices` array. Throws
|
|
137
|
+
* rather than returning `undefined` for a group `MARKER_PATTERN` never leaves unmatched — every
|
|
138
|
+
* group here sits on a mandatory part of the pattern, never inside an optional alternation, so a
|
|
139
|
+
* missing entry means the pattern changed underneath this function, not a normal runtime path.
|
|
140
|
+
*/
|
|
141
|
+
function requireGroupRange(indices, group) {
|
|
142
|
+
const range = indices[group];
|
|
143
|
+
if (!range) {
|
|
144
|
+
throw new Error(`marker.ts: expected capture group ${group} of MARKER_PATTERN to participate in the match`);
|
|
145
|
+
}
|
|
146
|
+
return range;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Validate one marker's raw attribute text. Split out from {@link scanEmbedMarkers} so the scan stays
|
|
150
|
+
* a plain loop: all three rejection branches live here, and the caller only chooses which list to
|
|
151
|
+
* push onto.
|
|
152
|
+
*
|
|
153
|
+
* EXPORTED (2026-09-23, Bug A / interactive-bugs plan Slice A1) so a consumer that only has the raw
|
|
154
|
+
* `data-embed-config` string in hand — the admin's canvas placeholder describer, which reads an
|
|
155
|
+
* already-authored DOM attribute rather than re-running {@link scanEmbedMarkers} — can apply the exact
|
|
156
|
+
* same parse-and-reject rule `scanEmbedMarkers` itself uses, instead of growing its own copy of it.
|
|
157
|
+
*/
|
|
158
|
+
export function parseEmbedMarkerConfig({ raw }, _optional = {}) {
|
|
159
|
+
let parsed;
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(raw);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
return { problem: { kind: "invalid-json", raw, message: err.message } };
|
|
165
|
+
}
|
|
166
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
167
|
+
return { problem: { kind: "not-an-object", raw } };
|
|
168
|
+
}
|
|
169
|
+
const config = parsed;
|
|
170
|
+
if (typeof config.type !== "string" || config.type === "") {
|
|
171
|
+
return { problem: { kind: "missing-type", raw } };
|
|
172
|
+
}
|
|
173
|
+
return { config };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Every `<tagName`/`</tagName` token in `masked` from `fromIndex` onward, in document order. The
|
|
177
|
+
* boundary lookahead (`[\s/>]`) stops `<my-card` matching a longer name like `<my-card-extra`; each
|
|
178
|
+
* token ends at its first `>`, and the scan resumes there, so a token's own attribute text is never
|
|
179
|
+
* re-read as another token. Stops at a trailing malformed tag with no `>` at all.
|
|
180
|
+
*
|
|
181
|
+
* Runs against the MASKED copy only, so a tag written inside a comment or a `<script>`/`<style>`
|
|
182
|
+
* element's raw text (already blanked by {@link maskNonRenderableRegions}) is invisible here,
|
|
183
|
+
* exactly as it is invisible to {@link MARKER_PATTERN} itself.
|
|
184
|
+
*
|
|
185
|
+
* @complexity O(n) over the remaining length of `masked`.
|
|
186
|
+
*/
|
|
187
|
+
function* tagTokens(masked, tagName, fromIndex) {
|
|
188
|
+
const token = new RegExp(`<(/?)${tagName}(?=[\\s/>])`, "gi");
|
|
189
|
+
token.lastIndex = fromIndex;
|
|
190
|
+
for (let match = token.exec(masked); match !== null; match = token.exec(masked)) {
|
|
191
|
+
const closeAngle = masked.indexOf(">", match.index);
|
|
192
|
+
if (closeAngle === -1)
|
|
193
|
+
return;
|
|
194
|
+
yield { isClose: match[1] === "/", span: [match.index, closeAngle + 1] };
|
|
195
|
+
token.lastIndex = closeAngle + 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The depth-balanced close for the open tag whose match ended at `fromIndex`, by counting
|
|
200
|
+
* {@link tagTokens} from there: `<div><div>…</div></div>` closes at the tag that actually balances the
|
|
201
|
+
* one already open, not at the first `</div>` found. Falls back to the first close found when the
|
|
202
|
+
* region never rebalances (a genuinely unclosed inner tag) — the result the old backreferenced pattern
|
|
203
|
+
* produced, so malformed markup degrades as it did before. `null` when there is no close at all.
|
|
204
|
+
*
|
|
205
|
+
* Only {@link findBalancedClose}'s fallback for an open tag {@link indexTagTokens} tokenized
|
|
206
|
+
* differently (its JSON config holds a `>`); every other lookup is answered from that index.
|
|
207
|
+
*
|
|
208
|
+
* @complexity O(n) over the remaining length of `masked`.
|
|
209
|
+
*/
|
|
210
|
+
function scanForBalancedClose(masked, tagName, fromIndex) {
|
|
211
|
+
let depth = 1;
|
|
212
|
+
let firstClose = null;
|
|
213
|
+
for (const { isClose, span } of tagTokens(masked, tagName, fromIndex)) {
|
|
214
|
+
if (!isClose) {
|
|
215
|
+
depth += 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
firstClose ??= span;
|
|
219
|
+
depth -= 1;
|
|
220
|
+
if (depth === 0)
|
|
221
|
+
return span;
|
|
222
|
+
}
|
|
223
|
+
return firstClose;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Pairs every `<tagName`/`</tagName` token in `masked` in ONE pass. Stack pairing gives each open the
|
|
227
|
+
* same close {@link scanForBalancedClose}'s depth count from that open would find (both match a close
|
|
228
|
+
* with the nearest still-open tag), but for every open at once — so a page of k unbalanced markers
|
|
229
|
+
* costs O(n), not the O(k·n) of rescanning to the end of the document once per marker.
|
|
230
|
+
*
|
|
231
|
+
* @complexity O(n) time, O(t) space for t tokens.
|
|
232
|
+
*/
|
|
233
|
+
function indexTagTokens(masked, tagName) {
|
|
234
|
+
const partners = new Map();
|
|
235
|
+
const closes = [];
|
|
236
|
+
const openStarts = [];
|
|
237
|
+
for (const { isClose, span } of tagTokens(masked, tagName, 0)) {
|
|
238
|
+
if (!isClose) {
|
|
239
|
+
partners.set(span[0], null);
|
|
240
|
+
openStarts.push(span[0]);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
closes.push(span);
|
|
244
|
+
const opener = openStarts.pop();
|
|
245
|
+
if (opener !== undefined)
|
|
246
|
+
partners.set(opener, span);
|
|
247
|
+
}
|
|
248
|
+
return { partners, closes };
|
|
249
|
+
}
|
|
250
|
+
/** The first span in `closes` (sorted by start) starting at or after `fromIndex`, or `null`.
|
|
251
|
+
* @complexity O(log c) — binary search. */
|
|
252
|
+
function firstCloseFrom(closes, fromIndex) {
|
|
253
|
+
let low = 0;
|
|
254
|
+
let high = closes.length;
|
|
255
|
+
while (low < high) {
|
|
256
|
+
const mid = (low + high) >>> 1;
|
|
257
|
+
// low < high keeps mid within the sorted closes array.
|
|
258
|
+
if (closes[mid][0] < fromIndex)
|
|
259
|
+
low = mid + 1;
|
|
260
|
+
else
|
|
261
|
+
high = mid;
|
|
262
|
+
}
|
|
263
|
+
return closes[low] ?? null;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The `[start, end)` span (in `masked`) of the close tag that balances the marker open tag spanning
|
|
267
|
+
* `[openStart, openEnd)` — see {@link scanForBalancedClose} for the balancing and first-close
|
|
268
|
+
* fallback rules — or `null` when no close exists at all (a void element such as `<img>`, or an
|
|
269
|
+
* unclosed marker): the caller then treats the open tag alone as the whole marker, rather than
|
|
270
|
+
* letting it swallow the rest of the document.
|
|
271
|
+
*
|
|
272
|
+
* Answers from a per-tag-name {@link indexTagTokens} built once per scan and kept in `cache`. When
|
|
273
|
+
* that index tokenized this open tag differently from the marker pattern (the index ends a tag at its
|
|
274
|
+
* first `>`, and a `>` inside the JSON config ends it early), it falls back to the exact linear count.
|
|
275
|
+
*
|
|
276
|
+
* @complexity O(log c) amortized once the tag name's O(n) index exists; O(n) on the fallback.
|
|
277
|
+
*/
|
|
278
|
+
function findBalancedClose(masked, tag, cache) {
|
|
279
|
+
const key = tag.name.toLowerCase();
|
|
280
|
+
let index = cache.get(key);
|
|
281
|
+
if (index === undefined) {
|
|
282
|
+
index = indexTagTokens(masked, key);
|
|
283
|
+
cache.set(key, index);
|
|
284
|
+
}
|
|
285
|
+
const partner = index.partners.get(tag.openStart);
|
|
286
|
+
const aligned = partner !== undefined && masked.indexOf(">", tag.openStart) + 1 === tag.openEnd;
|
|
287
|
+
if (!aligned)
|
|
288
|
+
return scanForBalancedClose(masked, key, tag.openEnd);
|
|
289
|
+
return partner ?? firstCloseFrom(index.closes, tag.openEnd);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Locate and parse every embed marker in `html`. Pure; allocates one result per marker.
|
|
293
|
+
*
|
|
294
|
+
* Scans {@link maskNonRenderableRegions}'s masked copy so a marker sitting inside an HTML comment or
|
|
295
|
+
* a `<script>`/`<style>` element's raw text is never matched, and so tags inside either are invisible
|
|
296
|
+
* to {@link findBalancedClose}'s depth count too. Every field on the resulting {@link EmbedMarker}
|
|
297
|
+
* (`whole`, `tag`, `attrs`, `inner`, the parsed config) is read back out of the ORIGINAL `html` at
|
|
298
|
+
* that match's own offsets — masking only decides which spans are eligible to be a marker or to count
|
|
299
|
+
* toward a close tag's depth, it must never change what an eligible marker's own fields report.
|
|
300
|
+
*
|
|
301
|
+
* Uses a private copy of {@link MARKER_PATTERN} (same source/flags) for the `exec` loop below, rather
|
|
302
|
+
* than mutating the shared module-level regex's `lastIndex` directly — this function is PURE, and a
|
|
303
|
+
* shared mutable scan cursor would corrupt a reentrant or recursive call.
|
|
304
|
+
*/
|
|
305
|
+
export function scanEmbedMarkers({ html }, _optional = {}) {
|
|
306
|
+
const markers = [];
|
|
307
|
+
const rejected = [];
|
|
308
|
+
let occurrence = 0;
|
|
309
|
+
const masked = maskNonRenderableRegions({ html: html });
|
|
310
|
+
const pattern = new RegExp(MARKER_PATTERN.source, MARKER_PATTERN.flags);
|
|
311
|
+
const closeIndexCache = new Map();
|
|
312
|
+
for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
|
|
313
|
+
occurrence += 1;
|
|
314
|
+
const indices = match.indices;
|
|
315
|
+
const [wholeStart, openTagEnd] = requireGroupRange(indices, 0);
|
|
316
|
+
const tagRange = requireGroupRange(indices, 1);
|
|
317
|
+
const attrsRange = requireGroupRange(indices, 2);
|
|
318
|
+
const doubleQuotedRange = indices[4];
|
|
319
|
+
const rawRange = doubleQuotedRange ?? requireGroupRange(indices, 3);
|
|
320
|
+
const tag = html.slice(...tagRange);
|
|
321
|
+
// No close at all (a void `<img>` marker): the open tag alone is the whole marker, inner empty.
|
|
322
|
+
const [closeStart, closeEnd] = findBalancedClose(masked, { name: tag, openStart: wholeStart, openEnd: openTagEnd }, closeIndexCache) ?? [
|
|
323
|
+
openTagEnd,
|
|
324
|
+
openTagEnd,
|
|
325
|
+
];
|
|
326
|
+
pattern.lastIndex = closeEnd; // resume past this marker's whole balanced span, never re-entering it
|
|
327
|
+
const index = wholeStart;
|
|
328
|
+
const whole = html.slice(wholeStart, closeEnd);
|
|
329
|
+
const attrs = html.slice(...attrsRange);
|
|
330
|
+
const raw = doubleQuotedRange ? decodeAttributeValue(html.slice(...rawRange)) : html.slice(...rawRange);
|
|
331
|
+
const inner = html.slice(openTagEnd, closeStart);
|
|
332
|
+
const result = parseEmbedMarkerConfig({ raw: raw });
|
|
333
|
+
if ("problem" in result) {
|
|
334
|
+
rejected.push({ problem: result.problem, occurrence, index });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const { config } = result;
|
|
338
|
+
markers.push({
|
|
339
|
+
type: config.type,
|
|
340
|
+
id: typeof config.id === "string" ? config.id : undefined,
|
|
341
|
+
config,
|
|
342
|
+
tag,
|
|
343
|
+
attrs,
|
|
344
|
+
whole,
|
|
345
|
+
inner,
|
|
346
|
+
index,
|
|
347
|
+
occurrence,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return { markers, rejected };
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The JSON text of a decoded marker value, made safe to sit verbatim inside a single-quoted
|
|
354
|
+
* attribute: `'` and `&` become the JSON escapes `\u0027`/`\u0026`. Both can only occur inside a
|
|
355
|
+
* JSON string, where those escapes mean the same character, so `JSON.parse` returns the identical
|
|
356
|
+
* value. Deliberately not `'`/`&`: {@link scanEmbedMarkers} reads a single-quoted value
|
|
357
|
+
* verbatim, so an entity there would reach `JSON.parse` undecoded and change the config.
|
|
358
|
+
*
|
|
359
|
+
* @complexity O(n) over `json`.
|
|
360
|
+
*/
|
|
361
|
+
function toSingleQuotedValue(json) {
|
|
362
|
+
return json.replace(/'/g, "\\u0027").replace(/&/g, "\\u0026");
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Rewrites every double-quoted, entity-encoded `data-embed-config="{"type":…}"` (what a DOM
|
|
366
|
+
* serializer such as the admin's Interactive canvas emits) back to the authored, readable form
|
|
367
|
+
* `data-embed-config='{"type":…}'`, so the stored page source reads the way it was written. Every
|
|
368
|
+
* Page HTML write runs through this (`PagesHtmlDocumentStore`), and the admin applies it to what the
|
|
369
|
+
* canvas hands back.
|
|
370
|
+
*
|
|
371
|
+
* Exact at the config level: a rewritten marker scans to the same `config`, `attrs` and `inner` it did
|
|
372
|
+
* before. Only the quoted value changes; the tag, other attributes and content are untouched. Left
|
|
373
|
+
* as written: a single-quoted marker, a marker inside a comment or `<script>`/`<style>` (never matched,
|
|
374
|
+
* see {@link maskNonRenderableRegions}), and a value that is not valid JSON, so a broken marker still
|
|
375
|
+
* reaches {@link scanEmbedMarkers}'s rejection with its original text.
|
|
376
|
+
*
|
|
377
|
+
* @complexity O(n) over `html`.
|
|
378
|
+
*/
|
|
379
|
+
export function normalizeEmbedMarkerQuoting({ html }, _optional = {}) {
|
|
380
|
+
const masked = maskNonRenderableRegions({ html: html });
|
|
381
|
+
const pattern = new RegExp(MARKER_PATTERN.source, MARKER_PATTERN.flags);
|
|
382
|
+
let result = "";
|
|
383
|
+
let copiedUpTo = 0;
|
|
384
|
+
for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
|
|
385
|
+
const valueRange = match.indices[4];
|
|
386
|
+
if (!valueRange)
|
|
387
|
+
continue;
|
|
388
|
+
const decoded = decodeAttributeValue(html.slice(...valueRange));
|
|
389
|
+
try {
|
|
390
|
+
JSON.parse(decoded);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
// The value's quotes sit one character outside its capture group on either side.
|
|
396
|
+
result += `${html.slice(copiedUpTo, valueRange[0] - 1)}'${toSingleQuotedValue(decoded)}'`;
|
|
397
|
+
copiedUpTo = valueRange[1] + 1;
|
|
398
|
+
}
|
|
399
|
+
return result + html.slice(copiedUpTo);
|
|
400
|
+
}
|
|
401
|
+
/** Every marker of one type, in document order. The common "which menus does this theme name" shape. */
|
|
402
|
+
export function markersOfType({ html, type }, _optional = {}) {
|
|
403
|
+
return scanEmbedMarkers({ html: html }).markers.filter((m) => m.type === type);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* The two marker `type` values this module parses but never itself resolves — owned end-to-end by
|
|
407
|
+
* `features/theme/static-render.ts` (`injectMenuEmbeds` for {@link MENU_MARKER_TYPE}, `resolveSlots`
|
|
408
|
+
* for {@link PARTIAL_MARKER_TYPE}), which run AFTER `widgets/resolver-service.ts`'s page-embed stage
|
|
409
|
+
* on every static-tier page render.
|
|
410
|
+
*
|
|
411
|
+
* Hoisted here (2026-08-17) so that ownership fact has exactly one spelling. Before this, it existed
|
|
412
|
+
* only as inline string-literal comparisons inside `static-render.ts` itself
|
|
413
|
+
* (`marker.type !== "menu"`, `marker.type !== "partial"`), and `resolver-service.ts`'s own
|
|
414
|
+
* `THEME_OWNED_MARKER_TYPES` carried an independently-typed copy of the same two strings as a
|
|
415
|
+
* documented stopgap pending this exact hoist (`ADS-memory/reports/
|
|
416
|
+
* 2026-08-16-embed-placeholder-gap.md`, "Reuse decision") — two spellings of one fact is exactly the
|
|
417
|
+
* kind of drift risk this module exists to close for marker *parsing*; these two constants close it
|
|
418
|
+
* for marker *type naming* the same way.
|
|
419
|
+
*
|
|
420
|
+
* Deliberately NOT the same question `isPageEmbedType()` (`widgets/resolver-service.ts`) answers —
|
|
421
|
+
* that function tests registry membership (`Object.hasOwn(HTML_EMBED_RESOLVERS, type)`), which is
|
|
422
|
+
* `false` for these two AND for a genuine unregistered/typo type alike. These constants name the
|
|
423
|
+
* "owned elsewhere, not a typo" fact directly instead; see `isPageEmbedType`'s own doc for why
|
|
424
|
+
* conflating the two would silence a real warning.
|
|
425
|
+
*/
|
|
426
|
+
export const MENU_MARKER_TYPE = "menu";
|
|
427
|
+
export const PARTIAL_MARKER_TYPE = "partial";
|
|
428
|
+
/**
|
|
429
|
+
* The post-previews marker type (2026-09-03) — a theme/page marker that renders a bounded list of
|
|
430
|
+
* published post previews wherever it appears, so any authored Page (a static theme's own marketing
|
|
431
|
+
* page, or a Page/Post rendered through a `theme.json` `templates` entry) can host a post listing.
|
|
432
|
+
* Same ownership shape as {@link MENU_MARKER_TYPE}/{@link PARTIAL_MARKER_TYPE} immediately above:
|
|
433
|
+
* resolved end-to-end by `features/theme/static-render.ts`'s `injectPostPreviewsEmbeds`, never
|
|
434
|
+
* registered in `widgets/resolver-service.ts`'s `HTML_EMBED_RESOLVERS` — see that file's
|
|
435
|
+
* `THEME_OWNED_MARKER_TYPES`, which carries a matching literal entry for the same disclosed
|
|
436
|
+
* "duplicated, not imported" reason its own doc already states for the other two.
|
|
437
|
+
*
|
|
438
|
+
* Config shape: `{"type":"post-previews","limit":6}` — `limit` is optional (defaults to
|
|
439
|
+
* `DEFAULT_POST_PREVIEWS_LIMIT`, `static-render.ts`) and is always clamped, never trusted verbatim,
|
|
440
|
+
* before it reaches the bounded repo query that feeds this marker.
|
|
441
|
+
*/
|
|
442
|
+
export const POST_PREVIEWS_MARKER_TYPE = "post-previews";
|
|
443
|
+
/**
|
|
444
|
+
* The collection marker type (2026-09-23) — a theme/page marker that renders a bounded, filtered,
|
|
445
|
+
* sorted list of entries from one custom content type wherever it appears. Same ownership shape as
|
|
446
|
+
* {@link MENU_MARKER_TYPE}/{@link PARTIAL_MARKER_TYPE}/{@link POST_PREVIEWS_MARKER_TYPE} immediately
|
|
447
|
+
* above: resolved end-to-end by `features/theme/static-render.ts`'s `injectCollectionEmbeds`, never
|
|
448
|
+
* registered in `widgets/resolver-service.ts`'s `HTML_EMBED_RESOLVERS` — see that file's
|
|
449
|
+
* `THEME_OWNED_MARKER_TYPES`, which carries a matching literal entry for the same disclosed
|
|
450
|
+
* "duplicated, not imported" reason its own doc gives for the other three.
|
|
451
|
+
*
|
|
452
|
+
* Config shape (validated by `entries/public-list.ts`'s `parseCollectionListConfig`, not by this
|
|
453
|
+
* module): `{"type":"collection","id":"recipe", …}` (`typeKey` accepted as an alias) plus the type's own filter/sort/limit/layout
|
|
454
|
+
* keys. Unlike {@link POST_PREVIEWS_MARKER_TYPE}, a resolved collection marker's wrapper is stripped
|
|
455
|
+
* of `data-embed-config` on the hit path ({@link withInnerContentFinal}) so a later re-scan of the
|
|
456
|
+
* same output can never rediscover and re-resolve it.
|
|
457
|
+
*/
|
|
458
|
+
export const COLLECTION_MARKER_TYPE = "collection";
|
|
459
|
+
/**
|
|
460
|
+
* The featured-image marker type (2026-10-05) — a post template's slot for the current post's
|
|
461
|
+
* featured image. A static-tier theme has no template language to read `post.featuredImage` (the
|
|
462
|
+
* Liquid/Handlebars tiers' theme data), so before this marker a static theme had no way to show the
|
|
463
|
+
* image at all; it only reached `og:image`. Same ownership shape as {@link COLLECTION_MARKER_TYPE}:
|
|
464
|
+
* resolved end-to-end by `features/theme/static-render.ts`'s `injectFeaturedImage`, listed in
|
|
465
|
+
* `resolver-service.ts`'s `THEME_OWNED_MARKER_TYPES`.
|
|
466
|
+
*
|
|
467
|
+
* Config shape: `{"type":"featured-image"}` — no target; it always means the entity the route is
|
|
468
|
+
* rendering. A hit fills the authored element with the `<img>` and strips the marker; no image
|
|
469
|
+
* (none set, unresolved, or a page with no current post) removes the WHOLE element, wrapper and
|
|
470
|
+
* fallback included, so a theme's figure/margins never render around nothing.
|
|
471
|
+
*/
|
|
472
|
+
export const FEATURED_IMAGE_MARKER_TYPE = "featured-image";
|
|
473
|
+
/**
|
|
474
|
+
* The longest a target value ({@link embedMarkerTarget}'s `id`/`slug`/`typeKey`) may be before it
|
|
475
|
+
* counts as absent rather than a usable reference. A duplicate of `features/widgets/html-embeds.ts`'s
|
|
476
|
+
* `MAX_EMBED_ID_LENGTH` (same number, same reasoning: a bound too long to safely carry into a
|
|
477
|
+
* `Map`/`Set` key or an `entry_refs` row), not a shared import — this module stays import-free (see
|
|
478
|
+
* {@link maskNonRenderableRegions}'s file header and `marker-target-parity.unit.test.ts`'s boundary
|
|
479
|
+
* assertion), the same "duplicated fact, disclosed in a comment" tradeoff {@link MENU_MARKER_TYPE} and
|
|
480
|
+
* {@link PARTIAL_MARKER_TYPE} already document for their own server-side twins.
|
|
481
|
+
*/
|
|
482
|
+
const MAX_TARGET_VALUE_LENGTH = 200;
|
|
483
|
+
/**
|
|
484
|
+
* One marker type's `data-embed-config` keys that can name its resolution target, in the exact
|
|
485
|
+
* precedence order the server tries them — `embedMarkerTarget` returns the FIRST of these keys with a
|
|
486
|
+
* usable string value. This is the "which key names the target" rule Bug A's root cause report
|
|
487
|
+
* (2026-09-23 interactive-bugs plan) found spread across four different resolvers
|
|
488
|
+
* (`resolver-service.ts`'s widget/media/post/content, `routes/site/pages.ts`'s collection) with no
|
|
489
|
+
* single shared table — the admin's own placeholder describer could not reuse it because nothing here
|
|
490
|
+
* exposed it. Hoisting it as data (not a switch statement) is what lets both the server's own resolvers
|
|
491
|
+
* and the admin's UI-only describer read the identical precedence rule.
|
|
492
|
+
*
|
|
493
|
+
* - `widget`/`form`/`media`/`post`/`content`: `id`, else `slug` — registered in
|
|
494
|
+
* `resolver-service.ts`'s `HTML_EMBED_RESOLVERS` (see `marker-target-parity.unit.test.ts`, which fails
|
|
495
|
+
* loudly if a registered type has no matching row or incorrect target keys).
|
|
496
|
+
* - `taxonomy`: `id` only — `widgets/resolvers/taxonomy.ts` reads `ref.id`, accepting a real
|
|
497
|
+
* taxonomy id or a unique taxonomy name through that key; `ref.slug` is never consulted.
|
|
498
|
+
* - {@link COLLECTION_MARKER_TYPE}: `id`, else `typeKey` — `routes/site/pages.ts`'s
|
|
499
|
+
* `collectionMarkerTypeKey` tries `config.id` before `config.typeKey`; `slug` is never consulted.
|
|
500
|
+
* - {@link MENU_MARKER_TYPE} / {@link PARTIAL_MARKER_TYPE}: `id` only — `static-render.ts` resolves
|
|
501
|
+
* both purely by id.
|
|
502
|
+
* - {@link POST_PREVIEWS_MARKER_TYPE}: no keys at all — this marker needs no target
|
|
503
|
+
* ({@link embedMarkerTarget} reports that as its own `{ key: "none" }` outcome, never `undefined`,
|
|
504
|
+
* which means "target missing").
|
|
505
|
+
*
|
|
506
|
+
* An unknown type (not a key of this table) is not a member of it; {@link embedMarkerTarget} falls back
|
|
507
|
+
* to `["id"]` for that case rather than treating an unrecognized type as needing no target — a display
|
|
508
|
+
* must never claim a slug resolves for a type no resolver has ever registered.
|
|
509
|
+
*/
|
|
510
|
+
export const EMBED_MARKER_TARGET_KEYS = {
|
|
511
|
+
widget: ["id", "slug"],
|
|
512
|
+
form: ["id", "slug"],
|
|
513
|
+
taxonomy: ["id"],
|
|
514
|
+
media: ["id", "slug"],
|
|
515
|
+
post: ["id", "slug"],
|
|
516
|
+
content: ["id", "slug"],
|
|
517
|
+
[COLLECTION_MARKER_TYPE]: ["id", "typeKey"],
|
|
518
|
+
[MENU_MARKER_TYPE]: ["id"],
|
|
519
|
+
[PARTIAL_MARKER_TYPE]: ["id"],
|
|
520
|
+
[POST_PREVIEWS_MARKER_TYPE]: [],
|
|
521
|
+
};
|
|
522
|
+
/** The default target-key list for a type absent from {@link EMBED_MARKER_TARGET_KEYS} — an unknown
|
|
523
|
+
* or typo'd type is assumed id-addressable like most types, but never slug-addressable: no resolver
|
|
524
|
+
* has ever registered to read a slug for it, so claiming one would resolve is always wrong. */
|
|
525
|
+
const UNKNOWN_TYPE_TARGET_KEYS = ["id"];
|
|
526
|
+
/** `value` trimmed down to a usable target string, or `undefined` if it is not a non-empty string of
|
|
527
|
+
* at most {@link MAX_TARGET_VALUE_LENGTH} characters — the same absent-vs-present rule
|
|
528
|
+
* `features/widgets/html-embeds.ts`'s `normalizeEmbedId`/`normalizeEmbedSlug` apply to their own
|
|
529
|
+
* config reads. @complexity O(1). */
|
|
530
|
+
function normalizeTargetValue(value) {
|
|
531
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_TARGET_VALUE_LENGTH)
|
|
532
|
+
return undefined;
|
|
533
|
+
return value;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Which `data-embed-config` key names `type`'s resolution target, and that key's value, per
|
|
537
|
+
* {@link EMBED_MARKER_TARGET_KEYS}'s precedence order. Three distinct outcomes:
|
|
538
|
+
*
|
|
539
|
+
* - `{ key, value }` — the first target key present with a usable string value.
|
|
540
|
+
* - `{ key: "none" }` — this type ({@link POST_PREVIEWS_MARKER_TYPE}) needs no target; a caller must
|
|
541
|
+
* never read this as "target missing".
|
|
542
|
+
* - `undefined` — the type has one or more target keys, but the marker's config supplies none of them
|
|
543
|
+
* (or only with an unusable value per {@link normalizeTargetValue}) — "target missing".
|
|
544
|
+
*
|
|
545
|
+
* `type` is compared lower-cased, matching `toEmbedRef`'s own `marker.type.toLowerCase()` in
|
|
546
|
+
* `resolver-service.ts` — a hand-authored `"Widget"` must resolve exactly as `"widget"` does.
|
|
547
|
+
*
|
|
548
|
+
* @complexity O(k) in the number of candidate keys for `type` (at most 2 today) — independent of the
|
|
549
|
+
* surrounding document's size.
|
|
550
|
+
*/
|
|
551
|
+
export function embedMarkerTarget({ type, config }, _optional = {}) {
|
|
552
|
+
const keys = EMBED_MARKER_TARGET_KEYS[type.toLowerCase()] ?? UNKNOWN_TYPE_TARGET_KEYS;
|
|
553
|
+
if (keys.length === 0)
|
|
554
|
+
return { key: "none" };
|
|
555
|
+
for (const key of keys) {
|
|
556
|
+
const value = normalizeTargetValue(config[key]);
|
|
557
|
+
if (value !== undefined)
|
|
558
|
+
return { key, value };
|
|
559
|
+
}
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* The "Copy embed code" snippet for any marker type: the same `<div data-embed-config='{"type":…,
|
|
564
|
+
* "<key>":"<value>"}'></div>` wrapper {@link scanEmbedMarkers}/{@link parseEmbedMarkerConfig} already
|
|
565
|
+
* parse back — generalised (readable-slugs S5b) out of `apps/admin/src/features/collections/rules.ts`'s
|
|
566
|
+
* `collectionEmbedSnippet`, the one admin-generated marker that existed before this: that function is
|
|
567
|
+
* now a one-line call onto this (`embedMarkerSnippet("collection", "id", key)`), byte-for-byte
|
|
568
|
+
* unchanged. `features/media`'s own "Copy embed code" button (S5a) is the second caller, with
|
|
569
|
+
* `("media", "slug", asset.slug)` — see {@link EMBED_MARKER_TARGET_KEYS}'s `media` row for why `slug`
|
|
570
|
+
* is a real, resolvable target key for this type.
|
|
571
|
+
*
|
|
572
|
+
* No escaping beyond the plain template: `key`/`value` are expected to already be validated,
|
|
573
|
+
* quote-free identifiers (a slug, a type key, an id) by the time a caller reaches for a copy button —
|
|
574
|
+
* the same assumption `collectionEmbedSnippet` always made.
|
|
575
|
+
*
|
|
576
|
+
* @complexity O(1) — one template string.
|
|
577
|
+
*/
|
|
578
|
+
export function embedMarkerSnippet({ type, key, value }, _optional = {}) {
|
|
579
|
+
return `<div data-embed-config='{"type":"${type}","${key}":"${value}"}'></div>`;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Rebuild a marker's element around new inner content, keeping its own tag and every authored
|
|
583
|
+
* attribute (`class`, `aria-label`, …). The counterpart to a wholesale replace: a menu marker keeps
|
|
584
|
+
* its `<nav class="docs-nav">` wrapper and only swaps what's inside, whereas a partial slot marker
|
|
585
|
+
* disappears entirely and is replaced by the partial.
|
|
586
|
+
*
|
|
587
|
+
* Synthesizing the open tag from `config` alone instead of using this is a silent bug — it drops the
|
|
588
|
+
* theme's styling hooks and accessible names with nothing failing.
|
|
589
|
+
*/
|
|
590
|
+
export function withInnerContent({ marker, inner }, _optional = {}) {
|
|
591
|
+
return `<${marker.tag}${marker.attrs}>${inner}</${marker.tag}>`;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Rebuild a marker's element with `id` added to its `data-embed-config`, keeping every other
|
|
595
|
+
* authored config key, the marker's own tag, its other attributes, and its authored inner content
|
|
596
|
+
* ({@link EmbedMarker.inner}) completely unchanged. The counterpart to {@link withInnerContent} for a
|
|
597
|
+
* caller that needs to SUPPLY a marker's target rather than its content — e.g. the unified `content`
|
|
598
|
+
* marker (2026-08-11): a theme-authored `{"type":"content"}` with no `id` means "the entity the route
|
|
599
|
+
* already resolved", and the route fills that id in here before the marker is ever resolved, so every
|
|
600
|
+
* downstream consumer (the scanner, the resolver, a later re-scan) sees an ordinary id-carrying marker
|
|
601
|
+
* rather than needing its own "no id means current entity" special case.
|
|
602
|
+
*
|
|
603
|
+
* Callers must check `marker.id === undefined` themselves before calling this — an author's own
|
|
604
|
+
* explicit id always wins over a caller-supplied default, and this function does not re-check that
|
|
605
|
+
* (it would silently overwrite an explicit reference otherwise, which is the one thing the unified
|
|
606
|
+
* marker's id-vs-no-id contract must never do).
|
|
607
|
+
*
|
|
608
|
+
* @complexity O(n) over the marker's own attrs length (one JSON stringify of a small object, one
|
|
609
|
+
* regex replace over `attrs`) — independent of the surrounding document's size.
|
|
610
|
+
*/
|
|
611
|
+
export function withAddedId({ marker, id }, _optional = {}) {
|
|
612
|
+
const config = JSON.stringify({ ...marker.config, id });
|
|
613
|
+
// Keeps the marker's own quoting: a double-quoted value is re-encoded, a single-quoted one is not.
|
|
614
|
+
const attrsWithId = marker.attrs.replace(MARKER_ATTRIBUTE_PATTERN, (_whole, doubleQuoted) => doubleQuoted === undefined ? ` data-embed-config='${config}'` : ` data-embed-config="${encodeDoubleQuotedValue(config)}"`);
|
|
615
|
+
return `<${marker.tag}${attrsWithId}>${marker.inner}</${marker.tag}>`;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Rebuild a marker's element around new inner content, like {@link withInnerContent}, but ALSO strips
|
|
619
|
+
* the marker's own `data-embed-config` attribute from the rebuilt tag — every other authored attribute
|
|
620
|
+
* (`class`, `aria-label`, …) survives, only the marker-ness is removed.
|
|
621
|
+
*
|
|
622
|
+
* For a caller that performs its OWN final resolution outside the shared registry/render pipeline
|
|
623
|
+
* (`pages.ts`'s recursive `content`-marker pre-splice, which fetches and inlines an `"html"`-format
|
|
624
|
+
* entity's own body ahead of the normal `resolveHtmlPageEmbeds`/`renderHtmlPageBody` pass) and must
|
|
625
|
+
* guarantee the result is not rediscovered and re-resolved by a LATER marker scan over the same output
|
|
626
|
+
* — an ordinary {@link withInnerContent} splice would leave `data-embed-config` intact, and the next
|
|
627
|
+
* scan would see what looks like a fresh, unresolved marker and try to resolve it again.
|
|
628
|
+
*
|
|
629
|
+
* @complexity O(n) over the marker's own attrs length (one regex replace) — independent of the
|
|
630
|
+
* surrounding document's size.
|
|
631
|
+
*/
|
|
632
|
+
export function withInnerContentFinal({ marker, inner }, _optional = {}) {
|
|
633
|
+
const attrsWithoutMarker = marker.attrs.replace(MARKER_ATTRIBUTE_PATTERN, "");
|
|
634
|
+
return `<${marker.tag}${attrsWithoutMarker}>${inner}</${marker.tag}>`;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Replace each marker's WHOLE element with what `resolve` returns for it.
|
|
638
|
+
*
|
|
639
|
+
* Whole-element rather than inner-content because the two consumers genuinely differ: a partial slot
|
|
640
|
+
* marker is scaffolding that vanishes once the partial is spliced in, while a menu marker is real
|
|
641
|
+
* theme markup that must survive with only its contents swapped. Callers in the second camp wrap
|
|
642
|
+
* their output in {@link withInnerContent}; making that explicit at the call site is better than a
|
|
643
|
+
* mode flag, because the choice is a real per-type decision and not a preference.
|
|
644
|
+
*
|
|
645
|
+
* `resolve` returning `undefined` means "leave this marker exactly as authored", and that default is
|
|
646
|
+
* load-bearing: a menu that does not exist, a target that was deleted, or a render that produces
|
|
647
|
+
* nothing must fall back to the theme's authored content rather than blanking a nav. Every consumer
|
|
648
|
+
* wants that rule, so it lives here rather than being re-implemented per call site.
|
|
649
|
+
*
|
|
650
|
+
* Applies right-to-left so each splice leaves the earlier markers' offsets valid.
|
|
651
|
+
*/
|
|
652
|
+
export function substituteMarkers({ html, resolve }, _optional = {}) {
|
|
653
|
+
const { markers } = scanEmbedMarkers({ html: html });
|
|
654
|
+
let out = html;
|
|
655
|
+
for (let i = markers.length - 1; i >= 0; i -= 1) {
|
|
656
|
+
// The reverse loop keeps i within the scanner result.
|
|
657
|
+
const m = markers[i];
|
|
658
|
+
const replacement = resolve(m);
|
|
659
|
+
if (replacement === undefined)
|
|
660
|
+
continue;
|
|
661
|
+
out = out.slice(0, m.index) + replacement + out.slice(m.index + m.whole.length);
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* The shape every attribute name {@link parseMarkerAttributes} accepts must have — deliberately
|
|
667
|
+
* narrower than the full HTML spec's own name production, sized for real attribute names (`class`,
|
|
668
|
+
* `data-*`, `aria-*`, `autoplay`, …), the same "narrower on purpose" discipline
|
|
669
|
+
* `@jini-ai/cms/media`'s `html-attributes.ts` applies to its own allowlisted names. A token whose name
|
|
670
|
+
* fails this is skipped, not thrown — this parser runs over already-authored page HTML (page trust,
|
|
671
|
+
* no allowlist; see the 2026-09-16 embed-attributes plan §0), so "not a well-formed attribute name" is
|
|
672
|
+
* a shape problem this parser degrades past, not a security boundary.
|
|
673
|
+
*/
|
|
674
|
+
const MARKER_ATTRIBUTE_NAME_PATTERN = /^[a-z_:][-a-z0-9_:.]*$/;
|
|
675
|
+
/**
|
|
676
|
+
* Quote-aware attribute-token scanner: matches one `name`, or one `name=unquoted`, `name="value"`, or
|
|
677
|
+
* `name='value'` pair. The identical loose shape `@jini-ai/cms/media`'s `html-attributes.ts` uses for
|
|
678
|
+
* its own `HTML_ATTRIBUTE_TOKEN` — quote-aware so a `data-embed-config='{"type":"media"}'` token
|
|
679
|
+
* (double quotes living inside a single-quoted value) is consumed as ONE token rather than corrupting
|
|
680
|
+
* the scan at its first interior `"`.
|
|
681
|
+
*/
|
|
682
|
+
const MARKER_ATTRIBUTE_TOKEN = /([^\s="']+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/g;
|
|
683
|
+
/**
|
|
684
|
+
* Parses a marker's verbatim {@link EmbedMarker.attrs} text into structured attributes, in authored
|
|
685
|
+
* order, with `data-embed-config` itself removed (it is the marker, not an attribute a caller would
|
|
686
|
+
* ever want to forward) — the read half of the 2026-09-16 embed-attributes contract:
|
|
687
|
+
* {@link formatMarkerAttributes} is its inverse.
|
|
688
|
+
*
|
|
689
|
+
* - Names are lowercased (HTML attribute names are case-insensitive).
|
|
690
|
+
* - A token whose (lowercased) name is not a valid attribute name per
|
|
691
|
+
* {@link MARKER_ATTRIBUTE_NAME_PATTERN} is skipped, never thrown.
|
|
692
|
+
* - A duplicate name keeps its FIRST occurrence — the same rule a real HTML parser applies, so this
|
|
693
|
+
* parser's result matches what a browser would actually expose on the element.
|
|
694
|
+
* - Every value is kept as SOURCE TEXT: never entity-decoded. Re-decoding `&` here and
|
|
695
|
+
* re-escaping on output (see {@link formatMarkerAttributes}) would double-escape a value like
|
|
696
|
+
* `title="a & b"` into `&amp;` — this module never round-trips through a decoded form.
|
|
697
|
+
*
|
|
698
|
+
* @complexity O(n) in the marker's own attrs length (one regex pass) — independent of the surrounding
|
|
699
|
+
* document's size.
|
|
700
|
+
*/
|
|
701
|
+
export function parseMarkerAttributes({ marker }, _optional = {}) {
|
|
702
|
+
const seenNames = new Set();
|
|
703
|
+
const attributes = [];
|
|
704
|
+
MARKER_ATTRIBUTE_TOKEN.lastIndex = 0;
|
|
705
|
+
let match;
|
|
706
|
+
while ((match = MARKER_ATTRIBUTE_TOKEN.exec(marker.attrs)) !== null) {
|
|
707
|
+
const name = match[1].toLowerCase();
|
|
708
|
+
if (name === "data-embed-config")
|
|
709
|
+
continue;
|
|
710
|
+
if (!MARKER_ATTRIBUTE_NAME_PATTERN.test(name))
|
|
711
|
+
continue;
|
|
712
|
+
if (seenNames.has(name))
|
|
713
|
+
continue;
|
|
714
|
+
seenNames.add(name);
|
|
715
|
+
const value = match[2] ?? match[3] ?? match[4] ?? null;
|
|
716
|
+
attributes.push({ name, value });
|
|
717
|
+
}
|
|
718
|
+
return attributes;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Formats parsed attributes back into attribute-string text, the inverse of
|
|
722
|
+
* {@link parseMarkerAttributes}: a valueless attribute is emitted as a bare ` name`; a valued one as
|
|
723
|
+
* ` name="value"`. The ONLY transform applied to a value is `"` -> `"` (a value may itself
|
|
724
|
+
* contain a literal `"`, e.g. carried over from a single-quoted authored attribute) — `&` is never
|
|
725
|
+
* touched, so source text that already reads `&` is emitted unchanged rather than
|
|
726
|
+
* double-escaping into `&amp;`.
|
|
727
|
+
*
|
|
728
|
+
* @complexity O(n) in the total length of every attribute's name and value.
|
|
729
|
+
*/
|
|
730
|
+
export function formatMarkerAttributes({ attrs }, _optional = {}) {
|
|
731
|
+
return attrs.map((attr) => (attr.value === null ? ` ${attr.name}` : ` ${attr.name}="${attr.value.replace(/"/g, """)}"`)).join("");
|
|
732
|
+
}
|
|
733
|
+
/** Whether a marker carries any authored attribute besides `data-embed-config` itself — the "bare
|
|
734
|
+
* marker" test {@link withElementKeptIfAttributed} uses to decide whether an element survives a
|
|
735
|
+
* whole-marker substitution.
|
|
736
|
+
*
|
|
737
|
+
* @complexity O(n) in the marker's own attrs length (delegates to {@link parseMarkerAttributes}).
|
|
738
|
+
*/
|
|
739
|
+
export function hasAuthoredAttributes({ marker }, _optional = {}) {
|
|
740
|
+
return parseMarkerAttributes({ marker: marker }).length > 0;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Rebuild a marker's element around new inner content using an EXPLICIT attribute list (rather than
|
|
744
|
+
* the marker's own verbatim {@link EmbedMarker.attrs}) — for a caller that has partitioned a marker's
|
|
745
|
+
* authored attributes and needs only a subset on the rebuilt wrapper (e.g. {@link substituteHtmlEmbeds}
|
|
746
|
+
* moving media-element attribute names off a non-media wrapper onto the resolved `<video>`/`<img>`,
|
|
747
|
+
* keeping the rest on the wrapper). The counterpart to {@link withInnerContent}, which always keeps
|
|
748
|
+
* every authored attribute verbatim.
|
|
749
|
+
*/
|
|
750
|
+
export function withInnerContentAndAttributes({ marker, attrs, inner }, _optional = {}) {
|
|
751
|
+
return `<${marker.tag}${formatMarkerAttributes({ attrs: attrs })}>${inner}</${marker.tag}>`;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Whole-element replace UNLESS the marker carries authored attributes, in which case its element
|
|
755
|
+
* survives (minus `data-embed-config`) around the resolved content — the 2026-09-16 rule for marker
|
|
756
|
+
* types (`widget`, `partial`) that default to disappearing entirely: a bare marker keeps disappearing,
|
|
757
|
+
* byte-identical to before, while an attributed one keeps whatever styling/accessibility hook the
|
|
758
|
+
* author put on it. Delegates to {@link withInnerContentFinal} for the attributed case so the kept
|
|
759
|
+
* wrapper's attribute text is the marker's own verbatim {@link EmbedMarker.attrs} (not a
|
|
760
|
+
* parse-then-reformat round trip) — the same byte-identical-when-nothing-moves discipline
|
|
761
|
+
* {@link withInnerContentFinal} itself already provides.
|
|
762
|
+
*/
|
|
763
|
+
export function withElementKeptIfAttributed({ marker, inner }, _optional = {}) {
|
|
764
|
+
return hasAuthoredAttributes({ marker: marker }) ? withInnerContentFinal({ marker: marker, inner: inner }) : inner;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Human-readable one-liner for a rejection, for a warning line or a write-time validation error.
|
|
768
|
+
* Deliberately quotes the offending JSON: the author needs to see what they typed, and the raw text
|
|
769
|
+
* is theme- or admin-authored, never end-user input.
|
|
770
|
+
*/
|
|
771
|
+
export function describeRejection({ rejection }, _optional = {}) {
|
|
772
|
+
const { problem, occurrence } = rejection;
|
|
773
|
+
const where = `embed marker #${occurrence}`;
|
|
774
|
+
if (problem.kind === "invalid-json")
|
|
775
|
+
return `${where}: data-embed-config is not valid JSON (${problem.message}) — ${problem.raw}`;
|
|
776
|
+
if (problem.kind === "not-an-object")
|
|
777
|
+
return `${where}: data-embed-config must be a JSON object — ${problem.raw}`;
|
|
778
|
+
return `${where}: data-embed-config is missing a "type" — ${problem.raw}`;
|
|
779
|
+
}
|
|
780
|
+
//# sourceMappingURL=marker.js.map
|