@jini-ai/cms 0.5.4 → 0.5.6

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 (244) hide show
  1. package/dist/consent/express/index.d.ts +53 -0
  2. package/dist/consent/express/index.js +159 -0
  3. package/dist/consent/express/response.d.ts +21 -0
  4. package/dist/consent/express/response.js +89 -0
  5. package/dist/consent/html/client.d.ts +6 -0
  6. package/dist/consent/html/client.js +135 -0
  7. package/dist/consent/html/gate.d.ts +29 -0
  8. package/dist/consent/html/gate.js +104 -0
  9. package/dist/consent/html/index.d.ts +4 -0
  10. package/dist/consent/html/index.js +3 -0
  11. package/dist/consent/html/render.d.ts +27 -0
  12. package/dist/consent/html/render.js +62 -0
  13. package/dist/consent/html/static.d.ts +7 -0
  14. package/dist/consent/html/static.js +25 -0
  15. package/dist/consent/html/styles.d.ts +3 -0
  16. package/dist/consent/html/styles.js +22 -0
  17. package/dist/consent/index.d.ts +3 -0
  18. package/dist/consent/index.js +3 -0
  19. package/dist/consent/node/index.d.ts +47 -0
  20. package/dist/consent/node/index.js +169 -0
  21. package/dist/consent/policy.d.ts +47 -0
  22. package/dist/consent/policy.js +62 -0
  23. package/dist/consent/types.d.ts +40 -0
  24. package/dist/consent/types.js +5 -0
  25. package/dist/content-types/agent-tools.js +1 -1
  26. package/dist/content-types/tool-registrations.d.ts +2 -0
  27. package/dist/content-types/tool-registrations.js +1 -0
  28. package/dist/content-types/write-service.d.ts +2 -0
  29. package/dist/content-types/write-service.js +3 -0
  30. package/dist/forms/descriptor-rules.d.ts +9 -0
  31. package/dist/forms/descriptor-rules.js +112 -0
  32. package/dist/forms/email-format.d.ts +7 -0
  33. package/dist/forms/email-format.js +11 -0
  34. package/dist/forms/express/handler.js +4 -8
  35. package/dist/forms/field-registry.d.ts +193 -0
  36. package/dist/forms/field-registry.js +231 -0
  37. package/dist/forms/forms.d.ts +8 -5
  38. package/dist/forms/forms.js +29 -46
  39. package/dist/forms/html/html-authoring.js +63 -13
  40. package/dist/forms/html/html-submission-adapter.d.ts +5 -5
  41. package/dist/forms/html/html-submission-adapter.js +6 -13
  42. package/dist/forms/html-render.d.ts +10 -3
  43. package/dist/forms/html-render.js +116 -14
  44. package/dist/forms/index.d.ts +1 -0
  45. package/dist/forms/index.js +1 -0
  46. package/dist/forms/repo.memory.js +4 -4
  47. package/dist/forms/submission-attempts.d.ts +1 -1
  48. package/dist/forms/submission-attempts.js +13 -2
  49. package/dist/forms/submission-email-validation.js +2 -4
  50. package/dist/forms/submit-service.d.ts +3 -0
  51. package/dist/forms/submit-service.js +38 -5
  52. package/dist/forms/types.d.ts +50 -2
  53. package/dist/listing/batch.d.ts +53 -0
  54. package/dist/listing/batch.js +57 -0
  55. package/dist/listing/html.d.ts +72 -0
  56. package/dist/listing/html.js +109 -0
  57. package/dist/listing/index.d.ts +5 -0
  58. package/dist/listing/index.js +5 -0
  59. package/dist/listing/paths.d.ts +32 -0
  60. package/dist/listing/paths.js +37 -0
  61. package/dist/listing/see-more-script.d.ts +16 -0
  62. package/dist/listing/see-more-script.js +62 -0
  63. package/dist/media/content-type-sniffer.d.ts +1 -1
  64. package/dist/media/content-type-sniffer.js +15 -0
  65. package/dist/media/image-transformer.sharp.js +4 -1
  66. package/dist/media/media-service.d.ts +4 -0
  67. package/dist/media/media-service.js +8 -0
  68. package/dist/media/node.d.ts +1 -1
  69. package/dist/media/node.js +1 -1
  70. package/dist/media/transform-registry.js +1 -1
  71. package/dist/media/transform-types.d.ts +2 -0
  72. package/dist/media/transform-types.js +3 -0
  73. package/dist/media/video-frames.ffmpeg.d.ts +15 -2
  74. package/dist/media/video-frames.ffmpeg.js +28 -13
  75. package/dist/navigation/agent-tools.js +20 -3
  76. package/dist/navigation/dropdown.d.ts +41 -0
  77. package/dist/navigation/dropdown.js +85 -0
  78. package/dist/navigation/index.d.ts +5 -1
  79. package/dist/navigation/index.js +4 -0
  80. package/dist/navigation/menu-html.d.ts +31 -0
  81. package/dist/navigation/menu-html.js +53 -0
  82. package/dist/navigation/menu-service.d.ts +9 -3
  83. package/dist/navigation/menu-service.js +15 -3
  84. package/dist/navigation/tool-registrations.d.ts +5 -0
  85. package/dist/navigation/tool-registrations.js +32 -2
  86. package/dist/navigation/types.d.ts +6 -0
  87. package/dist/redirects/capture.d.ts +2 -0
  88. package/dist/redirects/capture.js +4 -0
  89. package/dist/redirects/redirects.d.ts +8 -0
  90. package/dist/redirects/redirects.js +14 -0
  91. package/dist/routing/index.d.ts +5 -0
  92. package/dist/routing/index.js +2 -0
  93. package/dist/routing/ports.d.ts +51 -0
  94. package/dist/routing/routing.d.ts +138 -0
  95. package/dist/routing/routing.js +443 -0
  96. package/dist/routing/types.d.ts +164 -0
  97. package/dist/routing/types.js +2 -0
  98. package/dist/seo/media.d.ts +23 -0
  99. package/dist/seo/media.js +30 -5
  100. package/dist/seo/page-head.js +8 -1
  101. package/dist/seo/seo.d.ts +2 -0
  102. package/dist/seo/seo.js +11 -6
  103. package/dist/seo/types.d.ts +8 -0
  104. package/dist/seo/xml-escape.d.ts +1 -3
  105. package/dist/seo/xml-escape.js +1 -6
  106. package/dist/widgets/concurrency.d.ts +27 -1
  107. package/dist/widgets/concurrency.js +12 -23
  108. package/dist/widgets/config-validation.js +6 -0
  109. package/dist/widgets/gallery-resolver.d.ts +23 -0
  110. package/dist/widgets/gallery-resolver.js +80 -0
  111. package/dist/widgets/html/gallery-render.d.ts +24 -0
  112. package/dist/widgets/html/gallery-render.js +65 -0
  113. package/dist/widgets/html/html-embeds.d.ts +4 -0
  114. package/dist/widgets/html/html-embeds.js +7 -1
  115. package/dist/widgets/html/index.d.ts +3 -0
  116. package/dist/widgets/html/index.js +3 -0
  117. package/dist/widgets/html/server-fragment.d.ts +9 -0
  118. package/dist/widgets/html/server-fragment.js +38 -0
  119. package/dist/widgets/markers/gallery.d.ts +50 -0
  120. package/dist/widgets/markers/gallery.js +164 -0
  121. package/dist/widgets/markers/html-attributes.d.ts +6 -1
  122. package/dist/widgets/markers/html-attributes.js +24 -9
  123. package/dist/widgets/markers/index.d.ts +2 -0
  124. package/dist/widgets/markers/index.js +2 -0
  125. package/dist/widgets/markers/marker.d.ts +4 -0
  126. package/dist/widgets/markers/marker.js +6 -0
  127. package/dist/widgets/markers/media-reference.d.ts +11 -0
  128. package/dist/widgets/markers/media-reference.js +11 -0
  129. package/dist/widgets/page-embed-types.d.ts +1 -1
  130. package/dist/widgets/page-embed-types.js +1 -1
  131. package/dist/widgets/registry.d.ts +144 -10
  132. package/dist/widgets/registry.js +40 -0
  133. package/dist/widgets/resolver-service.d.ts +10 -0
  134. package/dist/widgets/resolver-service.js +73 -29
  135. package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
  136. package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
  137. package/dist/widgets/resolvers/gallery.d.ts +12 -0
  138. package/dist/widgets/resolvers/gallery.js +33 -0
  139. package/dist/widgets/resolvers/index.d.ts +1 -0
  140. package/dist/widgets/resolvers/index.js +1 -0
  141. package/dist/widgets/resolvers/menu.d.ts +8 -0
  142. package/dist/widgets/resolvers/menu.js +21 -6
  143. package/dist/widgets/types.d.ts +3 -1
  144. package/package.json +51 -6
  145. package/dist/http/settings/cms-adapter.d.ts +0 -20
  146. package/dist/http/settings/cms-adapter.js +0 -124
  147. package/dist/http/settings/contracts.d.ts +0 -135
  148. package/dist/http/settings/contracts.js +0 -2
  149. package/dist/http/settings/http.d.ts +0 -10
  150. package/dist/http/settings/http.js +0 -211
  151. package/dist/http/settings/index.d.ts +0 -5
  152. package/dist/http/settings/index.js +0 -4
  153. package/dist/http/settings/shared.d.ts +0 -68
  154. package/dist/http/settings/shared.js +0 -54
  155. package/dist/http/settings/stream.d.ts +0 -24
  156. package/dist/http/settings/stream.js +0 -147
  157. package/dist/settings/agent-tools.d.ts +0 -10
  158. package/dist/settings/agent-tools.js +0 -204
  159. package/dist/settings/agent-value-write-tools.d.ts +0 -22
  160. package/dist/settings/agent-value-write-tools.js +0 -154
  161. package/dist/settings/agent-writable-preferences.d.ts +0 -67
  162. package/dist/settings/agent-writable-preferences.js +0 -132
  163. package/dist/settings/agent-write-denylist.d.ts +0 -19
  164. package/dist/settings/agent-write-denylist.js +0 -15
  165. package/dist/settings/change-feed.d.ts +0 -83
  166. package/dist/settings/change-feed.js +0 -61
  167. package/dist/settings/definitions-dispatch.d.ts +0 -56
  168. package/dist/settings/definitions-dispatch.js +0 -86
  169. package/dist/settings/dictionaries/index.d.ts +0 -48
  170. package/dist/settings/dictionaries/index.js +0 -69
  171. package/dist/settings/dictionaries/settings-dialog.ar.d.ts +0 -3
  172. package/dist/settings/dictionaries/settings-dialog.ar.js +0 -27
  173. package/dist/settings/dictionaries/settings-dialog.bn.d.ts +0 -3
  174. package/dist/settings/dictionaries/settings-dialog.bn.js +0 -27
  175. package/dist/settings/dictionaries/settings-dialog.de.d.ts +0 -3
  176. package/dist/settings/dictionaries/settings-dialog.de.js +0 -27
  177. package/dist/settings/dictionaries/settings-dialog.es.d.ts +0 -3
  178. package/dist/settings/dictionaries/settings-dialog.es.js +0 -27
  179. package/dist/settings/dictionaries/settings-dialog.fa.d.ts +0 -3
  180. package/dist/settings/dictionaries/settings-dialog.fa.js +0 -27
  181. package/dist/settings/dictionaries/settings-dialog.fr.d.ts +0 -3
  182. package/dist/settings/dictionaries/settings-dialog.fr.js +0 -27
  183. package/dist/settings/dictionaries/settings-dialog.hi.d.ts +0 -3
  184. package/dist/settings/dictionaries/settings-dialog.hi.js +0 -27
  185. package/dist/settings/dictionaries/settings-dialog.hu.d.ts +0 -3
  186. package/dist/settings/dictionaries/settings-dialog.hu.js +0 -27
  187. package/dist/settings/dictionaries/settings-dialog.id.d.ts +0 -3
  188. package/dist/settings/dictionaries/settings-dialog.id.js +0 -27
  189. package/dist/settings/dictionaries/settings-dialog.it.d.ts +0 -3
  190. package/dist/settings/dictionaries/settings-dialog.it.js +0 -27
  191. package/dist/settings/dictionaries/settings-dialog.ja.d.ts +0 -3
  192. package/dist/settings/dictionaries/settings-dialog.ja.js +0 -27
  193. package/dist/settings/dictionaries/settings-dialog.ko.d.ts +0 -3
  194. package/dist/settings/dictionaries/settings-dialog.ko.js +0 -27
  195. package/dist/settings/dictionaries/settings-dialog.pl.d.ts +0 -3
  196. package/dist/settings/dictionaries/settings-dialog.pl.js +0 -27
  197. package/dist/settings/dictionaries/settings-dialog.pt-BR.d.ts +0 -3
  198. package/dist/settings/dictionaries/settings-dialog.pt-BR.js +0 -27
  199. package/dist/settings/dictionaries/settings-dialog.ru.d.ts +0 -3
  200. package/dist/settings/dictionaries/settings-dialog.ru.js +0 -27
  201. package/dist/settings/dictionaries/settings-dialog.th.d.ts +0 -3
  202. package/dist/settings/dictionaries/settings-dialog.th.js +0 -27
  203. package/dist/settings/dictionaries/settings-dialog.tr.d.ts +0 -3
  204. package/dist/settings/dictionaries/settings-dialog.tr.js +0 -27
  205. package/dist/settings/dictionaries/settings-dialog.uk.d.ts +0 -3
  206. package/dist/settings/dictionaries/settings-dialog.uk.js +0 -27
  207. package/dist/settings/dictionaries/settings-dialog.ur.d.ts +0 -3
  208. package/dist/settings/dictionaries/settings-dialog.ur.js +0 -27
  209. package/dist/settings/dictionaries/settings-dialog.zh-CN.d.ts +0 -3
  210. package/dist/settings/dictionaries/settings-dialog.zh-CN.js +0 -27
  211. package/dist/settings/dictionaries/settings-dialog.zh-TW.d.ts +0 -3
  212. package/dist/settings/dictionaries/settings-dialog.zh-TW.js +0 -27
  213. package/dist/settings/ensure-definitions.d.ts +0 -88
  214. package/dist/settings/ensure-definitions.js +0 -91
  215. package/dist/settings/errors.d.ts +0 -42
  216. package/dist/settings/errors.js +0 -46
  217. package/dist/settings/express/cms-adapter.d.ts +0 -20
  218. package/dist/settings/express/cms-adapter.js +0 -124
  219. package/dist/settings/express/contracts.d.ts +0 -135
  220. package/dist/settings/express/contracts.js +0 -2
  221. package/dist/settings/express/http.d.ts +0 -10
  222. package/dist/settings/express/http.js +0 -211
  223. package/dist/settings/express/shared.d.ts +0 -68
  224. package/dist/settings/express/shared.js +0 -54
  225. package/dist/settings/express/stream.d.ts +0 -24
  226. package/dist/settings/express/stream.js +0 -147
  227. package/dist/settings/ports.d.ts +0 -118
  228. package/dist/settings/principal-lookup.d.ts +0 -9
  229. package/dist/settings/principal-lookup.js +0 -2
  230. package/dist/settings/purge-service.d.ts +0 -54
  231. package/dist/settings/purge-service.js +0 -106
  232. package/dist/settings/repo.memory.d.ts +0 -119
  233. package/dist/settings/repo.memory.js +0 -199
  234. package/dist/settings/settings.d.ts +0 -139
  235. package/dist/settings/settings.js +0 -259
  236. package/dist/settings/tool-registrations.d.ts +0 -51
  237. package/dist/settings/tool-registrations.js +0 -250
  238. package/dist/settings/types.d.ts +0 -125
  239. package/dist/settings/types.js +0 -3
  240. package/dist/settings/ui-tab-definitions.d.ts +0 -41
  241. package/dist/settings/ui-tab-definitions.js +0 -148
  242. package/dist/settings/write-service.d.ts +0 -320
  243. package/dist/settings/write-service.js +0 -683
  244. /package/dist/{settings → routing}/ports.js +0 -0
