@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.
Files changed (261) hide show
  1. package/CHANGELOG.md +15 -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/forms/delete-submission.d.ts +38 -0
  46. package/dist/forms/delete-submission.js +25 -0
  47. package/dist/forms/duplicate-slug.d.ts +81 -0
  48. package/dist/forms/duplicate-slug.js +115 -0
  49. package/dist/forms/errors.d.ts +53 -0
  50. package/dist/forms/errors.js +25 -0
  51. package/dist/forms/express/handler.d.ts +59 -0
  52. package/dist/forms/express/handler.js +217 -0
  53. package/dist/forms/express/index.d.ts +2 -0
  54. package/dist/forms/express/index.js +2 -0
  55. package/dist/forms/fields-json.d.ts +14 -0
  56. package/dist/forms/fields-json.js +11 -0
  57. package/dist/forms/forms.d.ts +59 -0
  58. package/dist/forms/forms.js +213 -0
  59. package/dist/forms/html/authoring-adapter.d.ts +34 -0
  60. package/dist/forms/html/authoring-adapter.js +48 -0
  61. package/dist/forms/html/html-authoring.d.ts +12 -0
  62. package/dist/forms/html/html-authoring.js +133 -0
  63. package/dist/forms/html/html-submission-adapter.d.ts +10 -0
  64. package/dist/forms/html/html-submission-adapter.js +15 -0
  65. package/dist/forms/html/index.d.ts +4 -0
  66. package/dist/forms/html/index.js +4 -0
  67. package/dist/forms/html-render.d.ts +33 -0
  68. package/dist/forms/html-render.js +31 -0
  69. package/dist/forms/index.d.ts +17 -0
  70. package/dist/forms/index.js +17 -0
  71. package/dist/forms/notify-subscriber.d.ts +32 -0
  72. package/dist/forms/notify-subscriber.js +60 -0
  73. package/dist/forms/ports.d.ts +168 -0
  74. package/dist/forms/ports.js +2 -0
  75. package/dist/forms/rate-limit-key.d.ts +8 -0
  76. package/dist/forms/rate-limit-key.js +7 -0
  77. package/dist/forms/repo.memory.d.ts +120 -0
  78. package/dist/forms/repo.memory.js +188 -0
  79. package/dist/forms/sql/index.d.ts +5 -0
  80. package/dist/forms/sql/index.js +5 -0
  81. package/dist/forms/sql/repo.d.ts +94 -0
  82. package/dist/forms/sql/repo.js +175 -0
  83. package/dist/forms/sql/repo.rows.d.ts +33 -0
  84. package/dist/forms/sql/repo.rows.js +64 -0
  85. package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
  86. package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
  87. package/dist/forms/sql/tables.d.ts +32 -0
  88. package/dist/forms/sql/tables.js +2 -0
  89. package/dist/forms/submission-attempts.d.ts +70 -0
  90. package/dist/forms/submission-attempts.js +70 -0
  91. package/dist/forms/submission-email-validation.d.ts +8 -0
  92. package/dist/forms/submission-email-validation.js +34 -0
  93. package/dist/forms/submission-ip-retention.d.ts +30 -0
  94. package/dist/forms/submission-ip-retention.js +27 -0
  95. package/dist/forms/submit-service.d.ts +78 -0
  96. package/dist/forms/submit-service.js +109 -0
  97. package/dist/forms/types.d.ts +49 -0
  98. package/dist/forms/types.js +2 -0
  99. package/dist/forms/write-service.d.ts +83 -0
  100. package/dist/forms/write-service.js +271 -0
  101. package/dist/media/index.d.ts +0 -3
  102. package/dist/media/index.js +1 -4
  103. package/dist/media/node.d.ts +5 -0
  104. package/dist/media/node.js +6 -0
  105. package/dist/redirects/capture.d.ts +80 -0
  106. package/dist/redirects/capture.js +66 -0
  107. package/dist/redirects/express/index.d.ts +27 -0
  108. package/dist/redirects/express/index.js +40 -0
  109. package/dist/redirects/hit-sink.d.ts +56 -0
  110. package/dist/redirects/hit-sink.js +44 -0
  111. package/dist/redirects/host-config/index.d.ts +40 -0
  112. package/dist/redirects/host-config/index.js +145 -0
  113. package/dist/redirects/index.d.ts +14 -0
  114. package/dist/redirects/index.js +14 -0
  115. package/dist/redirects/matcher.d.ts +58 -0
  116. package/dist/redirects/matcher.js +144 -0
  117. package/dist/redirects/order.d.ts +30 -0
  118. package/dist/redirects/order.js +26 -0
  119. package/dist/redirects/ports.d.ts +242 -0
  120. package/dist/redirects/ports.internal.d.ts +57 -0
  121. package/dist/redirects/ports.internal.js +14 -0
  122. package/dist/redirects/ports.js +2 -0
  123. package/dist/redirects/redirects.d.ts +226 -0
  124. package/dist/redirects/redirects.js +590 -0
  125. package/dist/redirects/referrer-alias.d.ts +31 -0
  126. package/dist/redirects/referrer-alias.js +33 -0
  127. package/dist/redirects/repo.memory.d.ts +51 -0
  128. package/dist/redirects/repo.memory.js +98 -0
  129. package/dist/redirects/reserved-destination.d.ts +58 -0
  130. package/dist/redirects/reserved-destination.js +59 -0
  131. package/dist/redirects/resolver.d.ts +100 -0
  132. package/dist/redirects/resolver.js +87 -0
  133. package/dist/redirects/spa/index.d.ts +30 -0
  134. package/dist/redirects/spa/index.js +27 -0
  135. package/dist/redirects/sql/index.d.ts +4 -0
  136. package/dist/redirects/sql/index.js +4 -0
  137. package/dist/redirects/sql/repo.d.ts +82 -0
  138. package/dist/redirects/sql/repo.js +143 -0
  139. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  140. package/dist/redirects/sql/repo.rows.js +79 -0
  141. package/dist/redirects/sql/row-types.d.ts +34 -0
  142. package/dist/redirects/sql/row-types.js +2 -0
  143. package/dist/redirects/target-gate.d.ts +30 -0
  144. package/dist/redirects/target-gate.js +85 -0
  145. package/dist/redirects/types.d.ts +213 -0
  146. package/dist/redirects/types.js +42 -0
  147. package/dist/seo/absolute-url.d.ts +58 -0
  148. package/dist/seo/absolute-url.js +117 -0
  149. package/dist/seo/dom/index.d.ts +14 -0
  150. package/dist/seo/dom/index.js +52 -0
  151. package/dist/seo/errors.d.ts +35 -0
  152. package/dist/seo/errors.js +35 -0
  153. package/dist/seo/feed.d.ts +49 -0
  154. package/dist/seo/feed.js +61 -0
  155. package/dist/seo/index.d.ts +13 -0
  156. package/dist/seo/index.js +13 -0
  157. package/dist/seo/media.d.ts +57 -0
  158. package/dist/seo/media.js +134 -0
  159. package/dist/seo/page-head.d.ts +18 -0
  160. package/dist/seo/page-head.js +114 -0
  161. package/dist/seo/ports.d.ts +221 -0
  162. package/dist/seo/ports.js +27 -0
  163. package/dist/seo/seo.d.ts +73 -0
  164. package/dist/seo/seo.js +230 -0
  165. package/dist/seo/sitemap.d.ts +139 -0
  166. package/dist/seo/sitemap.js +217 -0
  167. package/dist/seo/types.d.ts +278 -0
  168. package/dist/seo/types.js +2 -0
  169. package/dist/seo/write-service.d.ts +79 -0
  170. package/dist/seo/write-service.js +273 -0
  171. package/dist/seo/xml-escape.d.ts +16 -0
  172. package/dist/seo/xml-escape.js +19 -0
  173. package/dist/settings/express/cms-adapter.d.ts +20 -0
  174. package/dist/settings/express/cms-adapter.js +124 -0
  175. package/dist/settings/express/contracts.d.ts +135 -0
  176. package/dist/settings/express/contracts.js +2 -0
  177. package/dist/settings/express/http.d.ts +10 -0
  178. package/dist/settings/express/http.js +211 -0
  179. package/dist/settings/express/index.d.ts +5 -0
  180. package/dist/settings/express/index.js +5 -0
  181. package/dist/settings/express/shared.d.ts +68 -0
  182. package/dist/settings/express/shared.js +54 -0
  183. package/dist/settings/express/stream.d.ts +24 -0
  184. package/dist/settings/express/stream.js +147 -0
  185. package/dist/settings/index.d.ts +3 -38
  186. package/dist/settings/index.js +3 -35
  187. package/dist/taxonomy/write-service.d.ts +2 -30
  188. package/dist/widgets/authorize-helper.d.ts +42 -0
  189. package/dist/widgets/authorize-helper.js +16 -0
  190. package/dist/widgets/concurrency.d.ts +12 -0
  191. package/dist/widgets/concurrency.js +44 -0
  192. package/dist/widgets/config-validation.d.ts +34 -0
  193. package/dist/widgets/config-validation.js +125 -0
  194. package/dist/widgets/embed-service.d.ts +133 -0
  195. package/dist/widgets/embed-service.js +460 -0
  196. package/dist/widgets/embed-validation.d.ts +23 -0
  197. package/dist/widgets/embed-validation.js +38 -0
  198. package/dist/widgets/entry-payload.d.ts +103 -0
  199. package/dist/widgets/entry-payload.js +160 -0
  200. package/dist/widgets/errors.d.ts +136 -0
  201. package/dist/widgets/errors.js +136 -0
  202. package/dist/widgets/host-ports.d.ts +149 -0
  203. package/dist/widgets/host-ports.js +2 -0
  204. package/dist/widgets/html/html-embeds.d.ts +247 -0
  205. package/dist/widgets/html/html-embeds.js +408 -0
  206. package/dist/widgets/html/index.d.ts +5 -0
  207. package/dist/widgets/html/index.js +4 -0
  208. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  209. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  210. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  211. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  212. package/dist/widgets/index.d.ts +20 -0
  213. package/dist/widgets/index.js +19 -0
  214. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  215. package/dist/widgets/markers/html-attributes.js +216 -0
  216. package/dist/widgets/markers/index.d.ts +3 -0
  217. package/dist/widgets/markers/index.js +3 -0
  218. package/dist/widgets/markers/marker.d.ts +496 -0
  219. package/dist/widgets/markers/marker.js +780 -0
  220. package/dist/widgets/page-embed-types.d.ts +42 -0
  221. package/dist/widgets/page-embed-types.js +42 -0
  222. package/dist/widgets/ports.d.ts +142 -0
  223. package/dist/widgets/ports.js +2 -0
  224. package/dist/widgets/read-service.d.ts +81 -0
  225. package/dist/widgets/read-service.js +79 -0
  226. package/dist/widgets/region-area-service.d.ts +110 -0
  227. package/dist/widgets/region-area-service.js +180 -0
  228. package/dist/widgets/registry.d.ts +217 -0
  229. package/dist/widgets/registry.js +172 -0
  230. package/dist/widgets/repo.memory.d.ts +41 -0
  231. package/dist/widgets/repo.memory.js +48 -0
  232. package/dist/widgets/resolver-service.d.ts +209 -0
  233. package/dist/widgets/resolver-service.js +909 -0
  234. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  235. package/dist/widgets/resolvers/contact-form.js +54 -0
  236. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  237. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  238. package/dist/widgets/resolvers/index.d.ts +65 -0
  239. package/dist/widgets/resolvers/index.js +155 -0
  240. package/dist/widgets/resolvers/menu.d.ts +42 -0
  241. package/dist/widgets/resolvers/menu.js +43 -0
  242. package/dist/widgets/resolvers/ports.d.ts +82 -0
  243. package/dist/widgets/resolvers/ports.js +2 -0
  244. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  245. package/dist/widgets/resolvers/recent-entries.js +165 -0
  246. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  247. package/dist/widgets/resolvers/taxonomy.js +34 -0
  248. package/dist/widgets/sql/index.d.ts +3 -0
  249. package/dist/widgets/sql/index.js +3 -0
  250. package/dist/widgets/sql/repo.d.ts +41 -0
  251. package/dist/widgets/sql/repo.js +82 -0
  252. package/dist/widgets/sql/tables.d.ts +13 -0
  253. package/dist/widgets/sql/tables.js +2 -0
  254. package/dist/widgets/types.d.ts +213 -0
  255. package/dist/widgets/types.js +17 -0
  256. package/dist/widgets/where-used.d.ts +41 -0
  257. package/dist/widgets/where-used.js +11 -0
  258. package/dist/widgets/write-service.d.ts +204 -0
  259. package/dist/widgets/write-service.js +332 -0
  260. package/dist/workspace/delete.d.ts +1 -1
  261. package/package.json +220 -12
