@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
@@ -151,7 +151,10 @@ export class SharpImageTransformer {
151
151
  fit: input.params.fit ?? "cover",
152
152
  });
153
153
  }
154
- pipeline = pipeline.toFormat(input.params.format);
154
+ // JPEG cannot carry alpha; explicit white avoids libvips' black background for logos.
155
+ if (input.params.format === "jpeg")
156
+ pipeline = pipeline.flatten({ background: "#ffffff" });
157
+ pipeline = pipeline.toFormat(input.params.format, input.params.quality === undefined ? undefined : { quality: input.params.quality });
155
158
  const outBuffer = await pipeline.toBuffer();
156
159
  return {
157
160
  bytes: new Uint8Array(outBuffer),
@@ -27,6 +27,10 @@ export declare const DEFAULT_MAX_UPLOAD_BYTES: number;
27
27
  * `routes/site/media-rendition.ts`). This widens the SAME advisory,
28
28
  * client-declared-string check item 4 above already discloses as untrusted —
29
29
  * no new ingress hardening was added for video.
30
+ *
31
+ * Audio (owner-approved mediawidget, 2026-10-10/11) follows that same original-byte
32
+ * lifecycle. Only formats recognized by the shared sniffer are allowed; no image
33
+ * transform is requested for them.
30
34
  */
31
35
  export declare const DEFAULT_ALLOWED_MIME_TYPES: ReadonlySet<string>;
32
36
  /**
@@ -100,6 +100,10 @@ function formatMaxUploadBytesAsMb(bytes) {
100
100
  * `routes/site/media-rendition.ts`). This widens the SAME advisory,
101
101
  * client-declared-string check item 4 above already discloses as untrusted —
102
102
  * no new ingress hardening was added for video.
103
+ *
104
+ * Audio (owner-approved mediawidget, 2026-10-10/11) follows that same original-byte
105
+ * lifecycle. Only formats recognized by the shared sniffer are allowed; no image
106
+ * transform is requested for them.
103
107
  */
