@jini-ai/cms 0.5.4 → 0.5.5

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 (113) hide show
  1. package/dist/navigation/agent-tools.js +20 -3
  2. package/dist/navigation/index.d.ts +3 -1
  3. package/dist/navigation/index.js +2 -0
  4. package/dist/navigation/menu-html.d.ts +31 -0
  5. package/dist/navigation/menu-html.js +53 -0
  6. package/dist/navigation/menu-service.d.ts +9 -3
  7. package/dist/navigation/menu-service.js +15 -3
  8. package/dist/navigation/tool-registrations.d.ts +5 -0
  9. package/dist/navigation/tool-registrations.js +32 -2
  10. package/dist/navigation/types.d.ts +6 -0
  11. package/dist/widgets/resolvers/menu.d.ts +8 -0
  12. package/dist/widgets/resolvers/menu.js +21 -6
  13. package/package.json +3 -3
  14. package/dist/http/settings/cms-adapter.d.ts +0 -20
  15. package/dist/http/settings/cms-adapter.js +0 -124
  16. package/dist/http/settings/contracts.d.ts +0 -135
  17. package/dist/http/settings/contracts.js +0 -2
  18. package/dist/http/settings/http.d.ts +0 -10
  19. package/dist/http/settings/http.js +0 -211
  20. package/dist/http/settings/index.d.ts +0 -5
  21. package/dist/http/settings/index.js +0 -4
  22. package/dist/http/settings/shared.d.ts +0 -68
  23. package/dist/http/settings/shared.js +0 -54
  24. package/dist/http/settings/stream.d.ts +0 -24
  25. package/dist/http/settings/stream.js +0 -147
  26. package/dist/settings/agent-tools.d.ts +0 -10
  27. package/dist/settings/agent-tools.js +0 -204
  28. package/dist/settings/agent-value-write-tools.d.ts +0 -22
  29. package/dist/settings/agent-value-write-tools.js +0 -154
  30. package/dist/settings/agent-writable-preferences.d.ts +0 -67
  31. package/dist/settings/agent-writable-preferences.js +0 -132
  32. package/dist/settings/agent-write-denylist.d.ts +0 -19
  33. package/dist/settings/agent-write-denylist.js +0 -15
  34. package/dist/settings/change-feed.d.ts +0 -83
  35. package/dist/settings/change-feed.js +0 -61
  36. package/dist/settings/definitions-dispatch.d.ts +0 -56
  37. package/dist/settings/definitions-dispatch.js +0 -86
  38. package/dist/settings/dictionaries/index.d.ts +0 -48
  39. package/dist/settings/dictionaries/index.js +0 -69
  40. package/dist/settings/dictionaries/settings-dialog.ar.d.ts +0 -3
  41. package/dist/settings/dictionaries/settings-dialog.ar.js +0 -27
  42. package/dist/settings/dictionaries/settings-dialog.bn.d.ts +0 -3
  43. package/dist/settings/dictionaries/settings-dialog.bn.js +0 -27
  44. package/dist/settings/dictionaries/settings-dialog.de.d.ts +0 -3
  45. package/dist/settings/dictionaries/settings-dialog.de.js +0 -27
  46. package/dist/settings/dictionaries/settings-dialog.es.d.ts +0 -3
  47. package/dist/settings/dictionaries/settings-dialog.es.js +0 -27
  48. package/dist/settings/dictionaries/settings-dialog.fa.d.ts +0 -3
  49. package/dist/settings/dictionaries/settings-dialog.fa.js +0 -27
  50. package/dist/settings/dictionaries/settings-dialog.fr.d.ts +0 -3
  51. package/dist/settings/dictionaries/settings-dialog.fr.js +0 -27
  52. package/dist/settings/dictionaries/settings-dialog.hi.d.ts +0 -3
  53. package/dist/settings/dictionaries/settings-dialog.hi.js +0 -27
  54. package/dist/settings/dictionaries/settings-dialog.hu.d.ts +0 -3
  55. package/dist/settings/dictionaries/settings-dialog.hu.js +0 -27
  56. package/dist/settings/dictionaries/settings-dialog.id.d.ts +0 -3
  57. package/dist/settings/dictionaries/settings-dialog.id.js +0 -27
  58. package/dist/settings/dictionaries/settings-dialog.it.d.ts +0 -3
  59. package/dist/settings/dictionaries/settings-dialog.it.js +0 -27
  60. package/dist/settings/dictionaries/settings-dialog.ja.d.ts +0 -3
  61. package/dist/settings/dictionaries/settings-dialog.ja.js +0 -27
  62. package/dist/settings/dictionaries/settings-dialog.ko.d.ts +0 -3
  63. package/dist/settings/dictionaries/settings-dialog.ko.js +0 -27
  64. package/dist/settings/dictionaries/settings-dialog.pl.d.ts +0 -3
  65. package/dist/settings/dictionaries/settings-dialog.pl.js +0 -27
  66. package/dist/settings/dictionaries/settings-dialog.pt-BR.d.ts +0 -3
  67. package/dist/settings/dictionaries/settings-dialog.pt-BR.js +0 -27
  68. package/dist/settings/dictionaries/settings-dialog.ru.d.ts +0 -3
  69. package/dist/settings/dictionaries/settings-dialog.ru.js +0 -27
  70. package/dist/settings/dictionaries/settings-dialog.th.d.ts +0 -3
  71. package/dist/settings/dictionaries/settings-dialog.th.js +0 -27
  72. package/dist/settings/dictionaries/settings-dialog.tr.d.ts +0 -3
  73. package/dist/settings/dictionaries/settings-dialog.tr.js +0 -27
  74. package/dist/settings/dictionaries/settings-dialog.uk.d.ts +0 -3
  75. package/dist/settings/dictionaries/settings-dialog.uk.js +0 -27
  76. package/dist/settings/dictionaries/settings-dialog.ur.d.ts +0 -3
  77. package/dist/settings/dictionaries/settings-dialog.ur.js +0 -27
  78. package/dist/settings/dictionaries/settings-dialog.zh-CN.d.ts +0 -3
  79. package/dist/settings/dictionaries/settings-dialog.zh-CN.js +0 -27
  80. package/dist/settings/dictionaries/settings-dialog.zh-TW.d.ts +0 -3
  81. package/dist/settings/dictionaries/settings-dialog.zh-TW.js +0 -27
  82. package/dist/settings/ensure-definitions.d.ts +0 -88
  83. package/dist/settings/ensure-definitions.js +0 -91
  84. package/dist/settings/errors.d.ts +0 -42
  85. package/dist/settings/errors.js +0 -46
  86. package/dist/settings/express/cms-adapter.d.ts +0 -20
  87. package/dist/settings/express/cms-adapter.js +0 -124
  88. package/dist/settings/express/contracts.d.ts +0 -135
  89. package/dist/settings/express/contracts.js +0 -2
  90. package/dist/settings/express/http.d.ts +0 -10
  91. package/dist/settings/express/http.js +0 -211
  92. package/dist/settings/express/shared.d.ts +0 -68
  93. package/dist/settings/express/shared.js +0 -54
  94. package/dist/settings/express/stream.d.ts +0 -24
  95. package/dist/settings/express/stream.js +0 -147
  96. package/dist/settings/ports.d.ts +0 -118
  97. package/dist/settings/ports.js +0 -2
  98. package/dist/settings/principal-lookup.d.ts +0 -9
  99. package/dist/settings/principal-lookup.js +0 -2
  100. package/dist/settings/purge-service.d.ts +0 -54
  101. package/dist/settings/purge-service.js +0 -106
  102. package/dist/settings/repo.memory.d.ts +0 -119
  103. package/dist/settings/repo.memory.js +0 -199
  104. package/dist/settings/settings.d.ts +0 -139
  105. package/dist/settings/settings.js +0 -259
  106. package/dist/settings/tool-registrations.d.ts +0 -51
  107. package/dist/settings/tool-registrations.js +0 -250
  108. package/dist/settings/types.d.ts +0 -125
  109. package/dist/settings/types.js +0 -3
  110. package/dist/settings/ui-tab-definitions.d.ts +0 -41
  111. package/dist/settings/ui-tab-definitions.js +0 -148
  112. package/dist/settings/write-service.d.ts +0 -320
  113. package/dist/settings/write-service.js +0 -683