package/dist/seo/media.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mimeForTransformFormat } from "../media/transform-types.js";
1
2
  /**
2
3
  * @file `resolveSeoImageRef` (ADR-PIPE-008 Decision §6, C-013, EC-07;
3
4
  * EC-07's "never generates" clause AMENDED 2026-09-05 — see the ADR's
@@ -83,23 +84,47 @@ function buildSeoImageUrl(deps, asset, transformName, latest) {
83
84
  * version listing).
84
85
  */
85
86
  export async function resolveSeoImageRef(required, _optional = {}) {
87
+ return (await resolveSeoImageDetails(required))?.url;
88
+ }
89
+ /** Resolve through the existing media owners, optionally selecting a host's share transform.
90
+ * Absolute external URLs remain unchanged with unknown metadata; no remote fetch occurs.
91
+ * The latest definition keeps the anonymous lazy-generation guarantee documented above.
92
+ * @param required Media ports, workspace and persisted reference.
93
+ * @param optional Host-selected transform; omitted or unregistered retains the reference's named transform.
94
+ * @returns URL and known output metadata, or undefined on a lookup miss.
95
+ * @complexity O(v) in registered transform versions; up to three bounded repository reads.
96
+ */
97
+ export async function resolveSeoImageDetails(required, optional = {}) {
86
98
  const { deps, input } = required;
87
99
  const ref = input.ref?.trim();
88
100
  if (!ref)
89
101
  return undefined;
90
102
  if (isAbsoluteUrl(ref))
91
- return ref;
103
+ return { url: ref, ...(optional.transformName === undefined ? {} : { ref }) };
92
104
  const parts = parseMediaRefParts(ref);
93
105
  if (!parts)
94
106
  return undefined;
95
- const { idOrSlug, transformName } = parts;
96
- const asset = await resolveVisibleAsset(deps, input.workspaceId, idOrSlug);
107
+ const asset = await resolveVisibleAsset(deps, input.workspaceId, parts.idOrSlug);
97
108
  if (!asset)
98
109
  return undefined;
99
- const latest = await resolveLatestTransformVersion(deps, input.workspaceId, transformName);
110
+ let transformName = optional.transformName ?? parts.transformName;
111
+ let latest = await resolveLatestTransformVersion(deps, input.workspaceId, transformName);
112
+ // Share-transform registration may still be in flight at boot. Keep the persisted reference
113
+ // servable through its own transform rather than dropping the image from both social cards.
114
+ if (!latest && transformName !== parts.transformName) {
115
+ transformName = parts.transformName;
116
+ latest = await resolveLatestTransformVersion(deps, input.workspaceId, transformName);
117
+ }
100
118
  if (!latest)
101
119
  return undefined;
102
- return buildSeoImageUrl(deps, asset, transformName, latest);
120
+ // inside/outside fit does not produce the exact declared box, so don't advertise that box.
121
+ const exactBox = latest.params.width !== undefined && latest.params.height !== undefined && latest.params.fit !== "inside" && latest.params.fit !== "outside";
122
+ return {
123
+ url: buildSeoImageUrl(deps, asset, transformName, latest),
124
+ ...(optional.transformName === undefined ? {} : { ref }),
125
+ ...(exactBox ? { width: latest.params.width, height: latest.params.height } : {}),
126
+ type: mimeForTransformFormat({ format: latest.params.format }),
127
+ };
103
128
  }
