@jini-ai/cms 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +14 -8
  3. package/dist/comments/errors.d.ts +23 -0
  4. package/dist/comments/errors.js +25 -0
  5. package/dist/comments/express/index.d.ts +2 -0
  6. package/dist/comments/express/index.js +2 -0
  7. package/dist/comments/express/submit-handler.d.ts +30 -0
  8. package/dist/comments/express/submit-handler.js +85 -0
  9. package/dist/comments/hooks.d.ts +39 -0
  10. package/dist/comments/hooks.js +41 -0
  11. package/dist/comments/index.d.ts +11 -0
  12. package/dist/comments/index.js +11 -0
  13. package/dist/comments/ingress.d.ts +57 -0
  14. package/dist/comments/ingress.js +173 -0
  15. package/dist/comments/module.d.ts +94 -0
  16. package/dist/comments/module.js +73 -0
  17. package/dist/comments/ports.d.ts +184 -0
  18. package/dist/comments/ports.js +2 -0
  19. package/dist/comments/repo.memory.d.ts +70 -0
  20. package/dist/comments/repo.memory.js +120 -0
  21. package/dist/comments/sanitize.d.ts +15 -0
  22. package/dist/comments/sanitize.js +31 -0
  23. package/dist/comments/spam.heuristic.d.ts +8 -0
  24. package/dist/comments/spam.heuristic.js +54 -0
  25. package/dist/comments/sql/index.d.ts +3 -0
  26. package/dist/comments/sql/index.js +3 -0
  27. package/dist/comments/sql/repo.d.ts +77 -0
  28. package/dist/comments/sql/repo.js +190 -0
  29. package/dist/comments/sql/repo.rows.d.ts +49 -0
  30. package/dist/comments/sql/repo.rows.js +71 -0
  31. package/dist/comments/types.d.ts +187 -0
  32. package/dist/comments/types.js +39 -0
  33. package/dist/comments/write-service.d.ts +121 -0
  34. package/dist/comments/write-service.js +144 -0
  35. package/dist/core/commands/command.d.ts +2 -9
  36. package/dist/core/commands/command.js +2 -9
  37. package/dist/core/index.d.ts +2 -1
  38. package/dist/core/index.js +1 -0
  39. package/dist/core/keyset-cursor.d.ts +25 -0
  40. package/dist/core/keyset-cursor.js +20 -0
  41. package/dist/core/ports.d.ts +30 -0
  42. package/dist/core/ports.js +0 -1
  43. package/dist/core/tools/args.d.ts +1 -46
  44. package/dist/core/tools/args.js +1 -44
  45. package/dist/media/index.d.ts +0 -3
  46. package/dist/media/index.js +1 -4
  47. package/dist/media/node.d.ts +5 -0
  48. package/dist/media/node.js +6 -0
  49. package/dist/redirects/capture.d.ts +80 -0
  50. package/dist/redirects/capture.js +66 -0
  51. package/dist/redirects/express/index.d.ts +27 -0
  52. package/dist/redirects/express/index.js +40 -0
  53. package/dist/redirects/hit-sink.d.ts +56 -0
  54. package/dist/redirects/hit-sink.js +44 -0
  55. package/dist/redirects/host-config/index.d.ts +40 -0
  56. package/dist/redirects/host-config/index.js +145 -0
  57. package/dist/redirects/index.d.ts +14 -0
  58. package/dist/redirects/index.js +14 -0
  59. package/dist/redirects/matcher.d.ts +58 -0
  60. package/dist/redirects/matcher.js +144 -0
  61. package/dist/redirects/order.d.ts +30 -0
  62. package/dist/redirects/order.js +26 -0
  63. package/dist/redirects/ports.d.ts +242 -0
  64. package/dist/redirects/ports.internal.d.ts +57 -0
  65. package/dist/redirects/ports.internal.js +14 -0
  66. package/dist/redirects/ports.js +2 -0
  67. package/dist/redirects/redirects.d.ts +226 -0
  68. package/dist/redirects/redirects.js +590 -0
  69. package/dist/redirects/referrer-alias.d.ts +31 -0
  70. package/dist/redirects/referrer-alias.js +33 -0
  71. package/dist/redirects/repo.memory.d.ts +51 -0
  72. package/dist/redirects/repo.memory.js +98 -0
  73. package/dist/redirects/reserved-destination.d.ts +58 -0
  74. package/dist/redirects/reserved-destination.js +59 -0
  75. package/dist/redirects/resolver.d.ts +100 -0
  76. package/dist/redirects/resolver.js +87 -0
  77. package/dist/redirects/spa/index.d.ts +30 -0
  78. package/dist/redirects/spa/index.js +27 -0
  79. package/dist/redirects/sql/index.d.ts +4 -0
  80. package/dist/redirects/sql/index.js +4 -0
  81. package/dist/redirects/sql/repo.d.ts +82 -0
  82. package/dist/redirects/sql/repo.js +143 -0
  83. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  84. package/dist/redirects/sql/repo.rows.js +79 -0
  85. package/dist/redirects/sql/row-types.d.ts +34 -0
  86. package/dist/redirects/sql/row-types.js +2 -0
  87. package/dist/redirects/target-gate.d.ts +30 -0
  88. package/dist/redirects/target-gate.js +85 -0
  89. package/dist/redirects/types.d.ts +213 -0
  90. package/dist/redirects/types.js +42 -0
  91. package/dist/seo/absolute-url.d.ts +58 -0
  92. package/dist/seo/absolute-url.js +117 -0
  93. package/dist/seo/dom/index.d.ts +14 -0
  94. package/dist/seo/dom/index.js +52 -0
  95. package/dist/seo/errors.d.ts +35 -0
  96. package/dist/seo/errors.js +35 -0
  97. package/dist/seo/feed.d.ts +49 -0
  98. package/dist/seo/feed.js +61 -0
  99. package/dist/seo/index.d.ts +13 -0
  100. package/dist/seo/index.js +13 -0
  101. package/dist/seo/media.d.ts +57 -0
  102. package/dist/seo/media.js +134 -0
  103. package/dist/seo/page-head.d.ts +18 -0
  104. package/dist/seo/page-head.js +114 -0
  105. package/dist/seo/ports.d.ts +221 -0
  106. package/dist/seo/ports.js +27 -0
  107. package/dist/seo/seo.d.ts +73 -0
  108. package/dist/seo/seo.js +230 -0
  109. package/dist/seo/sitemap.d.ts +139 -0
  110. package/dist/seo/sitemap.js +217 -0
  111. package/dist/seo/types.d.ts +278 -0
  112. package/dist/seo/types.js +2 -0
  113. package/dist/seo/write-service.d.ts +79 -0
  114. package/dist/seo/write-service.js +273 -0
  115. package/dist/seo/xml-escape.d.ts +16 -0
  116. package/dist/seo/xml-escape.js +19 -0
  117. package/dist/settings/express/cms-adapter.d.ts +20 -0
  118. package/dist/settings/express/cms-adapter.js +124 -0
  119. package/dist/settings/express/contracts.d.ts +135 -0
  120. package/dist/settings/express/contracts.js +2 -0
  121. package/dist/settings/express/http.d.ts +10 -0
  122. package/dist/settings/express/http.js +211 -0
  123. package/dist/settings/express/index.d.ts +5 -0
  124. package/dist/settings/express/index.js +5 -0
  125. package/dist/settings/express/shared.d.ts +68 -0
  126. package/dist/settings/express/shared.js +54 -0
  127. package/dist/settings/express/stream.d.ts +24 -0
  128. package/dist/settings/express/stream.js +147 -0
  129. package/dist/settings/index.d.ts +3 -38
  130. package/dist/settings/index.js +3 -35
  131. package/dist/taxonomy/write-service.d.ts +2 -30
  132. package/dist/widgets/authorize-helper.d.ts +42 -0
  133. package/dist/widgets/authorize-helper.js +16 -0
  134. package/dist/widgets/concurrency.d.ts +12 -0
  135. package/dist/widgets/concurrency.js +44 -0
  136. package/dist/widgets/config-validation.d.ts +34 -0
  137. package/dist/widgets/config-validation.js +125 -0
  138. package/dist/widgets/embed-service.d.ts +133 -0
  139. package/dist/widgets/embed-service.js +460 -0
  140. package/dist/widgets/embed-validation.d.ts +23 -0
  141. package/dist/widgets/embed-validation.js +38 -0
  142. package/dist/widgets/entry-payload.d.ts +103 -0
  143. package/dist/widgets/entry-payload.js +160 -0
  144. package/dist/widgets/errors.d.ts +136 -0
  145. package/dist/widgets/errors.js +136 -0
  146. package/dist/widgets/host-ports.d.ts +149 -0
  147. package/dist/widgets/host-ports.js +2 -0
  148. package/dist/widgets/html/html-embeds.d.ts +247 -0
  149. package/dist/widgets/html/html-embeds.js +408 -0
  150. package/dist/widgets/html/index.d.ts +5 -0
  151. package/dist/widgets/html/index.js +4 -0
  152. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  153. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  154. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  155. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  156. package/dist/widgets/index.d.ts +20 -0
  157. package/dist/widgets/index.js +19 -0
  158. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  159. package/dist/widgets/markers/html-attributes.js +216 -0
  160. package/dist/widgets/markers/index.d.ts +3 -0
  161. package/dist/widgets/markers/index.js +3 -0
  162. package/dist/widgets/markers/marker.d.ts +496 -0
  163. package/dist/widgets/markers/marker.js +780 -0
  164. package/dist/widgets/page-embed-types.d.ts +42 -0
  165. package/dist/widgets/page-embed-types.js +42 -0
  166. package/dist/widgets/ports.d.ts +142 -0
  167. package/dist/widgets/ports.js +2 -0
  168. package/dist/widgets/read-service.d.ts +81 -0
  169. package/dist/widgets/read-service.js +79 -0
  170. package/dist/widgets/region-area-service.d.ts +110 -0
  171. package/dist/widgets/region-area-service.js +180 -0
  172. package/dist/widgets/registry.d.ts +217 -0
  173. package/dist/widgets/registry.js +172 -0
  174. package/dist/widgets/repo.memory.d.ts +41 -0
  175. package/dist/widgets/repo.memory.js +48 -0
  176. package/dist/widgets/resolver-service.d.ts +209 -0
  177. package/dist/widgets/resolver-service.js +909 -0
  178. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  179. package/dist/widgets/resolvers/contact-form.js +54 -0
  180. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  181. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  182. package/dist/widgets/resolvers/index.d.ts +65 -0
  183. package/dist/widgets/resolvers/index.js +155 -0
  184. package/dist/widgets/resolvers/menu.d.ts +42 -0
  185. package/dist/widgets/resolvers/menu.js +43 -0
  186. package/dist/widgets/resolvers/ports.d.ts +82 -0
  187. package/dist/widgets/resolvers/ports.js +2 -0
  188. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  189. package/dist/widgets/resolvers/recent-entries.js +165 -0
  190. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  191. package/dist/widgets/resolvers/taxonomy.js +34 -0
  192. package/dist/widgets/sql/index.d.ts +3 -0
  193. package/dist/widgets/sql/index.js +3 -0
  194. package/dist/widgets/sql/repo.d.ts +41 -0
  195. package/dist/widgets/sql/repo.js +82 -0
  196. package/dist/widgets/sql/tables.d.ts +13 -0
  197. package/dist/widgets/sql/tables.js +2 -0
  198. package/dist/widgets/types.d.ts +213 -0
  199. package/dist/widgets/types.js +17 -0
  200. package/dist/widgets/where-used.d.ts +41 -0
  201. package/dist/widgets/where-used.js +11 -0
  202. package/dist/widgets/write-service.d.ts +204 -0
  203. package/dist/widgets/write-service.js +332 -0
  204. package/dist/workspace/delete.d.ts +1 -1
  205. package/package.json +220 -11
@@ -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="{&quot;type&quot;:…}"`, 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: `&quot;`/`&amp;`/`&apos;`/`&lt;`/`&gt;`/
80
+ * `&nbsp;` 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, "&amp;").replace(/"/g, "&quot;");
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 `&#39;`/`&amp;`: {@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="{&quot;type&quot;:…}"` (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 `&amp;` here and
695
+ * re-escaping on output (see {@link formatMarkerAttributes}) would double-escape a value like
696
+ * `title="a &amp; b"` into `&amp;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 `"` -> `&quot;` (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 `&amp;` is emitted unchanged rather than
726
+ * double-escaping into `&amp;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, "&quot;")}"`)).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