@jini-ai/cms 0.5.5 → 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 (135) 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/dropdown.d.ts +41 -0
  76. package/dist/navigation/dropdown.js +85 -0
  77. package/dist/navigation/index.d.ts +2 -0
  78. package/dist/navigation/index.js +2 -0
  79. package/dist/redirects/capture.d.ts +2 -0
  80. package/dist/redirects/capture.js +4 -0
  81. package/dist/redirects/redirects.d.ts +8 -0
  82. package/dist/redirects/redirects.js +14 -0
  83. package/dist/routing/index.d.ts +5 -0
  84. package/dist/routing/index.js +2 -0
  85. package/dist/routing/ports.d.ts +51 -0
  86. package/dist/routing/ports.js +2 -0
  87. package/dist/routing/routing.d.ts +138 -0
  88. package/dist/routing/routing.js +443 -0
  89. package/dist/routing/types.d.ts +164 -0
  90. package/dist/routing/types.js +2 -0
  91. package/dist/seo/media.d.ts +23 -0
  92. package/dist/seo/media.js +30 -5
  93. package/dist/seo/page-head.js +8 -1
  94. package/dist/seo/seo.d.ts +2 -0
  95. package/dist/seo/seo.js +11 -6
  96. package/dist/seo/types.d.ts +8 -0
  97. package/dist/seo/xml-escape.d.ts +1 -3
  98. package/dist/seo/xml-escape.js +1 -6
  99. package/dist/widgets/concurrency.d.ts +27 -1
  100. package/dist/widgets/concurrency.js +12 -23
  101. package/dist/widgets/config-validation.js +6 -0
  102. package/dist/widgets/gallery-resolver.d.ts +23 -0
  103. package/dist/widgets/gallery-resolver.js +80 -0
  104. package/dist/widgets/html/gallery-render.d.ts +24 -0
  105. package/dist/widgets/html/gallery-render.js +65 -0
  106. package/dist/widgets/html/html-embeds.d.ts +4 -0
  107. package/dist/widgets/html/html-embeds.js +7 -1
  108. package/dist/widgets/html/index.d.ts +3 -0
  109. package/dist/widgets/html/index.js +3 -0
  110. package/dist/widgets/html/server-fragment.d.ts +9 -0
  111. package/dist/widgets/html/server-fragment.js +38 -0
  112. package/dist/widgets/markers/gallery.d.ts +50 -0
  113. package/dist/widgets/markers/gallery.js +164 -0
  114. package/dist/widgets/markers/html-attributes.d.ts +6 -1
  115. package/dist/widgets/markers/html-attributes.js +24 -9
  116. package/dist/widgets/markers/index.d.ts +2 -0
  117. package/dist/widgets/markers/index.js +2 -0
  118. package/dist/widgets/markers/marker.d.ts +4 -0
  119. package/dist/widgets/markers/marker.js +6 -0
  120. package/dist/widgets/markers/media-reference.d.ts +11 -0
  121. package/dist/widgets/markers/media-reference.js +11 -0
  122. package/dist/widgets/page-embed-types.d.ts +1 -1
  123. package/dist/widgets/page-embed-types.js +1 -1
  124. package/dist/widgets/registry.d.ts +144 -10
  125. package/dist/widgets/registry.js +40 -0
  126. package/dist/widgets/resolver-service.d.ts +10 -0
  127. package/dist/widgets/resolver-service.js +73 -29
  128. package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
  129. package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
  130. package/dist/widgets/resolvers/gallery.d.ts +12 -0
  131. package/dist/widgets/resolvers/gallery.js +33 -0
  132. package/dist/widgets/resolvers/index.d.ts +1 -0
  133. package/dist/widgets/resolvers/index.js +1 -0
  134. package/dist/widgets/types.d.ts +3 -1
  135. package/package.json +51 -6
@@ -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
  } };
@@ -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
@@ -30,6 +30,8 @@ export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndClon
30
30
  export { MAX_MENU_HTML_LENGTH, isMenuHtmlAuthoring, menuHtmlInputProblem, mergeMenuHtml, type MenuHtmlAuthoring, } from "./menu-html.js";
31
31
  export { resolveForLocation, resolveMenuDoc, type ResolveTargetHrefFn, type ResolvedTargetHref, type ResolveForLocationDeps, type ResolveForLocationServiceInput, type ResolveForLocationRequired, type ResolveForLocationOptional, } from "./resolver.js";
32
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";
33
35
  /** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
34
36
  export { menusAgentToolCatalog, } from "./agent-tools.js";
35
37
  /**
@@ -21,6 +21,8 @@ export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndClon
21
21
  export { MAX_MENU_HTML_LENGTH, isMenuHtmlAuthoring, menuHtmlInputProblem, mergeMenuHtml, } from "./menu-html.js";
22
22
  export { resolveForLocation, resolveMenuDoc, } from "./resolver.js";
23
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";
24
26
  /** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
25
27
  export { menusAgentToolCatalog, } from "./agent-tools.js";
26
28
  /**
@@ -55,6 +55,8 @@ export interface RedirectSlugChangeCaptureReadDeps {
55
55
  }, optional?: Record<string, never>): Promise<RedirectRecord | null>;
56
56
  }
57
57
  export interface RedirectSlugChangeCaptureDeps {
58
+ /** Optional for existing hosts; route-owning hosts supply their reserved segment policy. */
59
+ reservedSegments?: ReadonlySet<string>;
58
60
  repo: RedirectSlugChangeCaptureReadDeps;