@@ -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
  },
@@ -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,6 +26,8 @@ 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";
31
33
  /** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
@@ -17,6 +17,8 @@ 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";
22
24
  /** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
@@ -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
  };
@@ -19,6 +19,11 @@ export interface MenusToolDeps {
19
19
  outbox: OutboxPort;
20
20
  menuRepo: MenuRepoPort;
21
21
  navLocationBindingRepo: NavLocationBindingRepoPort;
22
+ /**
23
+ * The host's raw-HTML permission (Tovu: `pages.edit_html`, the one HTML-mode forms use). A write
24
+ * carrying menu HTML is checked against it on top of the menu permission; unset, HTML menus are refused.
25
+ */
26
+ rawHtmlPermission?: string | undefined;
22
27
  }
23
28
  /**
24
29
  * This wiring layer's OWN risk classification, authored from what each handler below actually
@@ -15,6 +15,7 @@ import { buildDomainRegistrations, indexCatalogById, requireInputRecord, require
15
15
  import { requireToolPermission } from "../core/tools/index.js";
16
16
  import { menusAgentToolCatalog } from "./agent-tools.js";
17
17
  import { assignLocation, createMenu, MenuNotFoundError, MenuValidationError, updateMenuTree } from "./menu-service.js";
18
+ import { isMenuHtmlAuthoring } from "./menu-html.js";
18
19
  const CATALOG_BY_ID = indexCatalogById({ catalog: menusAgentToolCatalog });
19
20
  /**
20
21
  * This wiring layer's OWN risk classification, authored from what each handler below actually
@@ -54,10 +55,30 @@ function toMenuToolView(menu) {
54
55
  title: menu.title,
55
56
  status: menu.status,
56
57
  items: JSON.parse(JSON.stringify(menu.doc.items)),
58
+ ...(menu.doc.mode !== undefined ? { mode: menu.doc.mode } : {}),
59
+ ...(menu.doc.html !== undefined ? { html: menu.doc.html } : {}),
57
60
  locations: [...menu.locations],
58
61
  version: menu.version,
59
62
  };
60
63
  }
64
+ /** A write's HTML-mode fields as sent; `menu-service.ts` validates their values. */
65
+ function htmlAuthoring(input) {
66
+ return {
67
+ ...(input.mode !== undefined ? { mode: input.mode } : {}),
68
+ ...(input.html !== undefined ? { html: input.html } : {}),
69
+ };
70
+ }
71
+ /**
72
+ * Raw HTML is the host's trust boundary (HTML Pages, HTML-mode forms), so a write that authors menu
73
+ * HTML also needs that permission. Checked before the write, like forms' `prepareFormAuthoring`.
74
+ */
75
+ async function requireHtmlAuthoringPermission({ routeDeps, principalId, authoring, menuId }) {
76
+ if (!isMenuHtmlAuthoring(authoring))
77
+ return;
78
+ if (!routeDeps.rawHtmlPermission)
79
+ throw new ToolInputError({ message: "HTML menus are not enabled on this site" });
80
+ await requireToolPermission({ authorize: adaptLegacyAuthorize({ authorize: routeDeps.authorize }), workspaceId: routeDeps.workspaceId, principalId, permission: routeDeps.rawHtmlPermission }, { entityType: "menu", ...(menuId ? { entityId: menuId } : {}) });
81
+ }
61
82
  function menusDeps(routeDeps) {
62
83
  return { repo: routeDeps.menuRepo, clock: routeDeps.clock, idGen: routeDeps.idGen, outbox: routeDeps.outbox };
63
84
  }