@@ -0,0 +1,909 @@
1
+ import { isNonArrayRecord } from "@jini-ai/core";
2
+ import { resolveTaxonomyEmbeds } from "./resolvers/taxonomy.js";
3
+ import { parseWidgetAreaPayload, parseWidgetInstancePayload } from "./entry-payload.js";
4
+ import { resolveWidgetType } from "./resolvers/index.js";
5
+ import { WIDGET_AREA_CONTENT_TYPE, WIDGET_CONTENT_TYPE } from "./types.js";
6
+ /** REQ-28: a widget resolution failure renders an isolated placeholder — no internal detail in public output. */
7
+ const PLACEHOLDER_IR = { componentId: "widget-placeholder", props: {} };
8
+ function toRenderIr(result) {
9
+ return result?.ok ? result.ir : PLACEHOLDER_IR;
10
+ }
11
+ /** Walks a TipTap-shaped bodyJson tree collecting every `widgetEmbed` node (REQ-21) — same shape `core/entry-refs/extractor.ts` walks, kept separate since this module has no dependency on `core/entry-refs`. */
12
+ function collectWidgetEmbeds(root, out) {
13
+ // An explicit stack, not recursion: a stored document's nesting depth is attacker/import-controlled
14
+ // and a 5000-deep list overflowed the call stack here (GET /:slug -> 500) before `render.ts`'s own
15
+ // MAX_RENDER_DEPTH placeholder could ever apply. Children are pushed in reverse so the walk stays
16
+ // pre-order, the same visit order the recursive version had.
17
+ const stack = [root];
18
+ while (stack.length > 0) {
19
+ const node = stack.pop();
20
+ const candidate = { value: node };
21
+ const attrs = { value: isNonArrayRecord(candidate) && candidate.value.type === "widgetEmbed" ? candidate.value.attrs : undefined };
22
+ if (Array.isArray(node)) {
23
+ for (let i = node.length - 1; i >= 0; i--)
24
+ stack.push(node[i]);
25
+ continue;
26
+ }
27
+ if (!isNonArrayRecord(candidate))
28
+ continue;
29
+ if (isNonArrayRecord(attrs) && typeof attrs.value.widgetEntryId === "string" && typeof attrs.value.placementId === "string") {
30
+ out.push({ placementId: attrs.value.placementId, widgetEntryId: attrs.value.widgetEntryId });
31
+ }
32
+ if (Array.isArray(candidate.value.content))
33
+ stack.push(candidate.value.content);
34
+ }
35
+ }
36
+ /**
37
+ * Batch-loads and resolves every widget instance in `referencedIds` (REQ-24's "one batched query,
38
+ * at most one `resolveWidgetType` call per type" guarantee) into a `widgetEntryId ->
39
+ * WidgetResolveResult` map. Extracted so this exact batching contract has ONE implementation rather
40
+ * than two that could silently diverge (SPEC-047 Slice 2 inline refactor beat) — `resolvePageWidgets`
41
+ * (below, region + TipTap-inline-embed callers) and `resolveHtmlPageEmbeds` (an `"html"`-format
42
+ * Page's `data-widget-embed` callers) both call this.
43
+ *
44
+ * Never throws (REQ-27's contract, unchanged by this extraction) — a malformed widget-instance
45
+ * payload is skipped, not thrown; the caller's own placeholder-degradation step (REQ-28) handles a
46
+ * ref that never made it into the returned map.
47
+ *
48
+ * @complexity O(1) plus one batched `listByWorkspace` query plus at most one `resolveWidgetType`
49
+ * call per distinct widget type present among `referencedIds` (REQ-24).
50
+ * @overallScore 100
51
+ */
52
+ /** Parses one entries row into a {@link WidgetInstanceView}, or `null` when it should be skipped
53
+ * (REQ-27: a malformed payload, or an instance already trashed/purged). */
54
+ function parseWidgetInstanceRow(row) {
55
+ let payload;
56
+ try {
57
+ payload = parseWidgetInstancePayload({ fieldsJson: row.fieldsJson });
58
+ }
59
+ catch {
60
+ // REQ-27: a malformed widget-instance payload is skipped, not thrown — the reference that
61
+ // pointed at it simply has no resolved result, so the caller degrades it to the REQ-28
62
+ // placeholder like any other unresolved reference.
63
+ return null;
64
+ }
65
+ if (payload.status === "trash" || payload.status === "purged")
66
+ return null;
67
+ return { id: row.id, widgetType: payload.widgetType, config: payload.config };
68
+ }
69
+ /** Groups every referenced, resolvable widget row by its widget type (REQ-24's "one `resolveWidgetType`
70
+ * call per type" grouping step). */
71
+ function groupWidgetInstancesByType(widgetRows, referencedIds) {
72
+ const byType = new Map();
73
+ for (const row of widgetRows) {
74
+ if (!referencedIds.has(row.id))
75
+ continue;
76
+ const instance = parseWidgetInstanceRow(row);
77
+ if (!instance)
78
+ continue;
79
+ const list = byType.get(instance.widgetType) ?? [];
80
+ list.push(instance);
81
+ byType.set(instance.widgetType, list);
82
+ }
83
+ return byType;
84
+ }
85
+ async function resolveWidgetInstances(deps, workspaceId, referencedIds, context) {
86
+ const widgetRows = referencedIds.size > 0 ? await deps.entryRepo.listByWorkspace({ workspaceId }, { type: WIDGET_CONTENT_TYPE }) : [];
87
+ const byType = groupWidgetInstancesByType(widgetRows, referencedIds);
88
+ const resolvedById = new Map();
89
+ for (const [typeKey, instances] of byType) {
90
+ const results = await resolveWidgetType({ typeKey, instances, context });
91
+ for (const [id, result] of results)
92
+ resolvedById.set(id, result);
93
+ }
94
+ return resolvedById;
95
+ }
96
+ /**
97
+ * Never throws (REQ-27's contract) — every per-widget failure is isolated to a placeholder IR node
98
+ * (REQ-28) before this function returns.
99
+ *
100
+ * @complexity O(r) over `resolvedRegions` plus one batched widget-listing query plus one
101
+ * `resolveWidgetType` call per distinct widget type present on the page (REQ-24).
102
+ * @overallScore 100
103
+ */
104
+ /** Step 1: region -> widget_area -> ordered, enabled placement list. A malformed widget_area
105
+ * payload or an unresolvable binding/area degrades that one region to "no widgets" (REQ-27) rather
106
+ * than throwing. */
107
+ async function resolveRegionPlacements(deps, workspaceId, resolvedRegions) {
108
+ const regionPlacements = new Map();
109
+ for (const regionKey of resolvedRegions) {
110
+ regionPlacements.set(regionKey, []);
111
+ const binding = await deps.bindingRepo.findByRegion({ workspaceId, regionKey });
112
+ if (!binding)
113
+ continue;
114
+ const areaEntry = await deps.entryRepo.findById({ workspaceId, id: binding.areaEntryId });
115
+ if (!areaEntry || areaEntry.type !== WIDGET_AREA_CONTENT_TYPE)
116
+ continue;
117
+ try {
118
+ const payload = parseWidgetAreaPayload({ fieldsJson: areaEntry.fieldsJson });
119
+ regionPlacements.set(regionKey, payload.doc.placements.filter((placement) => placement.enabled));
120
+ }
121
+ catch {
122
+ // REQ-27: a malformed widget_area payload (e.g. wiped by an unrelated generic-entry update
123
+ // that bypassed this feature's own write path) degrades this one region to "no widgets" —
124
+ // this function must never throw, matching the doc comment above it that this call site was
125
+ // violating (Fable adversarial-review fix, 2026-07-21, Finding B).
126
+ }
127
+ }
128
+ return regionPlacements;
129
+ }
130
+ /** Step 3: every distinct widget id referenced on the page, from BOTH region placements and inline
131
+ * embeds — the set `resolveWidgetInstances` batch-loads against (REQ-24). */
132
+ function collectReferencedWidgetIds(regionPlacements, inlineEmbeds) {
133
+ const referencedIds = new Set();
134
+ for (const placements of regionPlacements.values()) {
135
+ for (const placement of placements)
136
+ referencedIds.add(placement.widgetEntryId);
137
+ }
138
+ for (const embed of inlineEmbeds)
139
+ referencedIds.add(embed.widgetEntryId);
140
+ return referencedIds;
141
+ }
142
+ export async function resolvePageWidgets(required, _optional = {}) {
143
+ const { deps, input } = required;
144
+ const context = { workspaceId: input.workspaceId, preview: false };
145
+ const regionPlacements = await resolveRegionPlacements(deps, input.workspaceId, input.resolvedRegions);
146
+ // 2. Inline embeds from the host document's bodyJson (REQ-21/23).
147
+ const inlineEmbeds = [];
148
+ if (input.pageBodyJson !== undefined)
149
+ collectWidgetEmbeds(input.pageBodyJson, inlineEmbeds);
150
+ // 3. One batched widget-instance load for every distinct widget referenced on the page (REQ-24 —
151
+ // see this file's header for why `listByWorkspace` stands in for a literal `WHERE id IN (...)`).
152
+ const referencedIds = collectReferencedWidgetIds(regionPlacements, inlineEmbeds);
153
+ // 4/5. Batch-load + resolve (skipping missing/trashed/purged targets — REQ-27's failure taxonomy
154
+ // handles them as "unresolved", not a crash), at most one `resolveWidgetType` call per distinct
155
+ // type present (REQ-24) — see `resolveWidgetInstances`'s own doc for why this is shared rather
156
+ // than inlined here.
157
+ const resolvedById = await resolveWidgetInstances(deps, input.workspaceId, referencedIds, context);
158
+ // 6. Assemble region -> IR[] (missing/failed placements degrade to the REQ-28 placeholder).
159
+ const regions = {};
160
+ for (const [regionKey, placements] of regionPlacements) {
161
+ regions[regionKey] = placements.map((placement) => toRenderIr(resolvedById.get(placement.widgetEntryId)));
162
+ }
163
+ // 7. Assemble placementId -> IR for inline embeds.
164
+ return { regions, inlineResolved: assembleInlineResolved(inlineEmbeds, resolvedById) };
165
+ }
166
+ /** placementId -> IR for every inline embed; a missing/trashed/failed widget degrades to the REQ-28
167
+ * placeholder IR. Shared by {@link resolvePageWidgets} and {@link resolvePostContentWidgetContext}. */
168
+ function assembleInlineResolved(inlineEmbeds, resolvedById) {
169
+ const inlineResolved = new Map();
170
+ for (const embed of inlineEmbeds) {
171
+ inlineResolved.set(embed.placementId, toRenderIr(resolvedById.get(embed.widgetEntryId)));
172
+ }
173
+ return inlineResolved;
174
+ }
175
+ /**
176
+ * Resolves every DISTINCT `slug` among `refs` (id-less refs only — {@link resolveWidgetTypeEmbeds}
177
+ * never consults `slug` when `id` is present, see that function's own doc) to its widget entry's real
178
+ * id, via one {@link EntryRepoPort.findBySlug} call per distinct slug, run concurrently
179
+ * (`Promise.all`) — mirrors `resolveMediaTypeEmbeds`'s own per-ref fan-out for a lookup this port has
180
+ * no batch primitive for. A slug with no matching widget warns and is simply absent from the returned
181
+ * map — {@link resolveWidgetTypeEmbeds} degrades that exactly like a nonexistent `id` (REQ-27/28's
182
+ * never-throws, degrade-to-placeholder discipline, unchanged for this new lookup path).
183
+ *
184
+ * `entries_workspace_type_slug_unique` (`workspaceId, type, slug`) makes "more than one match"
185
+ * structurally impossible — unlike the dormant `name` fallback's own design, which had to hedge
186
+ * against ambiguity because nothing backed it with a real unique index. That is why this function has
187
+ * no "zero or >1 matches -> placeholder" branch: zero is the only failure shape a `findBySlug` call
188
+ * against a UNIQUE-constrained column can produce.
189
+ *
190
+ * @complexity O(s) over the distinct slugs present, one `findBySlug` call each, run concurrently.
191
+ */
192
+ async function resolveWidgetSlugsToIds(slugRefs, deps, context) {
193
+ const distinctSlugs = new Set(slugRefs.map((ref) => ref.slug));
194
+ const idBySlug = new Map();
195
+ await Promise.all(Array.from(distinctSlugs).map(async (slug) => {
196
+ const entry = await deps.entryRepo.findBySlug({ workspaceId: context.workspaceId, type: WIDGET_CONTENT_TYPE, slug });
197
+ if (!entry) {
198
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "widget" reference — no widget with that slug', {
199
+ workspaceId: context.workspaceId,
200
+ slug,
201
+ });
202
+ return;
203
+ }
204
+ idBySlug.set(slug, entry.id);
205
+ }));
206
+ return idBySlug;
207
+ }
208
+ /** `data-embed-type="widget"` batches through the same {@link resolveWidgetInstances}
209
+ * `resolvePageWidgets` uses — any widget type, any placement context; no widget-embed-specific
210
+ * resolver logic exists or is needed beyond this thin adapter (plus, as of 2026-08-31, the `slug`
211
+ * lookup {@link resolveWidgetSlugsToIds} performs). Behavior for an `id`-carrying ref is carried over
212
+ * unchanged from the pre-restructuring `resolveHtmlPageEmbeds` widget path.
213
+ *
214
+ * **`id` is authoritative when present; `slug` is consulted only for a ref with no `id` at all** —
215
+ * matches `html-embeds.ts`'s own doc on the two keys. A ref's `slug` is resolved to a real entry id
216
+ * FIRST, then folded into the SAME `ids` set the id-carrying refs already populate, so id- and
217
+ * slug-addressed widgets share {@link resolveWidgetInstances}'s one batched `listByWorkspace` query
218
+ * (REQ-24) rather than paying for a second one. The returned map is keyed by whatever the MARKER
219
+ * itself carried (`ref.id` for an id-based ref, `ref.slug` for a slug-based one) — never by the
220
+ * resolved id a slug-based ref never had in its own markup — because `render.ts`'s
221
+ * `renderHtmlPageBody` looks results up using that same marker-authored key (`ref.id ?? ref.slug`);
222
+ * see that function's own doc.
223
+ *
224
+ * @complexity O(r) over `refs`, plus {@link resolveWidgetSlugsToIds}'s own O(s) slug-lookup cost, plus
225
+ * {@link resolveWidgetInstances}'s one batched query and at most one `resolveWidgetType` call per
226
+ * distinct widget type present (REQ-24) — unchanged from before this function gained `slug` support.
227
+ */
228
+ /** Every id {@link resolveWidgetInstances} must batch-load for `refs` — an id-carrying ref's own
229
+ * `id` directly, plus every slug-carrying ref's resolved id (`idBySlug`) folded into the SAME set,
230
+ * so id- and slug-addressed widgets share one batched query (REQ-24). Warns once per ref carrying
231
+ * neither a usable `id` nor `slug` at all. Split out of {@link resolveWidgetTypeEmbeds} to keep that
232
+ * function's own complexity low (Programmer workflow 5a3) — this loop is pure bookkeeping, not a
233
+ * second decision. */
234
+ function collectWidgetIdsToLoad(refs, idBySlug, context) {
235
+ const ids = new Set();
236
+ for (const ref of refs) {
237
+ if (ref.id !== null) {
238
+ ids.add(ref.id);
239
+ }
240
+ else if (ref.slug === null) {
241
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "widget" reference — missing or invalid "id"/"slug" in data-embed-config', {
242
+ workspaceId: context.workspaceId,
243
+ });
244
+ }
245
+ }
246
+ for (const id of idBySlug.values())
247
+ ids.add(id);
248
+ return ids;
249
+ }
250
+ /** Builds the final result, keyed by whatever the MARKER itself carried (`ref.id` for an id-based
251
+ * ref, `ref.slug` for a slug-based one — never the resolved id a slug-based ref never had in its own
252
+ * markup, see {@link resolveWidgetTypeEmbeds}'s own doc for why). `resolvedRaw` only carries an entry
253
+ * for an id that named a REAL, non-trashed widget row (`resolveWidgetInstances`'s own doc) — a
254
+ * nonexistent id/slug must stay ABSENT from the result, never present-with-placeholder, preserving
255
+ * this file's present-vs-absent convention (`resolveHtmlPageEmbeds`'s own doc) unchanged from before
256
+ * `slug` support existed. Split out of {@link resolveWidgetTypeEmbeds} for the same complexity reason
257
+ * as {@link collectWidgetIdsToLoad}. */
258
+ function buildResolvedWidgetMap(refs, idBySlug, resolvedRaw) {
259
+ const resolved = new Map();
260
+ for (const ref of refs) {
261
+ const resolvedId = ref.id ?? (ref.slug !== null ? idBySlug.get(ref.slug) : undefined);
262
+ const lookupKey = ref.id ?? ref.slug;
263
+ if (resolvedId !== undefined && lookupKey !== null && resolvedRaw.has(resolvedId)) {
264
+ resolved.set(lookupKey, toRenderIr(resolvedRaw.get(resolvedId)));
265
+ }
266
+ }
267
+ return resolved;
268
+ }
269
+ async function resolveWidgetTypeEmbeds(refs, deps, context) {
270
+ const slugRefs = refs.filter((ref) => ref.id === null && ref.slug !== null);
271
+ const idBySlug = await resolveWidgetSlugsToIds(slugRefs, deps, context);
272
+ const ids = collectWidgetIdsToLoad(refs, idBySlug, context);
273
+ const resolvedRaw = await resolveWidgetInstances(deps, context.workspaceId, ids, context);
274
+ return buildResolvedWidgetMap(refs, idBySlug, resolvedRaw);
275
+ }
276
+ /** A form slug/id is a synthetic contact-form instance, resolved through the widget pipeline. */
277
+ async function resolveFormTypeEmbeds(refs, _deps, context) {
278
+ const keys = [...new Set(refs.map((ref) => ref.id ?? ref.slug).filter((key) => key !== null))];
279
+ const results = await resolveWidgetType({
280
+ typeKey: "contact-form",
281
+ context,
282
+ instances: keys.map((id) => ({ id, widgetType: "contact-form", config: { formDefinitionRef: id } })),
283
+ });
284
+ return new Map(keys.map((key) => [key, toRenderIr(results.get(key))]));
285
+ }
286
+ /** Mirrors `render.ts`'s own `MAX_MEDIA_REF_ID_LENGTH`/`PLAUSIBLE_MEDIA_REF_ID_PATTERN` shape check
287
+ * for an `assetId`/`transformName` that will eventually become a `/m/` URL path segment —
288
+ * duplicated here rather than imported for the same reason `core/entry-refs/extractor.ts`
289
+ * duplicates `html-embeds.ts`'s scan pattern instead of importing it: `widgets/` (this file) must
290
+ * not depend on `server/http/site/render.ts`, which sits above it in this codebase's layering. Both
291
+ * copies guard the SAME hazard — this copy stops a malformed id/name from being used as a
292
+ * repo-lookup key at resolve time; `render.ts`'s copy (`renderWidgetMediaImage`) re-checks
293
+ * defense-in-depth immediately before URL templating. */
294
+ const MAX_MEDIA_REF_ID_LENGTH = 200;
295
+ const PLAUSIBLE_MEDIA_REF_ID_PATTERN = /^[^\s/]+$/;
296
+ function isPlausibleMediaRefId(value) {
297
+ return value.length > 0 && value.length <= MAX_MEDIA_REF_ID_LENGTH && PLAUSIBLE_MEDIA_REF_ID_PATTERN.test(value);
298
+ }
299
+ /** The IR's `slug` for a resolved asset: `null` means "has none" (`MediaAssetRenderMeta`'s own
300
+ * contract), and a pre-backfill row stores that as `""`, so an empty slug is normalized to `null`
301
+ * rather than passed through. Shared by every media IR builder in this file. */
302
+ function mediaIrSlug(record) {
303
+ return record.slug || null;
304
+ }
305
+ /** Shape-narrows a media ref into a usable `{assetId, transformName}` pair, or `undefined` when the
306
+ * ref's `id`/`variant` fails {@link isPlausibleMediaRefId}'s check — split out of
307
+ * {@link resolveOneMediaEmbed} so that function's own branch count stays proportional to "which
308
+ * resolution step failed", not also this shape check. */
309
+ /**
310
+ * T10 (2026-09-16 embed-attributes plan): `ref.id ?? ref.slug` — a `"media"` marker accepts a `slug`
311
+ * the same way a `"widget"` marker already does ({@link resolveWidgetTypeEmbeds}'s own doc,
312
+ * 2026-08-31): `id` wins when both are present and `slug` is never even consulted, matching
313
+ * `resolveOneMediaEmbed`'s own doc on its `refAssetId` — the value returned here IS that map key, and
314
+ * is passed straight into `findMediaByIdOrSlug`, which has always accepted either shape; only this
315
+ * shape-check gate was missing it before. Named `assetId` still (not `idOrSlug`) since that field is
316
+ * renamed at its one call site immediately.
317
+ */
318
+ function parseMediaEmbedRef(ref, context, host) {
319
+ const assetId = ref.id ?? ref.slug;
320
+ const transformName = ref.variant ?? host.media.publicTransformName;
321
+ if (assetId === null || !isPlausibleMediaRefId(assetId) || !isPlausibleMediaRefId(transformName)) {
322
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "media" reference — missing or invalid "id"/"slug"/"variant" in data-embed-config', { workspaceId: context.workspaceId });
323
+ return undefined;
324
+ }
325
+ return { assetId, transformName };
326
+ }
327
+ /** Builds the non-video (image-transform) IR for a resolved asset — the transform lookup + prop
328
+ * assembly {@link resolveOneMediaEmbed} defers to once it knows the asset isn't a video. `undefined`
329
+ * when `transformName` names no registered transform. */
330
+ async function buildMediaImageIr(assetId, transformName, transformRepo, record, context, host) {
331
+ const definition = await host.media.getLatestTransformDefinition({
332
+ deps: { transformRepo },
333
+ input: { workspaceId: context.workspaceId, name: transformName },
334
+ });
335
+ if (!definition) {
336
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "media" reference — transform not registered', {
337
+ workspaceId: context.workspaceId,
338
+ assetId,
339
+ transformName,
340
+ });
341
+ return undefined;
342
+ }
343
+ return {
344
+ componentId: "media-image",
345
+ props: {
346
+ assetId,
347
+ // Readable-slugs S3: `render.ts`'s `renderWidgetMediaImage` reads this to build the emitted
348
+ // `/m/...` URL via `mediaUrlKey` — `null`/absent falls back to `assetId`, same as every other
349
+ // emitter. See the rewritten comment at this function's own caller for why emitting the slug
350
+ // here is now safe (S2a/S2b's `media_slug_history` rename safety).
351
+ slug: mediaIrSlug(record),
352
+ transformName,
353
+ version: definition.version,
354
+ alt: record.alt,
355
+ width: record.width,
356
+ height: record.height,
357
+ cssClass: record.cssClass,
358
+ htmlAttributes: record.htmlAttributes,
359
+ },
360
+ };
361
+ }
362
+ /** One media ref's resolved id + IR, or `undefined` when it could not be resolved — extracted so
363
+ * {@link resolveMediaTypeEmbeds}'s own `Promise.all` callback is a thin per-ref dispatch, never the
364
+ * decision itself. `mediaRepo`/`transformRepo` are passed non-optional here: the caller has already
365
+ * guarded their absence before ever constructing this per-ref work. */
366
+ async function resolveOneMediaEmbed(ref, mediaRepo, transformRepo, mediaContentTypeStore, context, host) {
367
+ const parsed = parseMediaEmbedRef(ref, context, host);
368
+ if (!parsed)
369
+ return undefined;
370
+ // `refAssetId` is exactly what the marker's `data-embed-id` says — a theme author may write
371
+ // either the asset's `id` or its `slug` (2026-09-07). It stays the MAP KEY this function returns
372
+ // (`resolveMediaTypeEmbeds`'s caller looks up the resolved map by the marker's own literal id, so
373
+ // the key must match what was typed, not what it resolved to). `props.assetId` below is still
374
+ // normalized to the canonical `record.id` (the gate/lookup identifier), but `render.ts` no longer
375
+ // builds the served `/m/...` URL straight from it: as of readable-slugs S3, `props.slug` (added
376
+ // below) is what `mediaUrlKey` actually emits into `src`, falling back to `props.assetId` only when
377
+ // there is no valid slug. A later slug rename no longer risks breaking a URL already baked into
378
+ // previously-rendered HTML — that used to be `record.id`'s whole reason for being the emitted
379
+ // value; now it is `media_slug_history` (S2a/S2b) keeping the OLD slug resolving too.
380
+ const { assetId: refAssetId, transformName } = parsed;
381
+ const record = await host.media.findMediaByIdOrSlug({ deps: { mediaRepo }, input: { workspaceId: context.workspaceId, idOrSlug: refAssetId } });
382
+ if (!record) {
383
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "media" reference — no such asset', {
384
+ workspaceId: context.workspaceId,
385
+ assetId: refAssetId,
386
+ });
387
+ return undefined;
388
+ }
389
+ const canonicalAssetId = record.id;
390
+ const contentType = mediaContentTypeStore
391
+ ? (await mediaContentTypeStore.getMany({ workspaceId: context.workspaceId, sha256s: [record.source.sha256] })).get(record.source.sha256)
392
+ : undefined;
393
+ if (contentType?.startsWith("video/")) {
394
+ // Video needs no transform lookup at all: `render.ts`'s video branch points straight at
395
+ // `/m/{assetId}/original` (the byte-passthrough route — see that route's own doc for why
396
+ // video can't go through the image-transform pipeline below), so `transformName`/`version`
397
+ // would be unused props for this IR.
398
+ return {
399
+ assetId: refAssetId,
400
+ ir: {
401
+ componentId: "media-image",
402
+ props: {
403
+ assetId: canonicalAssetId,
404
+ slug: mediaIrSlug(record),
405
+ contentType,
406
+ alt: record.alt,
407
+ width: record.width,
408
+ height: record.height,
409
+ cssClass: record.cssClass,
410
+ htmlAttributes: record.htmlAttributes,
411
+ },
412
+ },
413
+ };
414
+ }
415
+ const ir = await buildMediaImageIr(canonicalAssetId, transformName, transformRepo, record, context, host);
416
+ return ir ? { assetId: refAssetId, ir } : undefined;
417
+ }
418
+ async function resolveMediaTypeEmbeds(refs, deps, context) {
419
+ const resolved = new Map();
420
+ const { mediaRepo, transformRepo, mediaContentTypeStore } = deps;
421
+ if (!mediaRepo || !transformRepo) {
422
+ if (refs.length > 0) {
423
+ console.warn('[widgets] resolveHtmlPageEmbeds: "media" embeds present but no mediaRepo/transformRepo dependency was supplied — every occurrence degrades to the placeholder', { workspaceId: context.workspaceId, occurrences: refs.length });
424
+ }
425
+ return resolved;
426
+ }
427
+ const entries = await Promise.all(refs.map((ref) => resolveOneMediaEmbed(ref, mediaRepo, transformRepo, mediaContentTypeStore, context, deps.host)));
428
+ for (const entry of entries) {
429
+ if (entry)
430
+ resolved.set(entry.assetId, entry.ir);
431
+ }
432
+ return resolved;
433
+ }
434
+ /** Scans a TipTap-shaped `bodyJson` tree for every ref-based `image`/`media` node's `assetId`
435
+ * (ADR-027 §4's `{assetId, transformName}` shape) — both the legacy image-only ref shape and the
436
+ * generic `media` node (2026-09-11, `render.ts`'s `renderDocMedia`'s own doc) share this collection,
437
+ * since both key their sizing/content-type override off the SAME `mediaAssetMetadata` map by
438
+ * `assetId` alone. Widened 2026-09-11 (owner-reported, verifying the admin Preview-tab fix: a
439
+ * `media` node embedded in a post's body rendered as a placeholder through this exact
440
+ * "post-content" IR path, even with the asset/transform fine, because this collector only ever
441
+ * recognized `"image"`) to match `routes/site/pages.ts`'s own `collectMediaRefAssetIds`, which was
442
+ * widened the same day — renamed from `isRefBasedImageNode`/`collectImageAssetIds` accordingly. Same
443
+ * walk shape as that module's copy, still duplicated rather than imported: that module sits above
444
+ * this one in the codebase's layering (this file's `resolveMediaTypeEmbeds`'s own doc discloses the
445
+ * same constraint for `render.ts`). A legacy `image` node (only `attrs.src`, no `assetId`) is never
446
+ * added — nothing here needs a sizing/version override for that shape. */
447
+ /** A TipTap `image` or `media` node carrying the `{assetId, transformName}` ref shape (vs. a legacy
448
+ * `image` node's `attrs.src`-only shape, which `media` has no equivalent of at all — see this file's
449
+ * header on `Media`/`renderDocMedia` for why). */
450
+ function isRefBasedMediaNode(obj) {
451
+ return (obj.type === "image" || obj.type === "media") && typeof obj.attrs === "object" && obj.attrs !== null;
452
+ }
453
+ function collectMediaRefAssetIds(root, out) {
454
+ // Iterative for the same reason as {@link collectWidgetEmbeds} (a 5000-deep post overflowed here).
455
+ const stack = [root];
456
+ while (stack.length > 0) {
457
+ const node = stack.pop();
458
+ if (Array.isArray(node)) {
459
+ for (let i = node.length - 1; i >= 0; i--)
460
+ stack.push(node[i]);
461
+ continue;
462
+ }
463
+ if (typeof node !== "object" || node === null)
464
+ continue;
465
+ const obj = node;
466
+ if (isRefBasedMediaNode(obj)) {
467
+ const assetId = obj.attrs.assetId;
468
+ if (typeof assetId === "string")
469
+ out.add(assetId);
470
+ }
471
+ if (Array.isArray(obj.content))
472
+ stack.push(obj.content);
473
+ }
474
+ }
475
+ /**
476
+ * Owner-reported bug (2026-08-12, widened 2026-09-11 to also collect the generic `media` node — see
477
+ * {@link collectMediaRefAssetIds}'s own doc) — resolves the `mediaTransformVersions`/
478
+ * `mediaAssetMetadata` context a "post-content" IR's embedded TipTap doc needs to render its OWN
479
+ * ref-based `image`/`media` nodes as real pictures instead of the filename-labelled placeholder.
480
+ * Before this function existed,
481
+ * NEITHER value was ever resolved for this render path at all: `render.ts`'s `renderWidgetPostContent`
482
+ * called `renderDocNode(bodyJson)` with no media context, which silently defaults both to empty maps
483
+ * — every ref-based image's transform lookup was unconditionally a miss, regardless of whether the
484
+ * asset/transform genuinely existed (confirmed live: asset uploaded, `"public"` transform
485
+ * registered, `/m/` rendition route returning 200 — and still a placeholder, because nothing upstream
486
+ * ever looked any of that up for this render path). See `renderWidgetPostContent`'s own doc in
487
+ * `render.ts` for the full trace.
488
+ *
489
+ * Mirrors `routes/site/pages.ts`'s `resolveMediaTransformVersionsForRender`/
490
+ * `resolveMediaAssetMetadataForRender` — the identical resolution the generic (non-template) render
491
+ * path already performs for `renderSite` — reusing THIS file's own `getLatestTransformDefinition`/
492
+ * `mediaRepo.findById` primitives (already proven correct here by {@link resolveMediaTypeEmbeds}, the
493
+ * sibling `"media"` embed resolver) rather than a third implementation of either lookup.
494
+ *
495
+ * Returns plain JSON, not `Map`s: `WidgetRenderIR.props` is a `JsonObject`, and this file must not
496
+ * import `render.ts` (see this file's layering note above) to build a real `Map` and hand it across
497
+ * that boundary directly. `render.ts`'s `readMediaTransformVersions`/`readMediaAssetMetadata`
498
+ * reconstruct the `Map`s `renderDocNode` needs from this exact shape.
499
+ *
500
+ * Never throws (REQ-27, same discipline every resolver in this file follows): a `bodyJson` with no
501
+ * ref-based images, or missing `mediaRepo`/`transformRepo` deps, resolves to empty objects —
502
+ * `render.ts`'s `image` case already degrades an absent transform-version/metadata entry to the
503
+ * placeholder, never a crash.
504
+ *
505
+ * Single-transform-name shortcut deliberately mirrored from `resolveMediaTransformVersionsForRender`
506
+ * (not a per-node scan of every distinct `transformName` present): `registerTransform` has exactly
507
+ * one caller anywhere in this codebase (`ensureCoreMediaTransform`), which only ever registers
508
+ * {@link CORE_PUBLIC_TRANSFORM_NAME} — see that function's own doc for the full disclosure and what
509
+ * widening this would take.
510
+ *
511
+ * Widened 2026-09-11 to also resolve `contentType` (`render.ts`'s `MediaAssetRenderMeta.contentType`'s
512
+ * own doc), closing the gap that doc's own history section used to describe: this function's caller
513
+ * is NOT limited to "a page embeds this post" — `routes/site/pages.ts`'s `renderViaTemplate` reaches
514
+ * this same function (via `resolveContentTypeEmbeds`'s DB branch) for a post rendered at its OWN
515
+ * public URL through a static-tier theme template, so a video asset in a post's own body was
516
+ * silently rendering as `<img>` there too, not only when embedded into a page. Resolved via
517
+ * `deps.mediaContentTypeStore`, keyed by the resolved record's `source.sha256` (the bytes' identity,
518
+ * not the asset id) — the identical mechanism `resolveOneMediaEmbed` (this file's own sibling `media`
519
+ * embed-type resolver) and `resolveMediaAssetMetadataForRender` (`routes/site/pages.ts`) both already
520
+ * use, batched into ONE `getMany` call across every resolved asset rather than one call per asset
521
+ * (that function's own shape, not `resolveOneMediaEmbed`'s per-ref shape, since this function already
522
+ * gathers every ref up front). `mediaContentTypeStore` absent, or a sha256 with no recorded type,
523
+ * both degrade `contentType` to `null` — read by `render.ts`'s `renderDocMedia` as "not video", never
524
+ * a crash or a guess.
525
+ *
526
+ * @complexity O(a) over the distinct ref-based `assetId`s the body references, each behind one
527
+ * `mediaRepo.findById` call, run concurrently via `Promise.all` alongside the single transform-
528
+ * definition lookup — same shape `resolveMediaAssetMetadataForRender` and `resolveMediaTypeEmbeds`
529
+ * both already use for their own per-asset fan-out — PLUS one further batched
530
+ * `MediaContentTypeStorePort.getMany` call across every resolved asset's sha256 at once, run after
531
+ * that fan-out resolves (its input is those results), mirroring `resolveMediaAssetMetadataForRender`'s
532
+ * identical two-step shape.
533
+ */
534
+ async function resolvePostContentMediaContext(deps, bodyJson, context) {
535
+ const { mediaRepo, transformRepo, mediaContentTypeStore } = deps;
536
+ const assetIds = new Set();
537
+ collectMediaRefAssetIds(bodyJson, assetIds);
538
+ if (assetIds.size === 0 || !mediaRepo || !transformRepo) {
539
+ return { mediaTransformVersions: {}, mediaAssetMetadata: {} };
540
+ }
541
+ const [definition, resolvedRecords] = await Promise.all([
542
+ deps.host.media.getLatestTransformDefinition({
543
+ deps: { transformRepo },
544
+ input: { workspaceId: context.workspaceId, name: deps.host.media.publicTransformName },
545
+ }),
546
+ Promise.all(Array.from(assetIds).map(async (assetId) => {
547
+ const record = await mediaRepo.findById({ workspaceId: context.workspaceId, id: assetId });
548
+ return record ? [assetId, record] : undefined;
549
+ })),
550
+ ]);
551
+ const found = resolvedRecords.filter((entry) => entry !== undefined);
552
+ const contentTypes = found.length > 0 && mediaContentTypeStore
553
+ ? await mediaContentTypeStore.getMany({ workspaceId: context.workspaceId, sha256s: found.map(([, record]) => record.source.sha256) })
554
+ : undefined;
555
+ return {
556
+ mediaTransformVersions: definition ? { [deps.host.media.publicTransformName]: definition.version } : {},
557
+ mediaAssetMetadata: Object.fromEntries(found.map(([assetId, record]) => [
558
+ assetId,
559
+ {
560
+ width: record.width,
561
+ height: record.height,
562
+ cssClass: record.cssClass,
563
+ htmlAttributes: record.htmlAttributes,
564
+ slug: mediaIrSlug(record),
565
+ contentType: contentTypes?.get(record.source.sha256) ?? null,
566
+ },
567
+ ])),
568
+ };
569
+ }
570
+ /**
571
+ * Owner-reported bug (2026-09-22) — the inline-widget half of a "post-content" IR's render context,
572
+ * the sibling of {@link resolvePostContentMediaContext}. A `widgetEmbed` node in the embedded body
573
+ * used to render as the placeholder on every template-rendered post, because `render.ts`'s
574
+ * `renderWidgetPostContent` had no `inlineResolved` map to look its `placementId` up in. Resolves
575
+ * through the SAME `collectWidgetEmbeds`/`resolveWidgetInstances`/`assembleInlineResolved` chain
576
+ * `resolvePageWidgets` uses for the non-template path, and returns plain JSON
577
+ * (`placementId -> WidgetRenderIR`) for the same layering reason that function's doc gives;
578
+ * `render.ts`'s `readInlineWidgets` rebuilds the `Map`.
579
+ *
580
+ * @complexity O(w) over the body's `widgetEmbed` nodes plus, only when there is at least one, one
581
+ * batched widget-instance load and one `resolveWidgetType` call per distinct type (REQ-24).
582
+ */
583
+ async function resolvePostContentWidgetContext(deps, bodyJson, context) {
584
+ const inlineEmbeds = [];
585
+ collectWidgetEmbeds(bodyJson, inlineEmbeds);
586
+ if (inlineEmbeds.length === 0)
587
+ return { inlineWidgets: {} };
588
+ const referencedIds = new Set(inlineEmbeds.map((embed) => embed.widgetEntryId));
589
+ const resolvedById = await resolveWidgetInstances(deps, context.workspaceId, referencedIds, context);
590
+ const inlineResolved = assembleInlineResolved(inlineEmbeds, resolvedById);
591
+ return { inlineWidgets: Object.fromEntries(inlineResolved) };
592
+ }
593
+ /** Everything a "post-content" IR's embedded body needs to render beyond the body itself — media
594
+ * context plus inline widgets — spread into the IR's `props` by all three "post-content" builders. */
595
+ async function resolvePostContentRenderContext(deps, bodyJson, context) {
596
+ const [mediaContext, widgetContext] = await Promise.all([
597
+ resolvePostContentMediaContext(deps, bodyJson, context),
598
+ resolvePostContentWidgetContext(deps, bodyJson, context),
599
+ ]);
600
+ return { ...mediaContext, ...widgetContext };
601
+ }
602
+ /**
603
+ * Resolves a post/page row by whichever key a {@link PageHtmlEmbedRef} carries — `id` authoritative
604
+ * when present, `slug` consulted only for a ref with no `id` at all. Same order
605
+ * {@link resolveWidgetTypeEmbeds}'s slug fallback and `resolveMediaTypeEmbeds`'s `parseMediaEmbedRef`
606
+ * already use for `widget`/`media` markers (S3, 2026-09-23 widget-attrs plan — `post`/`content` gain
607
+ * the identical treatment those two got in 2026-08-31/2026-09-16, deliberately deferred until now;
608
+ * see the retired paragraph this replaces in git history for why it was left out of those passes).
609
+ * `posts.slug` is unique per workspace (`posts_workspace_slug_unique`), so — like the widget/media
610
+ * cases — there is no "more than one match" branch to hedge against.
611
+ *
612
+ * Shared by {@link resolvePostTypeEmbeds} and {@link resolveContentTypeEmbeds} so both go through the
613
+ * ONE id-else-slug lookup, via the identical visibility guard ({@link findPublishedPostById}/
614
+ * {@link findPublishedPostBySlug}), rather than two copies that could drift apart.
615
+ *
616
+ * Non-throwing: `null` means "not found, not visible, or the ref carries neither key" — the same
617
+ * REQ-27 degrade-to-placeholder contract every resolver in this registry follows.
618
+ *
619
+ * @complexity O(1) — one guarded repo lookup (by id or by slug, never both), no iteration.
620
+ */
621
+ async function findPublishedPostByRef(postRepo, ref, context, host) {
622
+ if (ref.id !== null) {
623
+ return host.posts.findPublishedPostById({ deps: { repo: postRepo }, input: { workspaceId: context.workspaceId, id: ref.id } });
624
+ }
625
+ if (ref.slug !== null) {
626
+ return host.posts.findPublishedPostBySlug({ deps: { repo: postRepo }, input: { workspaceId: context.workspaceId, slug: ref.slug } });
627
+ }
628
+ return null;
629
+ }
630
+ /**
631
+ * `data-embed-type="post"` resolver — the post-template-picker feature (post-template.md's own
632
+ * design conversation, first shipped 2026-08-10). `data-embed-id`/`data-embed-slug` name the post's
633
+ * stored id or its unique slug, resolved by {@link findPublishedPostByRef} immediately above (id
634
+ * authoritative when both are present).
635
+ *
636
+ * Returns RAW post data (title/bodyJson/updatedAt/slug), not rendered HTML — this file (`widgets/`)
637
+ * must not depend on `server/http/site/render.ts` (that module sits above this one in the codebase's
638
+ * layering, the same constraint `resolveMediaTypeEmbeds`'s own doc discloses for why it returns
639
+ * `{assetId, transformName, ...}` instead of an `<img>` string). `render.ts`'s `renderWidgetIr`
640
+ * `"post-content"` case (which already has `renderDocNode` in scope) does the actual TipTap-to-HTML
641
+ * render from this data.
642
+ *
643
+ * A caller with no `postRepo` supplied (every pre-existing call site) behaves exactly like a `media`
644
+ * embed with no `mediaRepo` — every occurrence degrades to the placeholder, logged once, not silently.
645
+ *
646
+ * **Visibility fix, 2026-08-11**: was `postRepo.findById` directly — `PostRepoPort.findById` is
647
+ * trash- and status-BLIND by design (`post.ts`'s own doc on that port), so this resolver could
648
+ * previously pull ANY row by id, including an unpublished draft or a trashed one, onto the public
649
+ * site. That hole predates and is independent of this date's `content`-marker work, but is fixed
650
+ * here alongside it (same file, same class of hazard, same fix already being built for
651
+ * {@link resolveContentTypeEmbeds}'s guard) rather than left live next to a freshly-hardened sibling
652
+ * resolver. See {@link findPublishedPostById}'s own doc for why this differs from the raw port call.
653
+ */
654
+ async function resolvePostTypeEmbeds(refs, deps, context) {
655
+ const resolved = new Map();
656
+ const { postRepo } = deps;
657
+ if (!postRepo) {
658
+ if (refs.length > 0) {
659
+ console.warn('[widgets] resolveHtmlPageEmbeds: "post" embeds present but no postRepo dependency was supplied — every occurrence degrades to the placeholder', { workspaceId: context.workspaceId, occurrences: refs.length });
660
+ }
661
+ return resolved;
662
+ }
663
+ await Promise.all(refs.map(async (ref) => {
664
+ const key = ref.id ?? ref.slug;
665
+ if (key === null) {
666
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "post" reference — missing or invalid "id"/"slug" in data-embed-config', {
667
+ workspaceId: context.workspaceId,
668
+ });
669
+ return;
670
+ }
671
+ const post = await findPublishedPostByRef(postRepo, ref, context, deps.host);
672
+ if (!post) {
673
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "post" reference — no such published post', {
674
+ workspaceId: context.workspaceId,
675
+ postId: key,
676
+ });
677
+ return;
678
+ }
679
+ const renderContext = await resolvePostContentRenderContext(deps, post.bodyJson, context);
680
+ resolved.set(key, {
681
+ componentId: "post-content",
682
+ props: { title: post.title, slug: post.slug, updatedAt: post.updatedAt, publishAt: post.publishAt ?? null, bodyJson: post.bodyJson, ...renderContext },
683
+ });
684
+ }));
685
+ return resolved;
686
+ }
687
+ /**
688
+ * `data-embed-type="content"` resolver — the unified content marker's registry-owned half
689
+ * (2026-08-11, `ADS-memory/reports/design/2026-08-11-unified-content-marker-and-templates.md`).
690
+ * Handles `"doc"`-format targets ONLY: an `"html"`-format target's own body, and any FURTHER
691
+ * `content` markers nested inside it, are fetched, recursively resolved, and spliced in BEFORE this
692
+ * stage ever runs (`pages.ts`'s `resolveHtmlFormatContentMarkers` pre-pass, called ahead of
693
+ * `resolveHtmlPageEmbeds` from the template render path) — by the time the shared marker scan reaches
694
+ * this registry, an `html`-format reference has either already been consumed by that pre-pass or hit
695
+ * its depth/budget guard (below). Either way there is nothing safe to do here but degrade to the
696
+ * placeholder, same as any other unresolved reference: this resolver must never splice raw HTML
697
+ * itself, which is exactly what would collapse guard 1's doc/html escaping split into one branchless
698
+ * path (a TipTap doc renders through `renderDocNode`'s node-aware escaping; stored Page HTML is
699
+ * spliced at the trust level `PagesHtmlDocumentStore`/`pages.edit_html` already assume for it — the
700
+ * two must stay on separate code paths, never merged behind one generic "just render whatever this
701
+ * is" function).
702
+ *
703
+ * Reuses the EXISTING `"post-content"` render IR (`renderWidgetPostContent` in `render.ts`) rather
704
+ * than a new componentId: that function already renders ANY `{title, bodyJson, updatedAt}` bag
705
+ * regardless of the source row's `kind` — nothing in it is Post-specific — so a `kind: "page"`
706
+ * doc-format row (e.g. `our-story`) renders through the identical, already-vetted path a `kind:
707
+ * "post"` row does. The `"post-content"` label itself is pre-existing and not renamed here (`render.ts`
708
+ * is not in this change's file list); a follow-up rename to a kind-neutral label is low-priority tech
709
+ * debt, disclosed rather than silently left unmentioned.
710
+ *
711
+ * Visibility-filtered via {@link findPublishedPostByRef} (guard 2, the highest-risk part of the
712
+ * unified-marker design): an id OR slug supplied by an author, OR an id auto-filled by
713
+ * `injectCurrentEntityContentId` for the entity the route already resolved (never a slug —
714
+ * `injectCurrentEntityContentId` skips any marker already carrying a `slug`, its own doc explains
715
+ * why), can in principle name ANY row — this is the one place in the new marker's resolution chain
716
+ * that decides whether that row is allowed to reach the public page. The guard is on the ROW
717
+ * {@link findPublishedPostByRef} returns, identical regardless of which key found it.
718
+ *
719
+ * `pendingContentOverride` (2026-08-12; slug matching added S3 2026-09-23) bypasses this specific
720
+ * `findPublishedPostByRef` call, but NOT the visibility guard's intent: it only ever matches ONE
721
+ * row — the exact one `routes/admin/posts/template-preview.ts` already fetched (any status, but
722
+ * through its own `content.read`-authorized, session-gated lookup) and is previewing back to the
723
+ * SAME authenticated operator who owns that unsaved edit. It matches by `id` when the ref carries
724
+ * one (never falling through to `slug` for that same ref — the identical authoritative-when-present
725
+ * order {@link findPublishedPostByRef} uses below), by `slug` only for a slug-only ref. It can never
726
+ * be used to inject content for a DIFFERENT row than the one the caller resolved and authorized —
727
+ * see the override's own doc on {@link ResolveHtmlPageEmbedsDeps.pendingContentOverride}.
728
+ *
729
+ * `ref.header` (2026-09-04, {@link PageHtmlEmbedRef.header}'s own doc) is threaded into IR
730
+ * `props.header` unchanged in BOTH branches below — the DB lookup and the `pendingContentOverride`
731
+ * short-circuit both read it off the SAME scanned `ref`. This value is only ever a STAGING default,
732
+ * though, not what actually renders: `resolved` below is keyed by `ref.id`, one IR per id, so if the
733
+ * same id is embedded twice with different `header` options the two `resolved.set` calls race and
734
+ * one silently overwrites the other here — the `fc1a506a` bug (2026-09-05). `render.ts`'s
735
+ * `renderHtmlPageBody`/`withOccurrenceHeader` is what actually makes this safe: it discards this
736
+ * staged `header` and re-applies the CURRENT occurrence's own `ref.header` at substitution time,
737
+ * where the real per-occurrence ref is available. See the `resolved` map's own comment immediately
738
+ * below before adding another per-occurrence option here — the same collision reopens unless it
739
+ * gets the same substitution-time treatment.
740
+ *
741
+ * @complexity O(e) over the `content` refs present (already capped upstream at
742
+ * `MAX_HTML_EMBEDS_PER_PAGE`) — one `findPublishedPostById` call per ref not satisfied by
743
+ * {@link ResolveHtmlPageEmbedsDeps.pendingContentOverride}, run concurrently via `Promise.all`,
744
+ * mirroring every other resolver in this registry.
745
+ */
746
+ async function resolveContentTypeEmbeds(refs, deps, context) {
747
+ // Keyed by `ref.id`, ONE staged IR per id — not per occurrence. Any prop set here from
748
+ // occurrence-specific data (e.g. `header` in `props` below) is only a default: if the same id is
749
+ // embedded more than once on a page with different per-occurrence options, the concurrent
750
+ // `resolved.set` calls below race and whichever ref wins decides that prop for every occurrence,
751
+ // silently dropping the others' — the exact bug `fc1a506a` (2026-09-05) fixed for `header`.
752
+ // Occurrence-specific options must be re-applied at SUBSTITUTION time instead, where the real
753
+ // per-occurrence `ref` is available (`render.ts`'s `renderHtmlPageBody`/`withOccurrenceHeader` is
754
+ // `header`'s own fix); staging one here is fine only as long as every consumer of this Map applies
755
+ // that same override for its own option.
756
+ const resolved = new Map();
757
+ const { postRepo, pendingContentOverride } = deps;
758
+ if (!postRepo && !pendingContentOverride) {
759
+ if (refs.length > 0) {
760
+ console.warn('[widgets] resolveHtmlPageEmbeds: "content" embeds present but no postRepo dependency was supplied — every occurrence degrades to the placeholder', { workspaceId: context.workspaceId, occurrences: refs.length });
761
+ }
762
+ return resolved;
763
+ }
764
+ await Promise.all(refs.map(async (ref) => {
765
+ const key = ref.id ?? ref.slug;
766
+ if (key === null) {
767
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "content" reference — missing or invalid "id"/"slug" in data-embed-config', {
768
+ workspaceId: context.workspaceId,
769
+ });
770
+ return;
771
+ }
772
+ // Preview override, checked BEFORE any DB call (see `pendingContentOverride`'s own doc):
773
+ // the operator's pending, unsaved body for this exact row, never a fetch. Matches by `id` when
774
+ // the ref carries one (id authoritative, same order `findPublishedPostByRef` uses below), by
775
+ // `slug` only for a slug-only ref. Routes through the identical `"post-content"` IR/props shape
776
+ // the DB branch below builds, so this substitutes only the DATA SOURCE — every downstream
777
+ // render/sanitization step (`render.ts`'s `renderWidgetPostContent` -> `renderDocNode`) is the
778
+ // SAME code the saved-body path runs, never a second, parallel render path with its own
779
+ // escaping rules.
780
+ if (pendingContentOverride && (ref.id !== null ? ref.id === pendingContentOverride.id : ref.slug === pendingContentOverride.slug)) {
781
+ const renderContext = await resolvePostContentRenderContext(deps, pendingContentOverride.bodyJson, context);
782
+ resolved.set(key, {
783
+ componentId: "post-content",
784
+ props: {
785
+ title: pendingContentOverride.title,
786
+ slug: pendingContentOverride.slug,
787
+ updatedAt: pendingContentOverride.updatedAt,
788
+ publishAt: pendingContentOverride.publishAt ?? null,
789
+ bodyJson: pendingContentOverride.bodyJson,
790
+ header: ref.header,
791
+ ...renderContext,
792
+ },
793
+ });
794
+ return;
795
+ }
796
+ // No DB source for this ref (postRepo absent) and it didn't match the override above: nothing
797
+ // safe to do but leave it unresolved — degrades to the REQ-28 placeholder like any other miss.
798
+ if (!postRepo)
799
+ return;
800
+ const entity = await findPublishedPostByRef(postRepo, ref, context, deps.host);
801
+ if (!entity) {
802
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "content" reference — no such published entity', {
803
+ workspaceId: context.workspaceId,
804
+ entityId: key,
805
+ });
806
+ return;
807
+ }
808
+ if (entity.bodyFormat === "html") {
809
+ console.warn('[widgets] resolveHtmlPageEmbeds: unresolved "content" reference — html-format entity reached the registry resolver instead of the recursive pre-splice pass (depth/budget guard, or a direct reference outside a template render)', { workspaceId: context.workspaceId, entityId: key });
810
+ return;
811
+ }
812
+ const renderContext = await resolvePostContentRenderContext(deps, entity.bodyJson, context);
813
+ resolved.set(key, {
814
+ componentId: "post-content",
815
+ props: {
816
+ title: entity.title,
817
+ slug: entity.slug,
818
+ updatedAt: entity.updatedAt,
819
+ publishAt: entity.publishAt ?? null,
820
+ bodyJson: entity.bodyJson,
821
+ header: ref.header,
822
+ ...renderContext,
823
+ },
824
+ });
825
+ }));
826
+ return resolved;
827
+ }
828
+ /**
829
+ * Registry of known embed types (SPEC-047, generalized 2026-08-07). Adding a new embed type is
830
+ * registering one more entry here — no other file in `resolveHtmlPageEmbeds`'s call chain changes,
831
+ * and `html-embeds.ts`'s scanner already reports any type token it finds regardless of whether an
832
+ * entry exists here (see that file's own doc). A type ABSENT from this registry is the "unknown
833
+ * embed type" log path in {@link resolveHtmlPageEmbeds}; a type PRESENT here whose resolver still
834
+ * can't find a given id/name is the "resolver-internal miss" log path inside that resolver
835
+ * (`resolveWidgetTypeEmbeds`/`resolveMediaTypeEmbeds` above) — DECIDED (2026-08-07) to keep these
836
+ * two distinguishable in logs even though both render the identical REQ-28 placeholder externally:
837
+ * an author's markup typo (unknown type) and an author's stale reference (known type, dead id) are
838
+ * different problems needing different fixes.
839
+ *
840
+ * Historically `form` was removed (2026-08-10) because it was shorthand for a synthetic
841
+ * contact-form widget. The owner explicitly restores both spellings (2026-10-04): keep that
842
+ * synthetic instance on the SAME registered contact-form resolver so the two cannot drift.
843
+ */
844
+ export const HTML_EMBED_RESOLVERS = {
845
+ widget: resolveWidgetTypeEmbeds,
846
+ form: resolveFormTypeEmbeds,
847
+ taxonomy: (refs, deps) => resolveTaxonomyEmbeds({ refs, deps }),
848
+ media: resolveMediaTypeEmbeds,
849
+ post: resolvePostTypeEmbeds,
850
+ content: resolveContentTypeEmbeds,
851
+ };
852
+ /**
853
+ * Marker types that exist in the shared `data-embed-config` vocabulary (`core/embeds/marker.ts`) but
854
+ * are deliberately absent from {@link HTML_EMBED_RESOLVERS} — a theme's own structural markers, owned
855
+ * end-to-end by `features/theme/static-render.ts`'s `resolveSlots` (`"partial"`) and
856
+ * `injectMenuEmbeds` (`"menu"`), which run AFTER this stage on every static-tier page render
857
+ * (`routes/site/pages.ts`'s `renderViaTemplate`: `resolveHtmlPageEmbeds` first, then
858
+ * `renderStaticPage`). `isPageEmbedType`'s own doc already tells `render.ts`'s `renderHtmlPageBody` to
859
+ * leave these two untouched rather than substitute a placeholder over the nav — that fixed the
860
+ * SUBSTITUTION half of "this stage sees markers it does not own" (2026-08-10). This constant fixes the
861
+ * matching LOGGING half, which that earlier fix did not touch.
862
+ *
863
+ * Without this, {@link resolveHtmlPageEmbeds}'s "unknown embed type" warning fired for `menu`/`partial`
864
+ * on every single static-tier render — confirmed live during the 2026-08-16 GitHub Pages export
865
+ * (`gh-pages` commit `c0e52de2`, https://leonaburime-ucla.github.io/tovu-demo/): the log claimed "every
866
+ * occurrence degrades to the placeholder," which was false — curling the published page showed real,
867
+ * resolved `<a href>` links in both the nav and footer, zero placeholder markup anywhere. The page was
868
+ * never broken; only the diagnostic was. Left uncorrected, that false alarm fires on every render and
869
+ * trains a reader to ignore this log line, burying the ONE case it exists to catch: an author's actual
870
+ * markup typo (`HTML_EMBED_RESOLVERS`'s own doc on why "unknown type" and "known type, dead id" must
871
+ * stay distinguishable applies equally to "known-and-owned-elsewhere" vs. "owned nowhere").
872
+ *
873
+ * A type in this set must stay absent from `HTML_EMBED_RESOLVERS` — do not "fix" the warning by adding
874
+ * a resolver entry instead; see `isPageEmbedType`'s own doc for why that would reintroduce the
875
+ * substitution bug this file already fixed once.
876
+ *
877
+ * **Why this isn't just `!isPageEmbedType(type)`.** `isPageEmbedType` is membership in `PAGE_EMBED_TYPES`,
878
+ * which the compiler holds equal to `HTML_EMBED_RESOLVERS`'s keys — exactly the same fact this loop's own
879
+ * `HTML_EMBED_RESOLVERS[type]` lookup already tests, one
880
+ * bit: "is this resolver-service's own type." That bit is `false` for `partial`/`menu` AND for a genuine
881
+ * unregistered/typo type alike, so it cannot distinguish "known, owned by a later stage" from "owned
882
+ * nowhere" — using it to gate the warning would silence the ONE case the warning exists for, not just
883
+ * the two it shouldn't fire for. This set names the missing fact directly instead. It intentionally
884
+ * duplicates `static-render.ts`'s own `marker.type !== "menu"`/`!== "partial"` literals rather than
885
+ * importing them (that file is a different feature's territory in this change's dispatch) — the real
886
+ * fix, hoisting a shared constant into `core/embeds/marker.ts` for both files to import, is proposed but
887
+ * not yet authorized; see `ADS-memory/reports/2026-08-16-embed-placeholder-gap.md`'s "Reuse decision"
888
+ * section. Keep this set's members in sync with those two literals, not with `isPageEmbedType`.
889
+ *
890
+ * `"post-previews"` (2026-09-03) joins the set for the identical reason: it is resolved by
891
+ * `static-render.ts`'s `injectPostPreviewsEmbeds`, AFTER this stage, on every static-tier page
892
+ * render that reaches `renderStaticPage` — same duplicated-literal debt as the other two entries
893
+ * (see `POST_PREVIEWS_MARKER_TYPE`, `core/embeds/marker.ts`), not yet hoisted for the same reason.
894
+ *
895
+ * `"collection"` (2026-09-23) joins the set for the identical reason: it is resolved by
896
+ * `static-render.ts`'s `injectCollectionEmbeds`, AFTER this stage — same duplicated-literal debt as
897
+ * the other three entries (see `COLLECTION_MARKER_TYPE`, `core/embeds/marker.ts`).
898
+ *
899
+ * `"featured-image"` (2026-10-05) joins for the identical reason: `static-render.ts`'s
900
+ * `injectFeaturedImage` resolves it (see `FEATURED_IMAGE_MARKER_TYPE`, `core/embeds/marker.ts`).
901
+ */
902
+ export const THEME_OWNED_MARKER_TYPES = new Set(["partial", "menu", "post-previews", "collection", "featured-image"]);
903
+ /**
904
+ * `isPageEmbedType` (the "does this stage OWN the marker type" question, with its own doc) lives in
905
+ * the dependency-free `page-embed-types.ts` so `render.ts` and the theme render workers can ask it
906
+ * without loading this file's service graph. Re-exported so existing importers keep working.
907
+ */
908
+ export { isPageEmbedType } from "./page-embed-types.js";
909
+ //# sourceMappingURL=resolver-service.js.map