59
61
  db: RedirectDbHandle;
60
62
  clock: ClockPort;
@@ -1,4 +1,5 @@
1
1
  import { nowIso as clockNowIso } from "@jini-ai/core/primitives";
2
+ import { assertRedirectSourceAllowed } from "./redirects.js";
2
3
  import { insertRedirectAndRevision } from "./ports.internal.js";
3
4
  /** Default priority/statusCode/override for an auto-captured rule (behavior.spec.md §3 analog). */
4
5
  const AUTO_CAPTURE_STATUS_CODE = 301;
@@ -21,6 +22,9 @@ export class RedirectSlugChangeCapture {
21
22
  * @complexity O(1) — one read (idempotency check) + two writes.
22
23
  */
23
24
  async onSlugChange(input, _optional = {}) {
25
+ if (this.deps.reservedSegments) {
26
+ assertRedirectSourceAllowed({ fromPattern: input.oldPath, reservedSegments: this.deps.reservedSegments });
27
+ }
24
28
  const existing = await this.deps.repo.lookupExact({
25
29
  workspaceId: input.workspaceId,
26
30
  path: input.oldPath,
@@ -100,6 +100,14 @@ export interface RedirectsWriteDeps {
100
100
  idGen: IdGeneratorPort;
101
101
  outbox: OutboxPort;
102
102
  }
103
+ /** Refuse source patterns whose decoded first segment belongs to a host route.
104
+ * Uses the same decoding and case folding as the target gate; wildcard tails remain patterns.
105
+ * @throws RedirectValidationError before any write. @complexity O(n) in the pattern length.
106
+ */
107
+ export declare function assertRedirectSourceAllowed({ fromPattern, reservedSegments }: {
108
+ fromPattern: string;
109
+ reservedSegments: ReadonlySet<string>;
110
+ }, _optional?: Record<string, never>): void;
103
111
  /**
104
112
  * The site-relative half of the write gate: routing's structural + reserved-path verdict, then the
105
113
  * redirect oracle's own raw-character rule. The read path hands a relative location to that oracle
@@ -144,6 +144,16 @@ function siteRelativeRefusalReason(check) {
144
144
  return "it contains, or decodes to, a backslash or a control character";
145
145
  }
146
146
  }
147
+ /** Refuse source patterns whose decoded first segment belongs to a host route.
148
+ * Uses the same decoding and case folding as the target gate; wildcard tails remain patterns.
149
+ * @throws RedirectValidationError before any write. @complexity O(n) in the pattern length.
150
+ */
151
+ export function assertRedirectSourceAllowed({ fromPattern, reservedSegments }, _optional = {}) {
152
+ const verdict = checkSiteRelativeTarget({ raw: fromPattern, reservedSegments }, {});
153
+ if (verdict.kind === "reserved") {
154
+ throw new RedirectValidationError(`fromPattern '${fromPattern}' is reserved for the '${verdict.surface}' surface`);
155
+ }
156
+ }
147
157
  /**
148
158
  * The site-relative half of the write gate: routing's structural + reserved-path verdict, then the
149
159
  * redirect oracle's own raw-character rule. The read path hands a relative location to that oracle
@@ -257,6 +267,7 @@ export async function checkRedirectFieldsWouldWrite({ deps, fields }, { excludeI
257
267
  validateToTargetLength(fields.toTarget);
258
268
  validateStatusCode(fields.statusCode);
259
269
  validatePriority(fields.priority);
270
+ assertRedirectSourceAllowed({ fromPattern: fields.fromPattern, reservedSegments: deps.reservedSegments });
260
271
  await assertTargetAllowed(deps.originRegistry, fields.workspaceId, fields.toTarget, deps.reservedSegments);
261
272
  const finalTarget = await resolveCollapsedTarget(deps.repo, fields.workspaceId, fields.fromPattern, fields.toTarget);
262
273
  if (finalTarget !== fields.toTarget) {
@@ -300,6 +311,7 @@ export async function createRedirect(required, _optional = {}) {
300
311
  validateStatusCode(input.statusCode);
301
312
  const priority = input.priority ?? DEFAULT_PRIORITY;
302
313
  validatePriority(priority);
314
+ assertRedirectSourceAllowed({ fromPattern: input.fromPattern, reservedSegments: deps.reservedSegments });
303
315
  await assertTargetAllowed(deps.originRegistry, input.workspaceId, input.toTarget, deps.reservedSegments);
304
316
  const finalTarget = await resolveCollapsedTarget(deps.repo, input.workspaceId, input.fromPattern, input.toTarget);
305
317
  if (finalTarget !== input.toTarget) {
@@ -411,6 +423,7 @@ async function validateUpdateFields(deps, workspaceId, fields, existingId) {
411
423
  validateStatusCode(fields.statusCode);
412
424
  validateStatus(fields.status);
413
425
  validatePriority(fields.priority);
426
+ assertRedirectSourceAllowed({ fromPattern: fields.fromPattern, reservedSegments: deps.reservedSegments });
414
427
  await assertTargetAllowed(deps.originRegistry, workspaceId, fields.toTarget, deps.reservedSegments);
415
428
  const finalTarget = await resolveCollapsedTarget(deps.repo, workspaceId, fields.fromPattern, fields.toTarget);
416
429
  if (finalTarget !== fields.toTarget) {
@@ -442,6 +455,7 @@ export async function updateRedirect(required, _optional = {}) {
442
455
  if (!isRestoreOnlyUpdate(input)) {
443
456
  assertEntityLive({ entityType: "redirect", entityId: input.id, state: "trashed" });
444
457
  }
458
+ assertRedirectSourceAllowed({ fromPattern: existing.fromPattern, reservedSegments: deps.reservedSegments });
445
459
  const outcome = await deps.restore({
446
460
  workspaceId: input.workspaceId,
447
461
  id: input.id,
@@ -0,0 +1,5 @@
1
+ export { createRouting, RouteResolutionError } from './routing.js';
2
+ export type { CreateRoutingRequired, CreateRoutingOptional, EmptyOptions, Routing, IsActiveOptional, IsActiveRequired, RegisterNamedRouteInput, RegisterResolvePhaseOptions, ResolveInput, ResolveOptional, ResolveRequired, UrlForOptional, UrlForRequired } from './routing.js';
3
+ export type { RouteResolverDeps, RoutingContentRecord, RoutingContentReadPort, RoutingPorts } from './ports.js';
4
+ export type { EntryRefTarget, RouteRefTarget, RouteResolveContext, RouteResolvePhaseHandler, RouteResolvePhaseName, RouteResolvePhaseOutcome, RouteResolveResult, RouteTarget, RouteTargetKind, RouteUrl, SlugChangeCapture, SlugChangeCaptureInput, TermRefTarget, UrlTarget } from './types.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { createRouting, RouteResolutionError } from './routing.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file Dependency contracts for the Tovu `routing` Tier-2 library (ADR-039 v0).
3
+ *
4
+ * ADR-006 accounting (rule-of-two):
5
+ * - `routing` introduces NO new persistence port. Resolving an `entryRef`
6
+ * target reuses the existing `PostRepoPort` (`src/features/post/post.ts`) —
7
+ * `post` is the only content type implemented in this repo today. Inventing
8
+ * a routing-owned repo for data that already has one would fail rule-of-two.
9
+ * - The named-route registry and the resolve-phase/`SlugChangeCapture`
10
+ * registration slots are core-owned registration mechanisms (ADR-039 §1/§4),
11
+ * NOT open ADR-009 plugin hooks and NOT ports — they are ordinary in-module
12
+ * state with a typed register/get surface, same treatment ADR-029/ADR-032
13
+ * give their single-evaluator resolvers.
14
+ *
15
+ * Architectural role:
16
+ * INTERFACES ONLY — no feature logic lives here.
17
+ */
18
+ /** Minimal content projection; the host visibility owner evaluates publication, scheduling and Trash. */
19
+ export interface RoutingContentRecord {
20
+ readonly slug: string;
21
+ readonly status: string;
22
+ readonly deletedAt?: string | null | undefined;
23
+ readonly publishAt?: string | null | undefined;
24
+ }
25
+ /** A projection of the existing content reader, not a new store or persistence owner. */
26
+ export interface RoutingContentReadPort {
27
+ findById(required: {
28
+ workspaceId: string;
29
+ id: string;
30
+ }, optional?: Record<string, never>): Promise<RoutingContentRecord | null>;
31
+ }
32
+ /** Host-owned policy and operator channel. No implicit clock or console in generic routing. */
33
+ export interface RoutingPorts {
34
+ isLive(required: {
35
+ post: RoutingContentRecord;
36
+ }, optional?: Record<string, never>): boolean;
37
+ reportPhaseError(required: {
38
+ message: string;
39
+ error: unknown;
40
+ }, optional?: Record<string, never>): void;
41
+ }
42
+ /**
43
+ * Dependencies `urlFor`/`isActive` need to resolve `entryRef` targets. Kept as
44
+ * its own named shape (rather than inlining `PostRepoPort` at each call site)
45
+ * so a future second content type's repo can be added here without changing
46
+ * every call site's required-object shape.
47
+ */
48
+ export interface RouteResolverDeps {
49
+ readonly postRepo: RoutingContentReadPort;
50
+ }
51
+ //# sourceMappingURL=ports.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ports.js.map