104
129
  /**
105
130
  * The write-path refusal for an `ogImage`/`twitterImage`/`defaultOgImage` value (2026-10-05): the
@@ -22,8 +22,15 @@ function buildOpenGraphElements(og) {
22
22
  { kind: "og", property: "og:type", content: og.type, priority: 141 },
23
23
  { kind: "og", property: "og:url", content: og.url, priority: 142 },
24
24
  ];
25
- if (og.image)
25
+ if (og.image) {
26
26
  elements.push({ kind: "og", property: "og:image", content: og.image, priority: 143 });
27
+ if (og.imageWidth !== undefined)
28
+ elements.push({ kind: "og", property: "og:image:width", content: String(og.imageWidth), priority: 143 });
29
+ if (og.imageHeight !== undefined)
30
+ elements.push({ kind: "og", property: "og:image:height", content: String(og.imageHeight), priority: 143 });
31
+ if (og.imageType)
32
+ elements.push({ kind: "og", property: "og:image:type", content: og.imageType, priority: 143 });
33
+ }
27
34
  if (og.description)
28
35
  elements.push({ kind: "og", property: "og:description", content: og.description, priority: 144 });
29
36
  return elements;
package/dist/seo/seo.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface GetEntryMetaDeps {
37
37
  postRepo: SeoPostReadPort;
38
38
  settings: SeoSettingsReaderPort;
39
39
  media: ResolveSeoImageRefDeps;
40
+ /** Host-owned share-card variant; omitted preserves persisted transform choices. */
41
+ shareImageTransform?: string | undefined;
40
42
  originRegistry: OriginRegistryPort;
