@jini-ai/cms 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- package/dist/forms/delete-submission.d.ts +38 -0
- package/dist/forms/delete-submission.js +25 -0
- package/dist/forms/duplicate-slug.d.ts +81 -0
- package/dist/forms/duplicate-slug.js +115 -0
- package/dist/forms/errors.d.ts +53 -0
- package/dist/forms/errors.js +25 -0
- package/dist/forms/express/handler.d.ts +59 -0
- package/dist/forms/express/handler.js +217 -0
- package/dist/forms/express/index.d.ts +2 -0
- package/dist/forms/express/index.js +2 -0
- package/dist/forms/fields-json.d.ts +14 -0
- package/dist/forms/fields-json.js +11 -0
- package/dist/forms/forms.d.ts +59 -0
- package/dist/forms/forms.js +213 -0
- package/dist/forms/html/authoring-adapter.d.ts +34 -0
- package/dist/forms/html/authoring-adapter.js +48 -0
- package/dist/forms/html/html-authoring.d.ts +12 -0
- package/dist/forms/html/html-authoring.js +133 -0
- package/dist/forms/html/html-submission-adapter.d.ts +10 -0
- package/dist/forms/html/html-submission-adapter.js +15 -0
- package/dist/forms/html/index.d.ts +4 -0
- package/dist/forms/html/index.js +4 -0
- package/dist/forms/html-render.d.ts +33 -0
- package/dist/forms/html-render.js +31 -0
- package/dist/forms/index.d.ts +17 -0
- package/dist/forms/index.js +17 -0
- package/dist/forms/notify-subscriber.d.ts +32 -0
- package/dist/forms/notify-subscriber.js +60 -0
- package/dist/forms/ports.d.ts +168 -0
- package/dist/forms/ports.js +2 -0
- package/dist/forms/rate-limit-key.d.ts +8 -0
- package/dist/forms/rate-limit-key.js +7 -0
- package/dist/forms/repo.memory.d.ts +120 -0
- package/dist/forms/repo.memory.js +188 -0
- package/dist/forms/sql/index.d.ts +5 -0
- package/dist/forms/sql/index.js +5 -0
- package/dist/forms/sql/repo.d.ts +94 -0
- package/dist/forms/sql/repo.js +175 -0
- package/dist/forms/sql/repo.rows.d.ts +33 -0
- package/dist/forms/sql/repo.rows.js +64 -0
- package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
- package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
- package/dist/forms/sql/tables.d.ts +32 -0
- package/dist/forms/sql/tables.js +2 -0
- package/dist/forms/submission-attempts.d.ts +70 -0
- package/dist/forms/submission-attempts.js +70 -0
- package/dist/forms/submission-email-validation.d.ts +8 -0
- package/dist/forms/submission-email-validation.js +34 -0
- package/dist/forms/submission-ip-retention.d.ts +30 -0
- package/dist/forms/submission-ip-retention.js +27 -0
- package/dist/forms/submit-service.d.ts +78 -0
- package/dist/forms/submit-service.js +109 -0
- package/dist/forms/types.d.ts +49 -0
- package/dist/forms/types.js +2 -0
- package/dist/forms/write-service.d.ts +83 -0
- package/dist/forms/write-service.js +271 -0
- package/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- package/package.json +220 -12
|
@@ -0,0 +1,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
|