104
108
  export const DEFAULT_ALLOWED_MIME_TYPES = new Set([
105
109
  "image/jpeg",
@@ -109,6 +113,10 @@ export const DEFAULT_ALLOWED_MIME_TYPES = new Set([
109
113
  "image/avif",
110
114
  "video/mp4",
111
115
  "video/webm",
116
+ "audio/wav",
117
+ "audio/mpeg",
118
+ "audio/flac",
119
+ "audio/mp4",
112
120
  ]);
113
121
  /**
114
122
  * Enforces the "settable-once-from-absent" write-once rule for
@@ -1,5 +1,5 @@
1
1
  /** Node-bound media adapters; hosts supply the filesystem and optional codecs. */
2
2
  export { LocalFsBlobStore, type LocalFsBlobStoreDeps } from "./blob-store.fs.js";
3
3
  export { SharpImageTransformer, ImageTransformUnavailableError, ImageSourceCorruptError } from "./image-transformer.sharp.js";
4
- export { createFfmpegVideoFrameExtractor, findVideoBinaries, runLowPriorityVideoProcess, type VideoBinaries, type VideoBinaryFinder, type VideoProcessRunner, } from './video-frames.ffmpeg.js';
4
+ export { createFfmpegVideoFrameExtractor, createVideoExtractionLock, findVideoBinaries, runLowPriorityVideoProcess, type VideoExtractionLock, type VideoBinaries, type VideoBinaryFinder, type VideoProcessRunner, } from './video-frames.ffmpeg.js';
5
5
  //# sourceMappingURL=node.d.ts.map
@@ -2,5 +2,5 @@
2
2
  export { LocalFsBlobStore } from "./blob-store.fs.js";
3
3
  export { SharpImageTransformer, ImageTransformUnavailableError, ImageSourceCorruptError } from "./image-transformer.sharp.js";
4
4
  // Video previews: policy/port plus a bounded optional-host codec adapter.
5
- export { createFfmpegVideoFrameExtractor, findVideoBinaries, runLowPriorityVideoProcess, } from './video-frames.ffmpeg.js';
5
+ export { createFfmpegVideoFrameExtractor, createVideoExtractionLock, findVideoBinaries, runLowPriorityVideoProcess, } from './video-frames.ffmpeg.js';
6
6
  //# sourceMappingURL=node.js.map
@@ -26,7 +26,7 @@ export async function registerTransform(required, _optional = {}) {
26
26
  const owner = input.owner.trim();
27
27
  if (!owner)
28
28
  throw new TransformValidationError({ message: "owner is required" });
29
- assertValidTransformParams({ format: input.params.format }, { width: input.params.width, height: input.params.height, fit: input.params.fit });
29
+ assertValidTransformParams({ format: input.params.format }, { width: input.params.width, height: input.params.height, fit: input.params.fit, quality: input.params.quality });
30
30
  return withTransformRegistryLock({ key: `${input.workspaceId}:${name}`, criticalSection: async () => {
31
31
  const existingVersions = await deps.transformRepo.listByName({ workspaceId: input.workspaceId, name });
32
32
  const currentMaxVersion = existingVersions.reduce((max, row) => Math.max(max, row.version), 0);
@@ -41,6 +41,8 @@ export interface TransformParams {
41
41
  height?: number | undefined;
42
42
  fit?: TransformFit | undefined;
43
43
  format: TransformFormat;
44
+ /** Encoder quality, 1–100; omitted preserves the codec default. */
45
+ quality?: number | undefined;
44
46
  }
45
47
  /**
46
48
  * `transform_registry` sidecar row. Identity is
@@ -45,6 +45,9 @@ export function assertValidTransformParams(required, optional = {}) {
45
45
  throw new TransformValidationError({ message: `transform params.${key} must be an integer between 1 and ${MAX_TRANSFORM_DIMENSION_PX}` });
46
46
  }
47
47
  }
48
+ if (params.quality !== undefined && (!Number.isInteger(params.quality) || params.quality < 1 || params.quality > 100)) {
49
+ throw new TransformValidationError({ message: "transform params.quality must be an integer between 1 and 100" });
50
+ }
48
51
  if (params.fit !== undefined && !(params.width && params.height)) {
49
52
  throw new TransformValidationError({ message: "transform params.fit requires both width and height" });
50
53
  }
@@ -5,7 +5,10 @@ export interface VideoBinaries {
5
5
  readonly ffmpeg: string;
6
6
  readonly ffprobe: string;
7
7
  }
8
- export type VideoBinaryFinder = (required: Record<string, never>, optional: Record<string, never>) => Promise<VideoBinaries | undefined>;
8
+ export type VideoBinaryFinder = (required: Record<string, never>, optional: {
9
+ env?: NodeJS.ProcessEnv;
10
+ platform?: NodeJS.Platform;
11
+ }) => Promise<VideoBinaries | undefined>;
9
12
  export type VideoProcessRunner = (required: {
10
13
  command: string;
11
14
  args: readonly string[];
@@ -31,7 +34,17 @@ export declare const runLowPriorityVideoProcess: ({ command, args, spawnProcess,
31
34
  maxOutputBytes: number;
32
35
  signal?: AbortSignal;
33
36
  }) => Promise<Uint8Array>;
34
- export declare function createFfmpegVideoFrameExtractor({ findBinaries, runProcess, nowMs }: {
37
+ export interface VideoExtractionLock {
38
+ /** Synchronous acquisition prevents races before the first awaited probe. */
39
+ tryAcquire(required: Record<string, never>, optional: Record<string, never>): (() => void) | undefined;
40
+ }
41
+ /** A host may share this lock across its adapters to enforce a decoder budget.
42
+ * Returns a synchronous try-acquire port; release follows decoder cleanup. @complexity O(1). */
43
+ export declare function createVideoExtractionLock(_required: Record<string, never>, _optional?: Record<string, never>): VideoExtractionLock;
44
+ export declare function createFfmpegVideoFrameExtractor({ findBinaries, runProcess, nowMs, lock, env, platform }: {
45
+ lock?: VideoExtractionLock;
46
+ env?: NodeJS.ProcessEnv;
47
+ platform?: NodeJS.Platform;
35
48
  findBinaries?: VideoBinaryFinder;
36
49
  runProcess?: VideoProcessRunner;
37
50
  nowMs?: (required: Record<string, never>, optional: Record<string, never>) => number;
@@ -13,9 +13,9 @@ class VideoProcessError extends Error {
13
13
  this.reason = reason;
14
14
  }
15
15
  }
16
- async function executable(required, { configured } = {}) {
16
+ async function executable(required, { configured, env = process.env } = {}) {
17
17
  const candidates = configured ? [configured] : [
18
- ...(process.env['PATH'] ?? '').split(delimiter).filter(isAbsolute).map(directory => join(directory, required.name)),
18
+ ...(env['PATH'] ?? '').split(delimiter).filter(isAbsolute).map(directory => join(directory, required.name)),
19
19
  join('/opt/homebrew/bin', required.name), join('/usr/local/bin', required.name), join('/usr/bin', required.name),
20
20
  ];
21
21
  for (const candidate of candidates) {
@@ -32,12 +32,12 @@ async function executable(required, { configured } = {}) {
32
32
  }
33
33
  /** Discovery never executes a version/probe command. Configured paths are host configuration,
34
34
  * never tool arguments, and all subprocesses (including ffprobe) still go through nice. */
35
- export const findVideoBinaries = async (_required, _optional) => {
36
- if (process.platform === 'win32')
35
+ export const findVideoBinaries = async (_required, { env = process.env, platform = process.platform } = {}) => {
36
+ if (platform === 'win32')
37
37
  return undefined; // Never silently omit the low-priority guarantee.
38
- const nice = await executable({ name: 'nice' }, {});
39
- const ffmpeg = await executable({ name: 'ffmpeg' }, { configured: process.env['FFMPEG_PATH'] });
40
- const ffprobe = await executable({ name: 'ffprobe' }, { configured: process.env['FFPROBE_PATH'] });
38
+ const nice = await executable({ name: 'nice' }, { env });
39
+ const ffmpeg = await executable({ name: 'ffmpeg' }, { configured: env['FFMPEG_PATH'], env });
40
+ const ffprobe = await executable({ name: 'ffprobe' }, { configured: env['FFPROBE_PATH'], env });
41
41
  return nice && ffmpeg && ffprobe ? { nice, ffmpeg, ffprobe } : undefined;
42
42
  };
43
43
  /** detached creates a group including nice and its eventual executable; killing the group prevents
@@ -99,25 +99,40 @@ export const runLowPriorityVideoProcess = async ({ command, args, spawnProcess =
99
99
  abort();
100
100
  });
101
101
  };
102
+ /** A host may share this lock across its adapters to enforce a decoder budget.
103
+ * Returns a synchronous try-acquire port; release follows decoder cleanup. @complexity O(1). */
104
+ export function createVideoExtractionLock(_required, _optional = {}) {
105
+ let extracting = false;
106
+ return { tryAcquire() {
107
+ if (extracting)
108
+ return undefined;
109
+ extracting = true;
110
+ let released = false;
111
+ return () => { if (!released) {
112
+ released = true;
113
+ extracting = false;
114
+ } };
115
+ } };
116
+ }
102
117
  // Shared across ALL adapter instances: a second catalog/tenant cannot run a second ffmpeg in this
103
118
  // process. Fail busy instead of holding arbitrary numbers of uploaded videos in a pending queue.
104
- let extracting = false;
119
+ const defaultExtractionLock = createVideoExtractionLock({});
105
120
  const UNAVAILABLE = 'video preview unavailable on this host (nice, ffmpeg and ffprobe are required).';
106
121
  // Eight amortizes process/seek overhead while bounding expensive source decoders to
107
122
  // one eighth of the 64-frame maximum. Thread caps alone do not bound decoder memory.
108
123
  // This is a decoder-count limit, not a measured host RSS guarantee.
109
124
  const SOURCE_BATCH_SIZE = 8;
110
125
  const localInputFlags = ['-threads', '1', '-protocol_whitelist', 'file', '-format_whitelist', 'mov,matroska,webm'];
111
- export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinaries, runProcess = runLowPriorityVideoProcess, nowMs = () => performance.now() }, _optional = {}) {
126
+ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinaries, runProcess = runLowPriorityVideoProcess, nowMs = () => performance.now(), lock = defaultExtractionLock, env, platform }, _optional = {}) {
112
127
  return { extract: async ({ bytes }, options) => {
113
128
  if (bytes.byteLength > VIDEO_MAX_INPUT_BYTES)
114
129
  return { ok: false, reason: 'too-large', message: `Video preview input exceeds ${VIDEO_MAX_INPUT_BYTES} bytes.` };
115
130
  const type = sniffContentType({ bytes });
116
131
  if (type !== 'video/mp4' && type !== 'video/webm')
117
132
  return { ok: false, reason: 'invalid', message: 'Video preview requires stored MP4 or WebM bytes.' };
118
- if (extracting)
133
+ const release = lock.tryAcquire({}, {});
134
+ if (!release)
119
135
  return { ok: false, reason: 'busy', message: 'Video preview is busy. Try again after the current extraction finishes.' };
120
- extracting = true;
121
136
  const deadline = nowMs({}, {}) + VIDEO_EXTRACTION_TIMEOUT_MS;
122
137
  let directory;
123
138
  try {
@@ -125,7 +140,7 @@ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinari
125
140
  throw new VideoProcessError('canceled', 'Video preview canceled.');
126
141
  if (options.layout !== undefined && options.layout !== 'frames' && options.layout !== 'sheet')
127
142
  throw new VideoProcessError('invalid', 'Video layout must be frames or sheet.');
128
- const binaries = await findBinaries({}, {});
143
+ const binaries = await findBinaries({}, { ...(env ? { env } : {}), ...(platform ? { platform } : {}) });
129
144
  if (!binaries)
130
145
  return { ok: false, reason: 'unavailable', message: UNAVAILABLE };
131
146
  directory = await mkdtemp(join(tmpdir(), 'video-preview-'));
@@ -253,7 +268,7 @@ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinari
253
268
  return { ok: false, reason: 'invalid', message: 'Video preview temporary files could not be removed.' };
254
269
  }
255
270
  finally {
256
- extracting = false;
271
+ release();
257
272
  }
258
273
  }
259
274
  } };
@@ -71,6 +71,18 @@ const MENU_ID_SCHEMA = {
71
71
  type: "string",
72
72
  description: "The menu's id, as returned by menus_list_menus, menus_get_menu, or menus_create_menu.",
73
73
  };
74
+ /** HTML mode (2026-10-08, `menu-html.ts`): the same two fields on create and update. */
75
+ const MENU_MODE_SCHEMA = {
76
+ type: "string",
77
+ enum: ["items", "html"],
78
+ description: "What the site renders for this menu: 'items' (the item tree, the default) or 'html' (the menu's own 'html'). Switching keeps the other mode's data. Omit to leave unchanged.",
79
+ };
80
+ const MENU_HTML_SCHEMA = {
81
+ type: "string",
82
+ maxLength: 200000,
83
+ description: "The menu's own HTML for 'html' mode — any markup and classes, e.g. '<ul class=\"nav\"><li><a href=\"/\">Home</a></li></ul>'. " +
84
+ "Rendered inside the theme element that holds the menu's marker. Needs raw-HTML permission (admins/owners). Omit to leave unchanged.",
85
+ };
74
86
  /**
75
87
  * A link target, published as a `oneOf` over the 4 v1 kinds `menu-service.ts`'s `validateTarget`
76
88
  * accepts (`entryRef`/`termRef`/`url`/`route`) — `dynamicQuery`/`content` are named-but-rejected
@@ -208,6 +220,8 @@ export const menusAgentToolCatalog = [
208
220
  title: { type: "string", minLength: 1, description: "Human-readable menu name." },
209
221
  slug: { type: "string", pattern: "^[a-z0-9-]+$", description: "Stable machine handle. Lowercase letters, digits, and hyphens only." },
210
222
  items: { ...ITEMS_TREE_SCHEMA, description: "Optional initial item tree. Defaults to an empty menu if omitted." },
223
+ mode: MENU_MODE_SCHEMA,
224
+ html: MENU_HTML_SCHEMA,
211
225
  },
212
226
  },
213
227
  },
@@ -215,19 +229,22 @@ export const menusAgentToolCatalog = [
215
229
  name: "menus_update_menu_tree",
216
230
  description: "Replaces an existing menu's whole item tree (and optionally its title/slug) in one version-guarded write. " +
217
231
  "Always a whole-tree replace — there is no per-item patch operation, so the submitted tree must include every " +
218
- "item the menu keeps, each with its fields copied unchanged from the read. To delete a menu, use trash_item with entityType 'menu' (it moves to the Trash and can be restored).",
232
+ "item the menu keeps, each with its fields copied unchanged from the read. For a hand-written HTML menu, send " +
233
+ "'html' (and 'mode': 'html') and omit 'items' to keep the stored tree. To delete a menu, use trash_item with entityType 'menu' (it moves to the Trash and can be restored).",
219
234
  sideEffects: "mutates-durable-state",
220
235
  authorization: { permission: "admin.menus.update" },
221
236
  inputSchema: {
222
237
  type: "object",
223
238
  additionalProperties: false,
224
- required: ["menuId", "expectedVersion", "items"],
239
+ required: ["menuId", "expectedVersion"],
225
240
  properties: {
226
241
  menuId: MENU_ID_SCHEMA,
227
242
  expectedVersion: { type: "integer", description: "The menu's current 'version', as last returned by a read or write on it — the optimistic-concurrency guard. A stale value is rejected with a conflict naming the current version." },
228
243
  title: { type: "string", description: "New title. Omit to leave unchanged." },
229
244
  slug: { type: "string", pattern: "^[a-z0-9-]+$", description: "New slug. Omit to leave unchanged. Must not collide with another menu's slug." },
230
- items: ITEMS_TREE_SCHEMA,
245
+ items: { ...ITEMS_TREE_SCHEMA, description: "The full replacement item tree. Omit to keep the stored tree (e.g. an HTML-only edit); send at least one of items, html or mode." },
246
+ mode: MENU_MODE_SCHEMA,
247
+ html: MENU_HTML_SCHEMA,
231
248
  },
232
249
  },
233
250
  },
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Dropdown navigation (`"variant":"dropdown"` menus), following the APG disclosure-navigation
3
+ * pattern: a branch item carries a server-rendered `<button class="jini-menu-toggle" aria-expanded
4
+ * aria-controls>` pointing at its child `<ul id>`, and the root `<ul>` carries `data-jini-dropdown`.
5
+ * No `role="menu"` and no `aria-haspopup` — those make screen readers announce a menu and expect
6
+ * menubar keys, which site nav should not imitate.
7
+ *
8
+ * The toggles are real buttons in the markup (not injected by JS), so without JS Tab still reaches
9
+ * them and a theme's `:focus-within` CSS opens the panel. {@link MENU_DROPDOWN_SCRIPT} adds
10
+ * `jini-menu-js` to each root so a theme can switch that fallback off once JS owns the open state
11
+ * (otherwise Escape could not close a panel while focus sits on its toggle).
12
+ */
13
+ /** A branch item's toggle. Split form (default): an empty button labelled "`label` submenu" that sits
14
+ * after the item's link. `asLabel`: the button IS the item's label (a link-less parent), and carries
15
+ * `menu-item-label` so theme scripts that read `.menu-item-label` treat it as an inert parent.
16
+ * @complexity O(n) over `label`. */
17
+ export declare function renderMenuToggle({ controlsId, label }: {
18
+ controlsId: string;
19
+ label: string;
20
+ }, { asLabel }?: {
21
+ asLabel?: boolean;
22
+ }): string;
23
+ /**
24
+ * The inline script behind every `[data-jini-dropdown]` menu ({@link injectMenuDropdownScript} adds it
25
+ * once per page). State lives in `li.jini-menu-open` and the toggle's `aria-expanded`.
26
+ * - click on a toggle opens/closes its item (mouse, touch tap, Enter/Space); any click or focus
27
+ * elsewhere closes every open item that does not contain the target (siblings, click-outside,
28
+ * tabbing out). Closing an item closes its open descendants too.
29
+ * - Escape closes the nearest open item around the focus and returns focus to its toggle.
30
+ * - ArrowDown on a toggle opens it and focuses the submenu's first link/button; ArrowDown/ArrowUp
31
+ * inside a submenu move between sibling items; ArrowLeft/ArrowRight on a top-level item move between
32
+ * top-level items. No wrap at either end. Handled arrows `preventDefault` so the page does not scroll.
33
+ * Hover stays pure theme CSS and never touches `aria-expanded`.
34
+ */
35
+ export declare const MENU_DROPDOWN_SCRIPT = "(function(){\n var roots=document.querySelectorAll('[data-jini-dropdown]');\n for(var i=0;i<roots.length;i++)roots[i].classList.add('jini-menu-js');\n function inMenu(el){return el&&el.closest&&el.closest('[data-jini-dropdown]')?el:null;}\n function toggleOf(li){return li.querySelector(':scope > .jini-menu-toggle');}\n function panelOf(li){var b=toggleOf(li);return b?document.getElementById(b.getAttribute('aria-controls')):null;}\n function first(li){return li?li.querySelector('a[href], button'):null;}\n function setOpen(li,open){\n var items=open?[li]:[li].concat(Array.prototype.slice.call(li.querySelectorAll('li.jini-menu-open')));\n for(var i=0;i<items.length;i++){items[i].classList[open?'add':'remove']('jini-menu-open');var b=toggleOf(items[i]);if(b)b.setAttribute('aria-expanded',open?'true':'false');}\n }\n function closeAll(keep){\n var open=document.querySelectorAll('[data-jini-dropdown] li.jini-menu-open');\n for(var i=0;i<open.length;i++)if(!(keep&&keep.nodeType===1&&open[i].contains(keep)))setOpen(open[i],false);\n }\n document.addEventListener('focusin',function(e){closeAll(e.target);});\n document.addEventListener('click',function(e){\n closeAll(e.target);\n var b=inMenu(e.target)?e.target.closest('.jini-menu-toggle'):null;\n if(b)setOpen(b.parentNode,b.getAttribute('aria-expanded')!=='true');\n });\n document.addEventListener('keydown',function(e){\n var t=inMenu(e.target);if(!t)return;\n if(e.key==='Escape'){var o=t.closest('li.jini-menu-open');if(o){setOpen(o,false);toggleOf(o).focus();}return;}\n var li=t.closest('li'),next=null;if(!li)return;\n var top=li.parentNode.hasAttribute('data-jini-dropdown');\n if(e.key==='ArrowDown'&&t.classList.contains('jini-menu-toggle')){closeAll(li);setOpen(li,true);var p=panelOf(li);next=p?first(p):null;}\n else if(!top&&e.key==='ArrowDown')next=first(li.nextElementSibling);\n else if(!top&&e.key==='ArrowUp')next=first(li.previousElementSibling);\n else if(top&&e.key==='ArrowRight')next=first(li.nextElementSibling);\n else if(top&&e.key==='ArrowLeft')next=first(li.previousElementSibling);\n else return;\n e.preventDefault();\n if(next)next.focus();\n });\n })();";
36
+ /** Adds the one inline dropdown script before `</body>` when the page carries a `data-jini-dropdown`
37
+ * menu and does not already have the script. @complexity O(n). */
38
+ export declare function injectMenuDropdownScript({ html }: {
39
+ html: string;
40
+ }, _optional?: Record<string, never>): string;
41
+ //# sourceMappingURL=dropdown.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Dropdown navigation (`"variant":"dropdown"` menus), following the APG disclosure-navigation
3
+ * pattern: a branch item carries a server-rendered `<button class="jini-menu-toggle" aria-expanded
4
+ * aria-controls>` pointing at its child `<ul id>`, and the root `<ul>` carries `data-jini-dropdown`.
5
+ * No `role="menu"` and no `aria-haspopup` — those make screen readers announce a menu and expect
6
+ * menubar keys, which site nav should not imitate.
7
+ *
8
+ * The toggles are real buttons in the markup (not injected by JS), so without JS Tab still reaches
9
+ * them and a theme's `:focus-within` CSS opens the panel. {@link MENU_DROPDOWN_SCRIPT} adds
10
+ * `jini-menu-js` to each root so a theme can switch that fallback off once JS owns the open state
11
+ * (otherwise Escape could not close a panel while focus sits on its toggle).
12
+ */
13
+ const SCRIPT_MARKER = 'data-jini-menu-dropdown-script';
14
+ function escapeAttribute(value) {
15
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
16
+ }
17
+ /** A branch item's toggle. Split form (default): an empty button labelled "`label` submenu" that sits
18
+ * after the item's link. `asLabel`: the button IS the item's label (a link-less parent), and carries
19
+ * `menu-item-label` so theme scripts that read `.menu-item-label` treat it as an inert parent.
20
+ * @complexity O(n) over `label`. */
21
+ export function renderMenuToggle({ controlsId, label }, { asLabel = false } = {}) {
22
+ const id = escapeAttribute(controlsId);
23
+ const text = escapeAttribute(label);
24
+ return asLabel
25
+ ? `<button type="button" class="menu-item-label jini-menu-toggle" aria-expanded="false" aria-controls="${id}">${text}</button>`
26
+ : `<button type="button" class="jini-menu-toggle" aria-expanded="false" aria-controls="${id}" aria-label="${text} submenu"></button>`;
27
+ }
28
+ /**
29
+ * The inline script behind every `[data-jini-dropdown]` menu ({@link injectMenuDropdownScript} adds it
30
+ * once per page). State lives in `li.jini-menu-open` and the toggle's `aria-expanded`.
31
+ * - click on a toggle opens/closes its item (mouse, touch tap, Enter/Space); any click or focus
32
+ * elsewhere closes every open item that does not contain the target (siblings, click-outside,
33
+ * tabbing out). Closing an item closes its open descendants too.
34
+ * - Escape closes the nearest open item around the focus and returns focus to its toggle.
35
+ * - ArrowDown on a toggle opens it and focuses the submenu's first link/button; ArrowDown/ArrowUp
36
+ * inside a submenu move between sibling items; ArrowLeft/ArrowRight on a top-level item move between
37
+ * top-level items. No wrap at either end. Handled arrows `preventDefault` so the page does not scroll.
38
+ * Hover stays pure theme CSS and never touches `aria-expanded`.
39
+ */
40
+ export const MENU_DROPDOWN_SCRIPT = `(function(){
41
+ var roots=document.querySelectorAll('[data-jini-dropdown]');
42
+ for(var i=0;i<roots.length;i++)roots[i].classList.add('jini-menu-js');
43
+ function inMenu(el){return el&&el.closest&&el.closest('[data-jini-dropdown]')?el:null;}
44
+ function toggleOf(li){return li.querySelector(':scope > .jini-menu-toggle');}
45
+ function panelOf(li){var b=toggleOf(li);return b?document.getElementById(b.getAttribute('aria-controls')):null;}
46
+ function first(li){return li?li.querySelector('a[href], button'):null;}
47
+ function setOpen(li,open){
48
+ var items=open?[li]:[li].concat(Array.prototype.slice.call(li.querySelectorAll('li.jini-menu-open')));
49
+ for(var i=0;i<items.length;i++){items[i].classList[open?'add':'remove']('jini-menu-open');var b=toggleOf(items[i]);if(b)b.setAttribute('aria-expanded',open?'true':'false');}
50
+ }
51
+ function closeAll(keep){
52
+ var open=document.querySelectorAll('[data-jini-dropdown] li.jini-menu-open');
53
+ for(var i=0;i<open.length;i++)if(!(keep&&keep.nodeType===1&&open[i].contains(keep)))setOpen(open[i],false);
54
+ }
55
+ document.addEventListener('focusin',function(e){closeAll(e.target);});
56
+ document.addEventListener('click',function(e){
57
+ closeAll(e.target);
58
+ var b=inMenu(e.target)?e.target.closest('.jini-menu-toggle'):null;
59
+ if(b)setOpen(b.parentNode,b.getAttribute('aria-expanded')!=='true');
60
+ });
61
+ document.addEventListener('keydown',function(e){
62
+ var t=inMenu(e.target);if(!t)return;
63
+ if(e.key==='Escape'){var o=t.closest('li.jini-menu-open');if(o){setOpen(o,false);toggleOf(o).focus();}return;}
64
+ var li=t.closest('li'),next=null;if(!li)return;
65
+ var top=li.parentNode.hasAttribute('data-jini-dropdown');
66
+ if(e.key==='ArrowDown'&&t.classList.contains('jini-menu-toggle')){closeAll(li);setOpen(li,true);var p=panelOf(li);next=p?first(p):null;}
67
+ else if(!top&&e.key==='ArrowDown')next=first(li.nextElementSibling);
68
+ else if(!top&&e.key==='ArrowUp')next=first(li.previousElementSibling);
69
+ else if(top&&e.key==='ArrowRight')next=first(li.nextElementSibling);
70
+ else if(top&&e.key==='ArrowLeft')next=first(li.previousElementSibling);
71
+ else return;
72
+ e.preventDefault();
73
+ if(next)next.focus();
74
+ });
75
+ })();`;
76
+ /** Adds the one inline dropdown script before `</body>` when the page carries a `data-jini-dropdown`
77
+ * menu and does not already have the script. @complexity O(n). */
78
+ export function injectMenuDropdownScript({ html }, _optional = {}) {
79
+ if (!html.includes('data-jini-dropdown') || html.includes(SCRIPT_MARKER))
80
+ return html;
81
+ const script = `<script ${SCRIPT_MARKER}>${MENU_DROPDOWN_SCRIPT}</script>`;
82
+ const bodyClose = html.lastIndexOf('</body>');
83
+ return bodyClose === -1 ? html + script : html.slice(0, bodyClose) + script + html.slice(bodyClose);
84
+ }
85
+ //# sourceMappingURL=dropdown.js.map
@@ -4,7 +4,7 @@
4
4
  * A module's public contract is its `index.ts` — deep imports
5
5
  * from outside this directory should go through here.
6
6
  */
7
- export type { NavTargetKind, ReservedNavTargetKind, NavTarget, NavEntryTarget, NavTermTarget, NavUrlTarget, NavRouteTarget, NavItemAttrs, NavItemNode, NavMenuDoc, NavMenuEntry, MenuStatus, NavLocationKey, NavLocationBindingRow, NavLocationDescriptor, ResolvedNavItem, ResolvedNav, } from "./types.js";
7
+ export type { NavTargetKind, ReservedNavTargetKind, NavTarget, NavEntryTarget, NavTermTarget, NavUrlTarget, NavRouteTarget, NavItemAttrs, NavItemNode, NavMenuDoc, NavMenuMode, NavMenuEntry, MenuStatus, NavLocationKey, NavLocationBindingRow, NavLocationDescriptor, ResolvedNavItem, ResolvedNav, } from "./types.js";
8
8
  export { NAV_MENU_CONTENT_TYPE, NAV_FIELD_NAMESPACE, NAV_DOC_TYPE } from "./types.js";
9
9
  export type { NavLocationBindingRepoPort, NavResolveContext, NavTargetResolver, NavMenuReadModel, NavLocationRegistry, } from "./ports.js";
10
10
  export type { NavigationPermission, CreateMenuInput, UpdateMenuInput, AssignLocationInput, UnassignLocationInput, DeleteMenuInput, NavigationEventName, NavMenuChangedPayload, NavLocationChangedPayload, NavigationHookName, NavigationAiTool, NavWhereUsedResult, } from "./contracts.js";
@@ -26,8 +26,12 @@ export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, menuVersionConflictEr
26
26
  * `check:boundaries` blocks either host-side call site from doing so.
27
27
  */
28
28
  export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, isAllowedHref, ALLOWED_HREF_SHAPES_DESCRIPTION, MenuNotFoundError, MenuValidationError, MenuConflictError, MenuVersionConflictError, MenuLocationBoundError, DEFAULT_MAX_TREE_DEPTH, DEFAULT_MAX_ITEM_COUNT, type TreeValidationLimits, type CreateMenuDeps, type CreateMenuServiceInput, type CreateMenuRequired, type CreateMenuOptional, type UpdateMenuTreeDeps, type UpdateMenuTreeServiceInput, type UpdateMenuTreeRequired, type UpdateMenuTreeOptional, type AssignLocationDeps, type AssignLocationServiceInput, type AssignLocationRequired, type AssignLocationOptional, type DeleteMenuDeps, type DeleteMenuServiceInput, type DeleteMenuRequired, type DeleteMenuOptional, } from "./menu-service.js";
29
+ /** HTML mode (2026-10-08): validation/normalization for a menu's author-written markup. */
30
+ export { MAX_MENU_HTML_LENGTH, isMenuHtmlAuthoring, menuHtmlInputProblem, mergeMenuHtml, type MenuHtmlAuthoring, } from "./menu-html.js";
29
31
  export { resolveForLocation, resolveMenuDoc, type ResolveTargetHrefFn, type ResolvedTargetHref, type ResolveForLocationDeps, type ResolveForLocationServiceInput, type ResolveForLocationRequired, type ResolveForLocationOptional, } from "./resolver.js";
30
32
  export { rebuildNavLocationBindings, type RebuildNavLocationBindingsDeps, type RebuildNavLocationBindingsResult, } from "./reconcile.js";
33
+ /** Dropdown menus (2026-10-11): toggle markup + the inline keyboard/touch script (see `dropdown.ts`). */
34
+ export { renderMenuToggle, injectMenuDropdownScript, MENU_DROPDOWN_SCRIPT } from "./dropdown.js";
31
35
  /** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
32
36
  export { menusAgentToolCatalog, } from "./agent-tools.js";
33
37
  /**
@@ -17,8 +17,12 @@ export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, menuVersionConflictEr
17
17
  * `check:boundaries` blocks either host-side call site from doing so.
18
18
  */
19
19
  export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, isAllowedHref, ALLOWED_HREF_SHAPES_DESCRIPTION, MenuNotFoundError, MenuValidationError, MenuConflictError, MenuVersionConflictError, MenuLocationBoundError, DEFAULT_MAX_TREE_DEPTH, DEFAULT_MAX_ITEM_COUNT, } from "./menu-service.js";
20
+ /** HTML mode (2026-10-08): validation/normalization for a menu's author-written markup. */
21
+ export { MAX_MENU_HTML_LENGTH, isMenuHtmlAuthoring, menuHtmlInputProblem, mergeMenuHtml, } from "./menu-html.js";
20
22
  export { resolveForLocation, resolveMenuDoc, } from "./resolver.js";
21
23
  export { rebuildNavLocationBindings, } from "./reconcile.js";
24
+ /** Dropdown menus (2026-10-11): toggle markup + the inline keyboard/touch script (see `dropdown.ts`). */
25
+ export { renderMenuToggle, injectMenuDropdownScript, MENU_DROPDOWN_SCRIPT } from "./dropdown.js";
22
26
  /** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
23
27
  export { menusAgentToolCatalog, } from "./agent-tools.js";
24
28
  /**
@@ -0,0 +1,31 @@
1
+ import type { NavMenuDoc, NavMenuMode } from "./types.js";
2
+ /** Same ceiling HTML-mode forms use. */
3
+ export declare const MAX_MENU_HTML_LENGTH = 200000;
4
+ /** The optional HTML-mode fields a create/update write may carry. */
5
+ export interface MenuHtmlAuthoring {
6
+ mode?: NavMenuMode | undefined;
7
+ html?: string | undefined;
8
+ }
9
+ /**
10
+ * Whether a write authors raw HTML, and so needs the host's raw-HTML permission. Switching back to
11
+ * items without sending markup does not author anything (forms apply the same rule to "builder").
12
+ * @complexity O(1).
13
+ */
14
+ export declare function isMenuHtmlAuthoring({ mode, html }: MenuHtmlAuthoring, _optional?: Record<string, never>): boolean;
15
+ /**
16
+ * Why a write's `mode`/`html` is invalid, or `null` when it is fine. A message rather than a throw so
17
+ * `menu-service.ts` raises its own `MenuValidationError` without this file importing it back.
18
+ * @complexity O(1).
19
+ */
20
+ export declare function menuHtmlInputProblem({ mode, html }: MenuHtmlAuthoring, _optional?: Record<string, never>): string | null;
21
+ /**
22
+ * The doc's `mode`/`html` after a valid write: submitted values win, omitted ones keep what is
23
+ * stored, and a menu that never used HTML mode gets neither key, so its stored doc (and publish
24
+ * hash) is unchanged. Call {@link menuHtmlInputProblem} first.
25
+ * @complexity O(h) in the submitted html length (one parse + serialize).
26
+ */
27
+ export declare function mergeMenuHtml({ input, existing }: {
28
+ input: MenuHtmlAuthoring;
29
+ existing?: Pick<NavMenuDoc, "mode" | "html"> | undefined;
30
+ }, _optional?: Record<string, never>): Pick<NavMenuDoc, "mode" | "html">;
31
+ //# sourceMappingURL=menu-html.d.ts.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file HTML mode for a menu (2026-10-08): the author writes the menu's markup by hand instead of
3
+ * building an item tree, so it can be styled however they want.
4
+ *
5
+ * Same trust model as HTML-mode forms (`forms/html/html-authoring.ts`) and HTML Pages: the markup is
6
+ * admin/owner-trusted and NOT sanitized. Hosts gate it behind their raw-HTML permission (Tovu binds
7
+ * `pages.edit_html`, the one forms use); this module only validates and normalizes. parse5 balances
8
+ * the fragment so a stray `</nav>` or an unclosed `<div>` cannot close or swallow the theme element
9
+ * the menu renders inside.
10
+ *
11
+ * Both modes' data live side by side in the doc: switching mode never deletes the other one, so an
12
+ * author can flip back and find their items (or their HTML) still there.
13
+ */
14
+ import { parseFragment, serialize } from "parse5";
15
+ /** Same ceiling HTML-mode forms use. */
16
+ export const MAX_MENU_HTML_LENGTH = 200_000;
17
+ /**
18
+ * Whether a write authors raw HTML, and so needs the host's raw-HTML permission. Switching back to
19
+ * items without sending markup does not author anything (forms apply the same rule to "builder").
20
+ * @complexity O(1).
21
+ */
22
+ export function isMenuHtmlAuthoring({ mode, html }, _optional = {}) {
23
+ return html !== undefined || mode === "html";
24
+ }
25
+ /** Balance the fragment; scripts and authored styling stay as written (trusted markup). */
26
+ function normalizeMenuHtml(html) {
27
+ return serialize(parseFragment(html));
28
+ }
29
+ /**
30
+ * Why a write's `mode`/`html` is invalid, or `null` when it is fine. A message rather than a throw so
31
+ * `menu-service.ts` raises its own `MenuValidationError` without this file importing it back.
32
+ * @complexity O(1).
33
+ */
34
+ export function menuHtmlInputProblem({ mode, html }, _optional = {}) {
35
+ if (mode !== undefined && mode !== "items" && mode !== "html")
36
+ return "mode must be 'items' or 'html'";
37
+ if (html !== undefined && (typeof html !== "string" || html.length > MAX_MENU_HTML_LENGTH)) {
38
+ return `html must be a string of at most ${MAX_MENU_HTML_LENGTH} characters`;
39
+ }
40
+ return null;
41
+ }
42
+ /**
43
+ * The doc's `mode`/`html` after a valid write: submitted values win, omitted ones keep what is
44
+ * stored, and a menu that never used HTML mode gets neither key, so its stored doc (and publish
45
+ * hash) is unchanged. Call {@link menuHtmlInputProblem} first.
46
+ * @complexity O(h) in the submitted html length (one parse + serialize).
47
+ */
48
+ export function mergeMenuHtml({ input, existing }, _optional = {}) {
49
+ const mode = input.mode ?? existing?.mode;
50
+ const html = input.html !== undefined ? normalizeMenuHtml(input.html) : existing?.html;
51
+ return { ...(mode !== undefined ? { mode } : {}), ...(html !== undefined ? { html } : {}) };
52
+ }
53
+ //# sourceMappingURL=menu-html.js.map
@@ -2,7 +2,7 @@ import type { Clock, IdGenerator, UUID } from "@jini-ai/core/primitives";
2
2
  import type { OutboxPort } from "../core/ports.js";
3
3
  import type { MenuRepoPort } from "./repo.memory.js";
4
4
  import type { NavLocationBindingRepoPort } from "./ports.js";
5
- import { type NavItemNode, type NavLocationBindingRow, type NavLocationKey, type NavMenuEntry } from "./types.js";
5
+ import { type NavItemNode, type NavLocationBindingRow, type NavLocationKey, type NavMenuEntry, type NavMenuMode } from "./types.js";
6
6
  export declare class MenuNotFoundError extends Error {
7
7
  constructor({ message }: {
8
8
  message: string;
@@ -147,6 +147,9 @@ export interface CreateMenuServiceInput {
147
147
  slug: string;
148
148
  /** Optional initial tree; defaults to an empty menu. Items must carry ids. */
149
149
  items?: readonly NavItemNode[] | undefined;
150
+ /** HTML mode (`menu-html.ts`). The host gates raw HTML behind its own permission first. */
151
+ mode?: NavMenuMode | undefined;
152
+ html?: string | undefined;
150
153
  }
151
154
  export interface CreateMenuRequired {
152
155
  deps: CreateMenuDeps;
@@ -188,8 +191,11 @@ export interface UpdateMenuTreeServiceInput {
188
191
  expectedVersion: number;
189
192
  title?: string | undefined;
190
193
  slug?: string | undefined;
191
- /** The full replacement tree (whole-tree edit). */
192
- items: readonly NavItemNode[];
194
+ /** The full replacement tree (whole-tree edit). Omitted keeps the stored tree (an HTML-only edit). */
195
+ items?: readonly NavItemNode[] | undefined;
196
+ /** HTML mode (`menu-html.ts`); omitted keeps the stored value. The host gates raw HTML first. */
197
+ mode?: NavMenuMode | undefined;
198
+ html?: string | undefined;
193
199
  }
194
200
  export interface UpdateMenuTreeRequired {
195
201
  deps: UpdateMenuTreeDeps;
@@ -24,6 +24,7 @@ import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
24
24
  * through the injected repo ports (`deps`).
25
25
  */
26
26
  import { assertEntityLive } from "../core/entity-liveness.js";
27
+ import { menuHtmlInputProblem, mergeMenuHtml } from "./menu-html.js";
27
28
  import { NAV_DOC_TYPE, } from "./types.js";
28
29
  // ---------------------------------------------------------------------------
29
30
  // Outbox event publication — each mutating function below
@@ -281,6 +282,13 @@ function assertValidTitleAndSlug(title, slug) {
281
282
  throw new MenuValidationError({ message: "slug must use lowercase letters, numbers, and dashes" });
282
283
  }
283
284
  }
285
+ /** Validated, merged HTML-mode doc fields — `menu-html.ts`'s problem check raised as this file's own error. */
286
+ function menuHtmlFields({ input, existing }) {
287
+ const problem = menuHtmlInputProblem(input);
288
+ if (problem)
289
+ throw new MenuValidationError({ message: problem });
290
+ return mergeMenuHtml({ input, existing });
291
+ }
284
292
  /**
285
293
  * Creates a new `menu` entry in `published` status with no location
286
294
  * assignments. Rejects a duplicate slug within the workspace.
@@ -305,13 +313,14 @@ export async function createMenu(required, optional = {}) {
305
313
  if (duplicate)
306
314
  throw new MenuConflictError({ message: `slug '${slug}' already exists` });
307
315
  const items = validateAndCloneTree({ items: input.items ?? [] }, optional.limits);
316
+ const htmlFields = menuHtmlFields({ input });
308
317
  const menu = {
309
318
  id: deps.idGen.newId(),
310
319
  workspaceId: input.workspaceId,
311
320
  slug,
312
321
  title,
313
322
  status: "published",
314
- doc: { type: NAV_DOC_TYPE, version: 1, items },
323
+ doc: { type: NAV_DOC_TYPE, version: 1, items, ...htmlFields },
315
324
  locations: [],
316
325
  updatedAt: kernelNowIso({ clock: deps.clock }),
317
326
  version: 1,
@@ -407,15 +416,18 @@ export async function updateMenuTree(required, optional = {}) {
407
416
  throw new MenuConflictError({ message: `slug '${slug}' already exists` });
408
417
  }
409
418
  }
410
- const items = carryForwardEntryHints({
419
+ const items = input.items === undefined ? existing.doc.items : carryForwardEntryHints({
411
420
  previous: existing.doc.items,
412
421
  next: validateAndCloneTree({ items: input.items }, optional.limits),
413
422
  });
423
+ // Every writer (admin, agent tool, publish repoint, reverters) comes through here, so the stored
424
+ // HTML-mode fields are carried forward unless this write sets them — an items-only save never drops them.
425
+ const htmlFields = menuHtmlFields({ input, existing: existing.doc });
414
426
  const menu = {
415
427
  ...existing,
416
428
  title,
417
429
  slug,
418
- doc: { type: NAV_DOC_TYPE, version: existing.doc.version, items },
430
+ doc: { type: NAV_DOC_TYPE, version: existing.doc.version, items, ...htmlFields },
419
431
  updatedAt: kernelNowIso({ clock: deps.clock }),
420
432
  version: existing.version + 1,
421
433
  };