41
43
  runtimeMode: string;
42
44
  urls: SeoUrlsPort;
package/dist/seo/seo.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resolveWorkspaceOrigin, toAbsoluteUrl } from "./absolute-url.js";
2
2
  import { SeoEntryNotFoundError } from "./errors.js";
3
- import { resolveSeoImageRef } from "./media.js";
3
+ import { resolveSeoImageDetails } from "./media.js";
4
4
  /** Featured image (2026-10-05) as a share-image ref: the core "public" transform every `/m/` URL
5
5
  * already serves (`deps.ts`'s ADR-027 §4 registration). */
6
6
  function featuredImageRef(post) {
@@ -106,7 +106,7 @@ async function resolveRobots(deps, post, overrides, settings, workspaceId) {
106
106
  /** The first of `refs` that resolves through media, or `undefined`. @complexity O(r) resolves. */
107
107
  async function firstResolvedImage(deps, workspaceId, refs) {
108
108
  for (const ref of refs) {
109
- const resolved = ref ? await resolveSeoImageRef({ deps: deps.media, input: { workspaceId, ref } }) : undefined;
109
+ const resolved = ref ? await resolveSeoImageDetails({ deps: deps.media, input: { workspaceId, ref } }, { transformName: deps.shareImageTransform }) : undefined;
110
110
  if (resolved)
111
111
  return resolved;
112
112
  }
@@ -125,8 +125,8 @@ async function resolveShareImages(deps, post, overrides, settings, workspaceId,
125
125
  const ogImageResolved = await firstResolvedImage(deps, workspaceId, overrides.ogImage ? [overrides.ogImage] : fallbacks);
126
126
  const twitterImageResolved = await firstResolvedImage(deps, workspaceId, overrides.twitterImage ? [overrides.twitterImage] : fallbacks);
127
127
  return {
128
- ogImage: ogImageResolved ? toAbsoluteUrl({ origin, path: ogImageResolved }) : undefined,
129
- twitterImage: twitterImageResolved ? toAbsoluteUrl({ origin, path: twitterImageResolved }) : undefined,
128
+ ogImage: ogImageResolved ? { ...ogImageResolved, url: toAbsoluteUrl({ origin, path: ogImageResolved.url }) } : undefined,
129
+ twitterImage: twitterImageResolved ? { ...twitterImageResolved, url: toAbsoluteUrl({ origin, path: twitterImageResolved.url }) } : undefined,
130
130
  };
131
131
  }
132
132
  /** title: override, else titleTemplate applied to entry.title. description: override > site default >
@@ -150,7 +150,11 @@ function buildOpenGraph(overrides, title, description, ogType, canonical, ogImag
150
150
  description: overrides.ogDescription ?? description,
151
151
  type: ogType,
152
152
  url: canonical,
153
- image: ogImage,
153
+ image: ogImage?.url,
154
+ ...(ogImage?.ref === undefined ? {} : { imageRef: ogImage.ref }),
155
+ ...(ogImage?.width === undefined ? {} : { imageWidth: ogImage.width }),
156
+ ...(ogImage?.height === undefined ? {} : { imageHeight: ogImage.height }),
157
+ ...(ogImage?.type === undefined ? {} : { imageType: ogImage.type }),
154
158
  siteName: undefined,
155
159
  };
156
160
  }
@@ -159,7 +163,8 @@ function buildTwitter(overrides, title, description, twitterImage, settings) {
159
163
  card: overrides.twitterCard ?? "summary_large_image",
160
164
  title: overrides.twitterTitle ?? title,
161
165
  description: overrides.twitterDescription ?? description,
162
- image: twitterImage,
166
+ image: twitterImage?.url,
167
+ ...(twitterImage?.ref === undefined ? {} : { imageRef: twitterImage.ref }),
163
168
  site: settings.twitterSite,
164
169
  };
165
170
  }
@@ -188,6 +188,12 @@ export interface OpenGraph {
188
188
  type: OpenGraphType;
189
189
  url: string;
190
190
  image?: string | undefined;
191
+ /** Original selected source for admin diagnostics, before any share-card resize. */
192
+ imageRef?: string | undefined;
193
+ /** Known output metadata from the selected named transform, omitted for external URLs. */
194
+ imageWidth?: number | undefined;
195
+ imageHeight?: number | undefined;
196
+ imageType?: string | undefined;
191
197
  siteName?: string | undefined;
192
198
  }
193
199
  export interface TwitterCard {
@@ -195,6 +201,8 @@ export interface TwitterCard {
195
201
  title: string;
196
202
  description?: string | undefined;
197
203
  image?: string | undefined;
204
+ /** Original selected source for admin diagnostics, before any share-card resize. */
205
+ imageRef?: string | undefined;
198
206
  site?: string | undefined;
199
207
  }
200
208
  export interface RobotsDirective {
@@ -10,7 +10,5 @@
10
10
  */
11
11
  /** {@link escapeHtml} with the XML named entity `'` for the apostrophe (sitemap and feed XML).
12
12
  * @complexity O(text length). */
13
- export declare function escapeXml(required: {
14
- text: string;
15
- }, _optional?: Record<string, never>): string;
13
+ export { escapeXml } from "@jini-ai/core/text";
16
14
  //# sourceMappingURL=xml-escape.d.ts.map
@@ -8,12 +8,7 @@
8
8
  * other character first would double-escape the `&` that replacement introduced. The apostrophe
9
9
  * becomes the numeric `&#39;` in HTML, because `&apos;` is undefined in HTML4/XHTML1.
10
10
  */
11
- const XML_ENTITIES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" };
12
- const SPECIAL = /[&<>"']/g;
13
11
  /** {@link escapeHtml} with the XML named entity `&apos;` for the apostrophe (sitemap and feed XML).
14
12
  * @complexity O(text length). */
15
- // escapeHtml stays with the host renderer. A shared core owner for this leaf is a coordinator follow-up.
16
- export function escapeXml(required, _optional = {}) {
17
- return required.text.replace(SPECIAL, (char) => XML_ENTITIES[char] ?? char);
18
- }
13
+ export { escapeXml } from "@jini-ai/core/text";
19
14
  //# sourceMappingURL=xml-escape.js.map
@@ -1,3 +1,27 @@
1
+ /**
2
+ * @file A tiny per-key async mutex (SPEC-043 REQ-06/15, AC-04/EC-02).
3
+ *
4
+ * Purpose:
5
+ * `features/entries/write-service.ts`'s `updateEntry` reads the current row, checks
6
+ * `expectedVersion`, then writes — but `InMemoryEntryRepo.transaction` provides no real
7
+ * serialization (`async transaction(fn) { return fn(); }`), and the version check itself happens
8
+ * BEFORE that transaction call. Two truly concurrent callers racing the same entry id can both
9
+ * observe the pre-write version and both pass the check under the in-memory adapter (the real
10
+ * `SqliteEntryRepo.transaction`'s `BEGIN IMMEDIATE` already serializes real concurrent writers at
11
+ * the DB layer — this mutex is redundant-but-harmless there, and load-bearing for the in-memory
12
+ * path this test suite's concurrency assertions exercise).
13
+ *
14
+ * This module serializes the whole "read current version -> validate -> call updateEntry" sequence
15
+ * per entry id at the WIDGETS domain layer, restoring correct optimistic-concurrency semantics
16
+ * deterministically regardless of the underlying repo adapter's own concurrency properties.
17
+ *
18
+ * Architectural role:
19
+ * `widgets` domain-internal helper, not a frozen public contract.
20
+ */
21
+ export type EntryLockState = Map<string, Promise<void>>;
22
+ /** A host can own this state; the compatibility adapter still serializes existing callers.
23
+ * Idle keys are removed by withEntryLock. @complexity O(1). */
24
+ export declare function createEntryLockState(_required: Record<string, never>, _optional?: Record<string, never>): EntryLockState;
1
25
  /**
2
26
  * Runs `fn` exclusively with respect to every other `withEntryLock` call sharing the same `key` —
3
27
  * callers queue in call order, each waiting for the previous holder to finish.
@@ -8,5 +32,7 @@
8
32
  export declare function withEntryLock<T>({ key, fn }: {
9
33
  key: string;
10
34
  fn: () => Promise<T>;
11
- }, _optional?: Record<string, never>): Promise<T>;
35
+ }, { locks }?: {
36
+ locks?: EntryLockState;
37
+ }): Promise<T>;
12
38
  //# sourceMappingURL=concurrency.d.ts.map
@@ -1,24 +1,9 @@
1
- /**
2
- * @file A tiny per-key async mutex (SPEC-043 REQ-06/15, AC-04/EC-02).
3
- *
4
- * Purpose:
5
- * `features/entries/write-service.ts`'s `updateEntry` reads the current row, checks
6
- * `expectedVersion`, then writes — but `InMemoryEntryRepo.transaction` provides no real
7
- * serialization (`async transaction(fn) { return fn(); }`), and the version check itself happens
8
- * BEFORE that transaction call. Two truly concurrent callers racing the same entry id can both
9
- * observe the pre-write version and both pass the check under the in-memory adapter (the real
10
- * `SqliteEntryRepo.transaction`'s `BEGIN IMMEDIATE` already serializes real concurrent writers at
11
- * the DB layer — this mutex is redundant-but-harmless there, and load-bearing for the in-memory
12
- * path this test suite's concurrency assertions exercise).
13
- *
14
- * This module serializes the whole "read current version -> validate -> call updateEntry" sequence
15
- * per entry id at the WIDGETS domain layer, restoring correct optimistic-concurrency semantics
16
- * deterministically regardless of the underlying repo adapter's own concurrency properties.
17
- *
18
- * Architectural role:
19
- * `widgets` domain-internal helper, not a frozen public contract.
20
- */
21
- const locks = new Map();
1
+ /** A host can own this state; the compatibility adapter still serializes existing callers.
2
+ * Idle keys are removed by withEntryLock. @complexity O(1). */
3
+ export function createEntryLockState(_required, _optional = {}) {
4
+ return new Map();
5
+ }
6
+ const defaultLocks = createEntryLockState({});
22
7
  /**
23
8
  * Runs `fn` exclusively with respect to every other `withEntryLock` call sharing the same `key` —
24
9
  * callers queue in call order, each waiting for the previous holder to finish.
@@ -26,19 +11,23 @@ const locks = new Map();
26
11
  * @complexity O(1) scheduling overhead beyond `fn`'s own cost.
27
12
  * @overallScore 100
28
13
  */
29
- export async function withEntryLock({ key, fn }, _optional = {}) {
14
+ export async function withEntryLock({ key, fn }, { locks = defaultLocks } = {}) {
30
15
  const previous = locks.get(key) ?? Promise.resolve();
31
16
  let release;
32
17
  const next = new Promise((resolve) => {
33
18
  release = resolve;
34
19
  });
35
- locks.set(key, previous.then(() => next));
20
+ const tail = previous.then(() => next);
21
+ locks.set(key, tail);
36
22
  await previous;
37
23
  try {
38
24
  return await fn();
39
25
  }
40
26
  finally {
41
27
  release();
28
+ // A newer waiter owns the key until it finishes; deleting its tail would let callers race.
29
+ if (locks.get(key) === tail)
30
+ locks.delete(key);
42
31
  }
43
32
  }
44
33
  //# sourceMappingURL=concurrency.js.map
@@ -1,3 +1,4 @@
1
+ import { parseGalleryConfig } from "./markers/gallery.js";
1
2
  import { isNonArrayRecord } from "@jini-ai/core";
2
3
  /** Reports every key in `schema.required` that is missing from `value`. */
3
4
  function validateRequiredKeys(value, schema, path, errors) {
@@ -94,6 +95,11 @@ function walk(value, schema, path, errors) {
94
95
  export function validateWidgetConfig(required, _optional = {}) {
95
96
  const errors = [];
96
97
  walk(required.config, required.schema, "config", errors);
98
+ // The shared parser owns layout, id and CSS-ratio invariants; the schema narrows widgets to library images.
99
+ if (required.schema["x-gallery-config"] === true) {
100
+ for (const reason of parseGalleryConfig({ config: required.config }, { allowEmpty: true }).errors)
101
+ errors.push({ field: "config", reason });
102
+ }
97
103
  return { valid: errors.length === 0, fieldErrors: errors };
98
104
  }
99
105
  /** Pushes each declared free-text string under `schema` (recursion into declared `properties` and
@@ -0,0 +1,23 @@
1
+ import type { PageHtmlEmbedRef } from './html/html-embeds.js';
2
+ import type { WidgetRenderIR } from './types.js';
3
+ export type GalleryMediaResolver = (required: {
4
+ refs: readonly PageHtmlEmbedRef[];
5
+ }, optional?: Record<string, never>) => Promise<ReadonlyMap<string, WidgetRenderIR>>;
6
+ export type GalleryIssueReporter = (required: {
7
+ key: string | null;
8
+ messages: readonly string[];
9
+ }, optional?: Record<string, never>) => void;
10
+ /** Resolve bounded, deduplicated gallery assets through the existing media resolver port.
11
+ * Occurrence keys preserve different layouts/alt/captions for the same assets on one page.
12
+ * At the page media budget, whole later galleries degrade instead of silently losing images.
13
+ * @complexity O(g*i) time/space for g markers and i<=50 images; one media batch of at most maxMedia.
14
+ */
15
+ export declare function resolveGalleryEmbeds({ refs, resolveMedia }: {
16
+ refs: readonly PageHtmlEmbedRef[];
17
+ resolveMedia: GalleryMediaResolver;
18
+ }, { maxMedia, timeoutMs, reportIssue }?: {
19
+ maxMedia?: number;
20
+ timeoutMs?: number;
21
+ reportIssue?: GalleryIssueReporter;
22
+ }): Promise<ReadonlyMap<string, WidgetRenderIR>>;
23
+ //# sourceMappingURL=gallery-resolver.d.ts.map
@@ -0,0 +1,80 @@
1
+ import { galleryMediaReferences, parseGalleryConfig } from './markers/gallery.js';
2
+ /** Bound the media wait without taking ownership of the host repository's cancellation policy.
3
+ * The port has no abort signal; an expired call may finish in the background but is never awaited
4
+ * indefinitely and its timer is always released. No retries or duplicate repository calls.
5
+ */
6
+ async function resolveGalleryMedia({ resolveMedia, refs, timeoutMs }) {
7
+ let timer;
8
+ try {
9
+ return await Promise.race([
10
+ resolveMedia({ refs }),
11
+ new Promise(resolve => { timer = setTimeout(() => resolve(undefined), timeoutMs); }),
12
+ ]);
13
+ }
14
+ finally {
15
+ clearTimeout(timer);
16
+ }
17
+ }
18
+ /** Resolve bounded, deduplicated gallery assets through the existing media resolver port.
19
+ * Occurrence keys preserve different layouts/alt/captions for the same assets on one page.
20
+ * At the page media budget, whole later galleries degrade instead of silently losing images.
21
+ * @complexity O(g*i) time/space for g markers and i<=50 images; one media batch of at most maxMedia.
22
+ */
23
+ export async function resolveGalleryEmbeds({ refs, resolveMedia }, { maxMedia = 50, timeoutMs = 10000, reportIssue } = {}) {
24
+ const accepted = [];
25
+ const legacyIds = new Set();
26
+ const slugKeys = new Set();
27
+ const mediaRefs = new Map();
28
+ for (const ref of refs) {
29
+ const config = ref.config?.content ?? ref.config;
30
+ const parsed = parseGalleryConfig({ config }, { allowEmpty: ref.type === 'media' });
31
+ if (!parsed.config || ref.id === null) {
32
+ reportIssue?.({ key: ref.id, messages: parsed.errors });
33
+ continue;
34
+ }
35
+ const ids = new Set(parsed.config.mode === 'html'
36
+ ? galleryMediaReferences({ config: { html: parsed.config.html } }).map(item => item.mediaId)
37
+ : parsed.config.images.flatMap(item => (item.slug ?? item.mediaId) === undefined ? [] : [(item.slug ?? item.mediaId)]));
38
+ const added = [...ids].filter(id => !mediaRefs.has(id));
39
+ if (mediaRefs.size + added.length > maxMedia) {
40
+ reportIssue?.({ key: ref.id, messages: [`gallery media exceeds the page budget of ${maxMedia} distinct assets`] });
41
+ continue;
42
+ }
43
+ accepted.push({ ...ref, config: config });
44
+ const legacy = parsed.config.mode === undefined;
45
+ for (const key of ids)
46
+ (legacy ? legacyIds : slugKeys).add(key);
47
+ for (const id of added)
48
+ mediaRefs.set(id, { type: 'media', id: legacy ? id : null, slug: legacy ? null : id, name: null, variant: null, header: true });
49
+ }
50
+ // No second repository lifecycle: media owns workspace lookup, visibility, transforms and metadata.
51
+ const media = mediaRefs.size === 0 ? new Map() : await resolveGalleryMedia({ resolveMedia, refs: [...mediaRefs.values()], timeoutMs });
52
+ if (media === undefined) {
53
+ reportIssue?.({ key: null, messages: ['gallery media resolution timed out'] });
54
+ return new Map();
55
+ }
56
+ // Author-supplied ids are map keys, including names such as __proto__; never assign them onto
57
+ // an object with a prototype or inherit a fake media record from Object.prototype.
58
+ const imageProps = Object.create(null);
59
+ for (const [id, ir] of media) {
60
+ // Legacy mediaId is an indexed asset id, not the single-media marker's slug alias. The reused
61
+ // media resolver accepts both; accepting a slug here would render an asset that the pure
62
+ // entry_refs extractor cannot protect, since it cannot resolve a slug to the canonical id.
63
+ // New slug content deliberately indexes authored keys; the host usage adapter reads id and slug.
64
+ if (legacyIds.has(id) && !slugKeys.has(id)) {
65
+ if (ir.props.assetId !== id) {
66
+ reportIssue?.({ key: id, messages: ['gallery mediaId must be the canonical media id, not a slug'] });
67
+ continue;
68
+ }
69
+ // A video resolved by the generic media pipeline is not an image in a legacy gallery.
70
+ if (typeof ir.props.contentType === 'string' && (ir.props.contentType.startsWith('video/') || ir.props.contentType.startsWith('audio/')))
71
+ continue;
72
+ }
73
+ imageProps[id] = ir.props;
74
+ }
75
+ for (const key of slugKeys)
76
+ if (!media.has(key))
77
+ reportIssue?.({ key, messages: [`unknown media slug: ${key}`] });
78
+ return new Map(accepted.map(ref => [ref.id, { componentId: ref.type === 'media' ? 'media' : 'gallery', props: { config: ref.config, media: imageProps } }]));
79
+ }
80
+ //# sourceMappingURL=gallery-resolver.js.map
@@ -0,0 +1,24 @@
1
+ import type { JsonObject } from '@jini-ai/core/primitives';
2
+ import { type GalleryImage } from '../markers/gallery.js';
3
+ /** Layout-only baseline; host themes override jini classes/custom properties without palette coupling. */
4
+ export declare const GALLERY_STYLE = "<style data-jini-gallery-style>\n.jini-gallery{gap:var(--jini-gallery-gap,1rem)}\n.jini-gallery-item{margin:0;min-width:0;break-inside:avoid}\n.jini-gallery audio{display:block;width:100%}\n.jini-gallery img,.jini-gallery video{display:block;width:100%;height:auto;aspect-ratio:var(--jini-gallery-ratio,auto);object-fit:cover}\n.jini-gallery-single{display:grid;grid-template-columns:1fr}\n.jini-gallery-grid{display:grid;grid-template-columns:repeat(var(--jini-gallery-columns,3),minmax(0,1fr))}\n.jini-gallery-masonry{columns:var(--jini-gallery-columns,3);column-gap:var(--jini-gallery-gap,1rem)}\n.jini-gallery-masonry .jini-gallery-item{margin-block-end:var(--jini-gallery-gap,1rem)}\n.jini-gallery-carousel{display:flex;overflow-x:auto;scroll-snap-type:x mandatory}\n.jini-gallery-carousel .jini-gallery-item{flex:0 0 min(85%,36rem);scroll-snap-align:start}\n@media(max-width:48rem){.jini-gallery-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.jini-gallery-masonry{columns:2}}\n@media(max-width:30rem){.jini-gallery-grid{grid-template-columns:1fr}.jini-gallery-masonry{columns:1}}\n</style>";
5
+ export type GalleryImageRenderer = (required: {
6
+ image: GalleryImage & {
7
+ alt: string;
8
+ };
9
+ media: JsonObject | undefined;
10
+ }, optional?: Record<string, never>) => string;
11
+ /** Server-render accessible gallery markup. Image URLs, metadata and srcset belong to the host
12
+ * image pipeline supplied as a port, never templated here. Invalid config produces no output.
13
+ * @complexity O(n) time and output space for at most 50 images, without I/O.
14
+ */
15
+ export declare function renderGallery({ config, media, renderImage, renderRawHtml }: {
16
+ config: unknown;
17
+ media?: unknown;
18
+ renderImage: GalleryImageRenderer;
19
+ renderRawHtml?: (required: {
20
+ html: string;
21
+ media: Record<string, JsonObject>;
22
+ }, optional?: Record<string, never>) => string;
23
+ }, _optional?: Record<string, never>): string;
24
+ //# sourceMappingURL=gallery-render.d.ts.map
@@ -0,0 +1,65 @@
1
+ import { escapeHtml } from '@jini-ai/core/text';
2
+ import { parseEmbedHtmlAttributes } from '../markers/html-attributes.js';
3
+ import { parseGalleryConfig } from '../markers/gallery.js';
4
+ /** Layout-only baseline; host themes override jini classes/custom properties without palette coupling. */
5
+ export const GALLERY_STYLE = `<style data-jini-gallery-style>
6
+ .jini-gallery{gap:var(--jini-gallery-gap,1rem)}
7
+ .jini-gallery-item{margin:0;min-width:0;break-inside:avoid}
8
+ .jini-gallery audio{display:block;width:100%}
9
+ .jini-gallery img,.jini-gallery video{display:block;width:100%;height:auto;aspect-ratio:var(--jini-gallery-ratio,auto);object-fit:cover}
10
+ .jini-gallery-single{display:grid;grid-template-columns:1fr}
11
+ .jini-gallery-grid{display:grid;grid-template-columns:repeat(var(--jini-gallery-columns,3),minmax(0,1fr))}
12
+ .jini-gallery-masonry{columns:var(--jini-gallery-columns,3);column-gap:var(--jini-gallery-gap,1rem)}
13
+ .jini-gallery-masonry .jini-gallery-item{margin-block-end:var(--jini-gallery-gap,1rem)}
14
+ .jini-gallery-carousel{display:flex;overflow-x:auto;scroll-snap-type:x mandatory}
15
+ .jini-gallery-carousel .jini-gallery-item{flex:0 0 min(85%,36rem);scroll-snap-align:start}
16
+ @media(max-width:48rem){.jini-gallery-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.jini-gallery-masonry{columns:2}}
17
+ @media(max-width:30rem){.jini-gallery-grid{grid-template-columns:1fr}.jini-gallery-masonry{columns:1}}
18
+ </style>`;
19
+ /** Render one ordered figure using the host's established image renderer and alt fallback.
20
+ * Missing alt is flagged in markup as well as validation; an explicit empty alt stays decorative.
21
+ */
22
+ function renderFigure({ image, media, renderImage }) {
23
+ if ((image.slug !== undefined || image.mediaId !== undefined) && !media)
24
+ return '';
25
+ const storedAlt = typeof media?.alt === 'string' && media.alt.length > 0 ? media.alt : undefined;
26
+ const alt = image.alt ?? storedAlt;
27
+ const missingAlt = alt === undefined ? ' data-jini-alt-missing="true"' : '';
28
+ const markup = renderImage({ image: { ...image, alt: alt ?? '' }, media });
29
+ const caption = image.caption === undefined ? '' : `<figcaption>${escapeHtml({ text: image.caption })}</figcaption>`;
30
+ return `<figure class="jini-gallery-item"${missingAlt}>${markup}${caption}</figure>`;
31
+ }
32
+ /** Server-render accessible gallery markup. Image URLs, metadata and srcset belong to the host
33
+ * image pipeline supplied as a port, never templated here. Invalid config produces no output.
34
+ * @complexity O(n) time and output space for at most 50 images, without I/O.
35
+ */
36
+ export function renderGallery({ config, media, renderImage, renderRawHtml }, _optional = {}) {
37
+ const parsed = parseGalleryConfig({ config }, { allowEmpty: true });
38
+ if (!parsed.config)
39
+ return '';
40
+ const gallery = parsed.config;
41
+ const records = typeof media === 'object' && media !== null && !Array.isArray(media) ? media : {};
42
+ if (gallery.mode === 'html')
43
+ return renderRawHtml?.({ html: gallery.html ?? '', media: records }) ?? '';
44
+ // Media-mode configs come from the Media widget editor; naming the Gallery editor there misleads.
45
+ if (gallery.images.length === 0)
46
+ return `<div class="jini-gallery jini-gallery-empty" role="note">${gallery.mode === 'media' ? 'Choose media in the Media widget editor.' : 'Choose images in the Gallery widget editor.'}</div>`;
47
+ const figures = gallery.images.map(image => renderFigure({ image, media: (image.slug ?? image.mediaId) !== undefined && Object.hasOwn(records, (image.slug ?? image.mediaId)) ? records[(image.slug ?? image.mediaId)] : undefined, renderImage })).join('');
48
+ const ratio = gallery.aspectRatio === undefined ? '' : `;--jini-gallery-ratio:${gallery.aspectRatio}`;
49
+ // TODO: bind data-jini-lightbox through a host theme's lightbox script slot when one exists.
50
+ // Until then the gallery stays readable and keyboard-scrollable without any client script.
51
+ const attributes = {
52
+ role: 'region', 'aria-label': gallery.mode === 'media' ? 'Media' : 'Image gallery',
53
+ ...(gallery.layout === 'carousel' ? { tabindex: '0', 'aria-roledescription': 'carousel' } : {}),
54
+ ...(gallery.lightbox === true ? { 'data-jini-lightbox': 'true' } : {}),
55
+ ...parseEmbedHtmlAttributes({ text: gallery.htmlAttributes ?? '' }).attributes,
56
+ };
57
+ const extraClass = [gallery.cssClass, attributes.class].filter(Boolean).join(' ');
58
+ delete attributes.class;
59
+ // Layout styles remain the base; authored safe style declarations override them in order.
60
+ const authoredStyle = attributes.style;
61
+ delete attributes.style;
62
+ const extra = Object.entries(attributes).map(([name, value]) => ` ${name}="${escapeHtml({ text: value })}"`).join('');
63
+ return GALLERY_STYLE + `<div class="jini-gallery jini-gallery-${gallery.layout}${extraClass ? ` ${escapeHtml({ text: extraClass })}` : ''}"${extra} style="--jini-gallery-columns:${gallery.columns ?? 3}${ratio}${authoredStyle ? `;${escapeHtml({ text: authoredStyle })}` : ''}">${figures}</div>`;
64
+ }
65
+ //# sourceMappingURL=gallery-render.js.map
@@ -107,6 +107,8 @@ import { type MarkerAttribute } from "../markers/marker.js";
107
107
  * — this interface is the compatibility seam, not a ceiling.
108
108
  */
109
109
  export interface PageHtmlEmbedRef {
110
+ /** Occurrence identity for placement overrides sharing one saved widget. */
111
+ readonly placementKey?: string;
110
112
  readonly type: string;
111
113
  readonly id: string | null;
112
114
  /** See this file's header for why this is NOT the same mechanism as `name` below, despite the
@@ -129,6 +131,8 @@ export interface PageHtmlEmbedRef {
129
131
  */
130
132
  readonly header: boolean;
131
133
  readonly mode?: "html";
134
+ /** Full inline configuration for compound embeds; existing scalar projections remain stable. */
135
+ readonly config?: Readonly<Record<string, unknown>>;
132
136
  }
133
137
  /**
134
138
  * Resource bound (Programmer workflow step 5a4) mirroring `embed-service.ts`'s
@@ -156,12 +156,18 @@ function configBooleanDefaultTrue(config, key) {
156
156
  function toEmbedRef(marker) {
157
157
  return {
158
158
  type: marker.type.toLowerCase(),
159
- id: normalizeEmbedId(configString(marker.config, "id")),
159
+ // Only saved widget overlays use placement keys. Inline Media content already has its own
160
+ // occurrence id below; a second key would make rendering miss the resolver's media:<n> result.
161
+ ...(marker.type.toLowerCase() === "widget" && marker.config.content !== undefined ? { placementKey: `placement:${marker.occurrence}` } : {}),
162
+ // Inline galleries have no target entity. Key by occurrence so repeated assets with different
163
+ // layouts/captions cannot overwrite each other's resolved IR, even with an authored id.
164
+ id: (marker.type.toLowerCase() === "gallery" || (marker.type.toLowerCase() === "media" && (marker.config.content !== undefined || marker.config.items !== undefined || marker.config.html !== undefined))) ? `${marker.type.toLowerCase()}:${marker.occurrence}` : normalizeEmbedId(configString(marker.config, "id")),
160
165
  slug: normalizeEmbedSlug(configString(marker.config, "slug")),
161
166
  name: configString(marker.config, "name"),
162
167
  variant: configString(marker.config, "variant"),
163
168
  header: configBooleanDefaultTrue(marker.config, "header"),
164
169
  ...(marker.config.mode === "html" ? { mode: "html" } : {}),
170
+ config: marker.config,
165
171
  };
166
172
  }
167
173
  /**
@@ -1,5 +1,8 @@
1
+ export { resolveGalleryWidgetMedia } from "../resolver-service.js";
1
2
  export * from "./html-embeds.js";
2
3
  export * from "./legacy-widget-markers.js";
4
+ export * from "./gallery-render.js";
3
5
  export * from "./resolve-html-page-embeds.js";
4
6
  export type { ResolveHtmlPageEmbedsDeps } from "../resolver-service.js";
7
+ export * from './server-fragment.js';
5
8
  //# sourceMappingURL=index.d.ts.map
@@ -1,4 +1,7 @@
1
+ export { resolveGalleryWidgetMedia } from "../resolver-service.js";
1
2
  export * from "./html-embeds.js";
2
3
  export * from "./legacy-widget-markers.js";
4
+ export * from "./gallery-render.js";
3
5
  export * from "./resolve-html-page-embeds.js";
6
+ export * from './server-fragment.js';
4
7
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,9 @@
1
+ /** Reject executable/unsupported markup rather than quietly preserving a plugin's unsafe output.
2
+ * URLs are local or HTTP(S); form submission remains native. No inline style, events, SVG,
3
+ * iframe, script, srcdoc, data URLs, or enhancement assets are admitted. */
4
+ export declare function validateServerWidgetFragment(required: {
5
+ html: string;
6
+ }, optional?: {
7
+ maxBytes?: number;
8
+ }): string;
9
+ //# sourceMappingURL=server-fragment.d.ts.map
@@ -0,0 +1,38 @@
1
+ /** Public plugin fragments are HTML, never same-origin script (ADR-025). Use the existing
2
+ * HTML parser, not regex sanitization: decoded attributes and foreign namespaces matter. */
3
+ import { parseFragment, serialize } from 'parse5';
4
+ const ELEMENTS = new Set(['div', 'span', 'p', 'br', 'hr', 'strong', 'em', 'b', 'i', 'small', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code', 'a', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'section', 'article', 'aside', 'header', 'footer', 'nav', 'form', 'label', 'input', 'textarea', 'select', 'option', 'button']);
5
+ const ATTRIBUTES = new Set(['id', 'class', 'title', 'role', 'href', 'src', 'alt', 'width', 'height', 'action', 'method', 'name', 'value', 'type', 'required', 'disabled', 'checked', 'selected', 'multiple', 'placeholder', 'for', 'rows', 'cols', 'min', 'max', 'step', 'colspan', 'rowspan']);
6
+ /** Reject executable/unsupported markup rather than quietly preserving a plugin's unsafe output.
7
+ * URLs are local or HTTP(S); form submission remains native. No inline style, events, SVG,
8
+ * iframe, script, srcdoc, data URLs, or enhancement assets are admitted. */
9
+ export function validateServerWidgetFragment(required, optional = {}) {
10
+ if (new TextEncoder().encode(required.html).byteLength > (optional.maxBytes ?? 65536))
11
+ throw new Error('widget HTML exceeds its size cap');
12
+ const root = parseFragment(required.html);
13
+ const visit = (node) => {
14
+ if ('tagName' in node) {
15
+ if (node.namespaceURI !== 'http://www.w3.org/1999/xhtml' || !ELEMENTS.has(node.tagName))
16
+ throw new Error('executable or unsupported widget element');
17
+ for (const attr of node.attrs) {
18
+ if (attr.namespace || (!ATTRIBUTES.has(attr.name) && !/^aria-[a-z-]+$/.test(attr.name)))
19
+ throw new Error('executable or unsupported widget attribute');
20
+ if (['href', 'src', 'action'].includes(attr.name)) {
21
+ // URL parsing catches entity-decoded schemes and embedded tab/newline obfuscation.
22
+ const value = attr.value;
23
+ if (/[\u0000-\u0020\\]/.test(value) || value.startsWith('//'))
24
+ throw new Error('unsafe widget URL');
25
+ const url = new URL(value, 'https://widget.invalid/');
26
+ if (!['http:', 'https:'].includes(url.protocol))
27
+ throw new Error('unsafe widget URL');
28
+ }
29
+ }
30
+ for (const child of node.childNodes)
31
+ visit(child);
32
+ }
33
+ };
34
+ for (const node of root.childNodes)
35
+ visit(node);
36
+ return serialize(root);
37
+ }
38
+ //# sourceMappingURL=server-fragment.js.map