@@ -82,6 +103,8 @@ export function buildMenusRegistrations(routeDeps) {
82
103
  if (input.items !== undefined && !Array.isArray(input.items)) {
83
104
  throw new ToolInputError({ message: "'items' must be an array of nav items" });
84
105
  }
106
+ const authoring = htmlAuthoring(input);
107
+ await requireHtmlAuthoringPermission({ routeDeps, principalId: ctx.principal.id, authoring });
85
108
  return withSchemaOnRejection({ toolId: "menus_create_menu", catalog: CATALOG_BY_ID, isShapeRejection: ({ error }) => isMenusShapeRejection(error), fn: async () => {
86
109
  const { menu } = await createMenu({
87
110
  deps: menusDeps(routeDeps),
@@ -90,6 +113,7 @@ export function buildMenusRegistrations(routeDeps) {
90
113
  title: requireString({ input: input, key: "title" }),
91
114
  slug: requireString({ input: input, key: "slug" }),
92
115
  items: Array.isArray(input.items) ? input.items : undefined,
116
+ ...authoring,
93
117
  },
94
118
  });
95
119
  return { menu: toMenuToolView(menu) };
@@ -99,8 +123,13 @@ export function buildMenusRegistrations(routeDeps) {
99
123
  const input = requireInputRecord({ input: ctx.input });
100
124
  const menuId = requireString({ input: input, key: "menuId" });
101
125
  await requireToolPermission({ authorize: adaptLegacyAuthorize({ authorize: routeDeps.authorize }), workspaceId: routeDeps.workspaceId, principalId: ctx.principal.id, permission: "admin.menus.update" }, { entityType: "menu", entityId: menuId });
102
- if (!Array.isArray(input.items))
103
- throw new Error("'items' (array) is required");
126
+ const authoring = htmlAuthoring(input);
127
+ if (input.items === undefined && authoring.mode === undefined && authoring.html === undefined) {
128
+ throw new Error("'items' (array), 'html' or 'mode' is required");
129
+ }
130
+ if (input.items !== undefined && !Array.isArray(input.items))
131
+ throw new Error("'items' must be an array of nav items");
132
+ await requireHtmlAuthoringPermission({ routeDeps, principalId: ctx.principal.id, authoring, menuId });
104
133
  return withSchemaOnRejection({ toolId: "menus_update_menu_tree", catalog: CATALOG_BY_ID, isShapeRejection: ({ error }) => isMenusShapeRejection(error), fn: async () => {
105
134
  const { menu } = await updateMenuTree({
106
135
  deps: menusDeps(routeDeps),
@@ -111,6 +140,7 @@ export function buildMenusRegistrations(routeDeps) {
111
140
  title: typeof input.title === "string" ? input.title : undefined,
112
141
  slug: typeof input.slug === "string" ? input.slug : undefined,
113
142
  items: input.items,
143
+ ...authoring,
114
144
  },
115
145
  });
116
146
  return { menu: toMenuToolView(menu) };
@@ -123,7 +123,13 @@ export interface NavMenuDoc {
123
123
  /** Doc-schema version, so the validator can migrate shapes later. */
124
124
  readonly version: number;
125
125
  readonly items: readonly NavItemNode[];
126
+ /** What renders at the menu's marker; absent means `"items"`. See `menu-html.ts`. */
127
+ readonly mode?: NavMenuMode | undefined;
128
+ /** Author-written HTML for `"html"` mode, kept even while the menu renders its items. */
129
+ readonly html?: string | undefined;
126
130
  }
131
+ /** `"items"` renders the item tree; `"html"` renders the author's own markup (`menu-html.ts`). */
132
+ export type NavMenuMode = "items" | "html";
127
133
  export type MenuStatus = "draft" | "published" | "trash";
128
134
  /**
129
135
  * A typed read model over a `type='menu'` entries row (universal
@@ -38,5 +38,13 @@ export interface MenuResolverDeps {
38
38
  /** Host routing injection; callers without it retain the diagnostic placeholder documented above. */
39
39
  resolveTargetHref?: ResolveTargetHrefFn;
40
40
  }
41
+ /**
42
+ * Resolve menu widgets through navigation's read model: stored HTML or resolved items by doc mode.
43
+ * HTML is trusted and balanced by navigation's save path (`mergeMenuHtml`), like static menus;
44
+ * forwarding it adds no second parser or sanitization policy. Read failures propagate to dispatch.
45
+ * @param deps - Navigation read/resolution ports and host routing/public-filter policy.
46
+ * @returns A resolver producing mode-specific menu IR, or existing typed config/target failures.
47
+ * @complexity One read per instance; item resolution/filtering scales with the selected item trees.
48
+ */
41
49
  export declare function createMenuResolver(deps: MenuResolverDeps, _optional?: Record<string, never>): WidgetResolver;
42
50
  //# sourceMappingURL=menu.d.ts.map
@@ -9,6 +9,14 @@ function toMenuItemProps(items, publicOnly) {
9
9
  }) : items;
10
10
  return visible;
11
11
  }
12
+ /**
13
+ * Resolve menu widgets through navigation's read model: stored HTML or resolved items by doc mode.
14
+ * HTML is trusted and balanced by navigation's save path (`mergeMenuHtml`), like static menus;
15
+ * forwarding it adds no second parser or sanitization policy. Read failures propagate to dispatch.
16
+ * @param deps - Navigation read/resolution ports and host routing/public-filter policy.
17
+ * @returns A resolver producing mode-specific menu IR, or existing typed config/target failures.
18
+ * @complexity One read per instance; item resolution/filtering scales with the selected item trees.
19
+ */
12
20
  export function createMenuResolver(deps, _optional = {}) {
13
21
  const resolveTargetHref = deps.resolveTargetHref ?? DEFAULT_RESOLVE_TARGET_HREF;
14
22
  return {
@@ -25,14 +33,21 @@ export function createMenuResolver(deps, _optional = {}) {
25
33
  results.set(instance.id, { ok: false, reason: "target-disabled" });
26
34
  continue;
27
35
  }
28
- const items = await deps.menus.resolveMenuDoc({
29
- doc: menu.doc,
30
- context: { workspaceId: context.workspaceId },
31
- resolveTargetHref,
32
- });
36
+ let props;
37
+ if (menu.doc.mode === "html") {
38
+ props = { title: menu.title, mode: "html", html: menu.doc.html ?? "" };
39
+ }
40
+ else {
41
+ const items = await deps.menus.resolveMenuDoc({
42
+ doc: menu.doc,
43
+ context: { workspaceId: context.workspaceId },
44
+ resolveTargetHref,
45
+ });
46
+ props = { title: menu.title, items: toMenuItemProps(items, deps.publicOnly === true) };
47
+ }
33
48
  results.set(instance.id, {
34
49
  ok: true,
35
- ir: { componentId: "menu", props: { title: menu.title, items: toMenuItemProps(items, deps.publicOnly === true) } },
50
+ ir: { componentId: "menu", props },
36
51
  dependencyKeys: [menu.id],
37
52
  });
38
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/cms",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Content-model capability for Jini-hosted products: framework-free contracts, ports, and domain services in `/core` and per-domain subpaths; Node-bound adapters in domain-specific subpaths.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -319,9 +319,9 @@
319
319
  "@types/better-sqlite3": "^7.6.13",
320
320
  "@electric-sql/pglite": "0.5.8",
321
321
  "jsdom": "^25.0.1",
322
- "@jini-ai/core": "0.4.2",
323
322
  "@jini-ai/http-kit": "0.4.1",
324
- "@jini-ai/db": "0.3.1"
323
+ "@jini-ai/db": "0.3.1",
324
+ "@jini-ai/core": "0.4.2"
325
325
  },
326
326
  "dependencies": {
327
327
  "@jini-ai/core": "^0.4.2"
@@ -1,20 +0,0 @@
1
- import { type SettingsWriteServiceDeps, type SettingsRepoPort } from "../../settings/index.js";
2
- import type { SettingsService, SettingsPermissions, SettingsChangeFeed } from "./contracts.js";
3
- /** Keep the CMS write chokepoint while translating its permission vocabulary to host policy. */
4
- /** Bind the CMS ledger and write chokepoint without copying their domain logic.
5
- * Effective reads scan definitions and resolve each namespace key; mutations inherit CMS transactions/errors.
6
- * @complexity O(1) setup. Per operation: O(definitions + keys) reads, or O(batch size) writes.
7
- * @example createCmsSettingsService({ deps: cmsPorts, permissions });
8
- */
9
- export declare function createCmsSettingsService(required: {
10
- deps: SettingsWriteServiceDeps;
11
- permissions: SettingsPermissions;
12
- }): SettingsService;
13
- /** Adapt the ledger through CMS tenant/principal disclosure rules, returning names and cursors only.
14
- * @complexity O(1) setup; collect is O(page size), with namespace lookup per visible revision.
15
- * @example createCmsSettingsChangeFeed({ repo });
16
- */
17
- export declare function createCmsSettingsChangeFeed(required: {
18
- repo: SettingsRepoPort;
19
- }): SettingsChangeFeed;
20
- //# sourceMappingURL=cms-adapter.d.ts.map
@@ -1,124 +0,0 @@
1
- import { getEffective, resolveDefinition, set, clear, resetNamespace, registerDefinitions, NON_REGISTER_DEFINITION_OPS, parseNonRegisterDefinitionOp, collectChangedNamespaces, } from "../../settings/index.js";
2
- /** Keep the CMS write chokepoint while translating its permission vocabulary to host policy. */
3
- /** Bind the CMS ledger and write chokepoint without copying their domain logic.
4
- * Effective reads scan definitions and resolve each namespace key; mutations inherit CMS transactions/errors.
5
- * @complexity O(1) setup. Per operation: O(definitions + keys) reads, or O(batch size) writes.
6
- * @example createCmsSettingsService({ deps: cmsPorts, permissions });
7
- */
8
- export function createCmsSettingsService(required) {
9
- const { deps: original, permissions } = required;
10
- const permissionMap = new Map([
11
- ["settings.definitions.manage", permissions.manageDefinitions],
12
- ["settings.global.write", permissions.writeGlobal],
13
- ["settings.workspace.write", permissions.writeWorkspace],
14
- ["settings.user.self.write", permissions.writeUserSelf],
15
- ["settings.user.write", permissions.writeUserOther],
16
- ["settings.reset.global", permissions.reset.global],
17
- ["settings.reset.workspace", permissions.reset.workspace],
18
- ["settings.reset.user", permissions.reset.user],
19
- ]);
20
- const deps = { ...original,
21
- authorize: (input) => {
22
- const permission = permissionMap.get(input.permission);
23
- if (permission === undefined)
24
- return Promise.resolve({ allowed: false, reason: "unknown_settings_permission" });
25
- return original.authorize({ ...input, permission });
26
- },
27
- };
28
- const repo = deps.repo;
29
- const listDefinitions = async ({ workspaceId }) => {
30
- const [platform, workspace] = await Promise.all([
31
- repo.listActiveDefinitions({ workspaceId: null }), repo.listActiveDefinitions({ workspaceId }),
32
- ]);
33
- return [...platform, ...workspace];
34
- };
35
- return {
36
- listDefinitions,
37
- effective: async ({ namespace, workspaceId, principalId }) => {
38
- const defs = await listDefinitions({ workspaceId });
39
- const keys = new Set(defs.filter((d) => d.namespace === namespace).map((d) => d.key));
40
- const data = [];
41
- for (const key of keys) {
42
- const result = await getEffective({ repo }, { namespace, key, scopeContext: { workspaceId, principalId } });
43
- if (result)
44
- data.push({ key, value: result.value, sourceLayer: result.sourceLayer, defVersion: result.defVersion });
45
- }
46
- return data;
47
- },
48
- raw: async ({ namespace, key, workspaceId, principalId }) => {
49
- const definition = await resolveDefinition({ repo }, { namespace, key, workspaceId });
50
- if (!definition)
51
- return null;
52
- const settingId = definition.settingId;
53
- const records = await Promise.all([
54
- repo.getGlobalValue(settingId), repo.getWorkspaceValue({ workspaceId, settingId }),
55
- principalId ? repo.getUserValue({ workspaceId, principalId, settingId }) : Promise.resolve(null),
56
- ]);
57
- const values = records.map((r) => r?.state === "set" ? r.valueJson : null);
58
- return { key: `${namespace}.${key}`, global: values[0], workspace: values[1], user: values[2], default: definition.defaultValue };
59
- },
60
- set: (input) => set({ deps, input }),
61
- clear: (input) => clear({ deps, input }),
62
- reset: async (input) => {
63
- const definitions = await repo.listActiveDefinitions({ workspaceId: input.scope === "global" ? null : (input.workspaceId ?? input.authWorkspaceId ?? null) });
64
- const keys = definitions.filter((d) => d.namespace === input.namespace).map((d) => d.key);
65
- return resetNamespace({ deps, input }, keys);
66
- },
67
- definitions: ({ items, callerPrincipalId, authWorkspaceId }) => applyDefinitions({ deps, items, callerPrincipalId, authWorkspaceId }),
68
- };
69
- }
70
- /** Preserve register batching and ordered non-register writes over existing CMS operation contracts.
71
- * Unknown operations return their name. Earlier non-register writes are not rolled back by later failures.
72
- * @complexity O(item count) time/space plus CMS effects; this batch is not atomic.
73
- */
74
- async function applyDefinitions(required) {
75
- const { deps, items, callerPrincipalId, authWorkspaceId } = required;
76
- const toRegister = [];
77
- const applied = [];
78
- for (const raw of items) {
79
- const item = raw;
80
- const op = String(item.op ?? "register");
81
- const namespace = String(item.namespace ?? "");
82
- const key = String(item.key ?? "");
83
- const ownerKind = item.ownerKind;
84
- const workspaceId = ownerKind === "site" ? authWorkspaceId : null;
85
- const defaultValue = (item.defaultJson ?? null);
86
- if (op === "register") {
87
- toRegister.push({ namespace, key, ownerKind, workspaceId, schema: item.schemaJson,
88
- defaultValue, scopes: Number(item.scopes), secret: Boolean(item.secret ?? false) });
89
- }
90
- else {
91
- const parsedOp = parseNonRegisterDefinitionOp({ op });
92
- if (!parsedOp)
93
- return { unknownOp: op };
94
- const opItem = { namespace, key, ownerKind, workspaceId, defaultJson: defaultValue,
95
- ...(item.newNamespace === undefined ? {} : { newNamespace: item.newNamespace }),
96
- ...(item.newKey === undefined ? {} : { newKey: item.newKey }),
97
- ...(item.schemaJson === undefined ? {} : { schemaJson: item.schemaJson }),
98
- ...(item.coercionJson === undefined ? {} : { coercionJson: item.coercionJson }),
99
- };
100
- await NON_REGISTER_DEFINITION_OPS[parsedOp]({ ctx: { deps, callerPrincipalId, authWorkspaceId }, item: opItem });
101
- }
102
- applied.push({ key: `${namespace}.${key}`, op, status: "applied" });
103
- }
104
- if (toRegister.length)
105
- await registerDefinitions({ deps, input: { definitions: toRegister, callerPrincipalId, authWorkspaceId } });
106
- return { applied };
107
- }
108
- /** Adapt the ledger through CMS tenant/principal disclosure rules, returning names and cursors only.
109
- * @complexity O(1) setup; collect is O(page size), with namespace lookup per visible revision.
110
- * @example createCmsSettingsChangeFeed({ repo });
111
- */
112
- export function createCmsSettingsChangeFeed(required) {
113
- const { repo } = required;
114
- return {
115
- head: () => repo.maxRevisionSeq(),
116
- collect: async ({ sinceSeq, limit, viewer }) => {
117
- const revisions = await repo.listRevisionsSince({ sinceSeq, limit, workspaceId: viewer.workspaceId });
118
- // Resolve afresh each batch: a rename must never leave a stale namespace cached forever.
119
- const batch = await collectChangedNamespaces({ revisions, viewer, resolveNamespace: async ({ settingId }) => (await repo.findDefinitionBySettingId({ settingId }))?.namespace ?? null });
120
- return { ...batch, examinedCount: revisions.length };
121
- },
122
- };
123
- }
124
- //# sourceMappingURL=cms-adapter.js.map