@jini-ai/cms 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.5.8 — 2026-10-13
2
+
3
+ - New `./navigation/node` subpath: re-exports the full `./navigation` API with parse5 menu-HTML normalization bound into `createMenu`, `updateMenuTree`, `mergeMenuHtml` and `buildMenusRegistrations`. Node hosts writing HTML-mode menus should import those from `./navigation/node`.
4
+ - `./navigation` no longer loads the optional peer `parse5`, so it is universal again. Every name is still exported; an HTML-mode write through it needs an injected `normalizeMenuHtml` (`normalizeHtml` for `mergeMenuHtml`) and otherwise fails before saving. Item-only writes are unchanged.
5
+ - `./seo` no longer reaches the media transform module for rendition MIME types; `TransformFormat` and `mimeForTransformFormat` remain exported from the media entries.
6
+
7
+ ## 0.5.7 — 2026-10-12
8
+
9
+ - New render-only leaf subpaths: `./forms/html-render`, `./forms/submission-attempts`, `./widgets/page-embed-types`, `./widgets/html/html-embeds`, `./widgets/html/gallery-render`, `./navigation/types`, `./navigation/dropdown` and `./media/slug` (the media slug rule, still re-exported from `./media`). Existing barrels are unchanged.
10
+
1
11
  ## 0.5.4 — 2026-10-08
2
12
 
3
13
  - Ships `dist/forms/**`: 0.5.3's `files` negation `!forms/**` also stripped `dist/forms`, so `@jini-ai/cms/forms` (and `/html`, `/sql`, `/express`) failed to resolve. The negation is removed: `files` already ships only `dist`, and the nested `forms/` package is never packed.
package/README.md CHANGED
@@ -78,6 +78,13 @@ coordination.
78
78
  `taxonomy`, `workspace`, and `trash`. `settings` is now a compatibility shim, with implementation
79
79
  in `@jini-ai/core/settings`. `./core/tools` re-exports the shared core permission helpers; `./media/import` exports bounded image fetching with injected policies and ports.
80
80
 
81
+ `./navigation` stays universal: submitted menu HTML requires an injected `normalizeMenuHtml`
82
+ dependency (`normalizeHtml` for `mergeMenuHtml`). Item-only writes and retained markup need no
83
+ parser. Node hosts use `./navigation/node`, which re-exports the navigation API and supplies
84
+ parse5 normalization to create/update, merge and tool registration. The existing validation,
85
+ raw-HTML permission checks, OCC and outbox owners remain unchanged; an unconfigured universal
86
+ HTML write fails before saving rather than persisting markup that could escape its theme container.
87
+
81
88
  **Still to move:** `post`, `seo`, `comments`, `redirects`, `widgets`, `newsletter`,
82
89
  `members`. The unimplemented `./widgets` export has been removed.
83
90
  The nested forms package is maintained by its separate owner and is excluded from this parent package.
@@ -0,0 +1,19 @@
1
+ /** Shared rendition format metadata. Media validation and SEO use this leaf without loading
2
+ * either domain's runtime; media/transform-types.ts preserves its existing public exports.
3
+ */
4
+ /**
5
+ * Output re-encode format. Matches `media-service.ts`'s
6
+ * `DEFAULT_ALLOWED_MIME_TYPES` image set minus SVG (never a transform output —
7
+ * SVG has no raster pixels to resize).
8
+ */
9
+ export type TransformFormat = "jpeg" | "png" | "webp" | "gif";
10
+ /** Maps a {@link TransformFormat} to the MIME type served on the rendition response. */
11
+ export declare const MIME_BY_TRANSFORM_FORMAT: Record<TransformFormat, string>;
12
+ /**
13
+ * @complexity O(1).
14
+ * @overallScore 100
15
+ */
16
+ export declare function mimeForTransformFormat(requiredArgs: {
17
+ format: TransformFormat;
18
+ }, optionalArgs?: Record<string, never>): string;
19
+ //# sourceMappingURL=transform-format.d.ts.map
@@ -0,0 +1,19 @@
1
+ /** Shared rendition format metadata. Media validation and SEO use this leaf without loading
2
+ * either domain's runtime; media/transform-types.ts preserves its existing public exports.
3
+ */
4
+ /** Maps a {@link TransformFormat} to the MIME type served on the rendition response. */
5
+ export const MIME_BY_TRANSFORM_FORMAT = {
6
+ jpeg: "image/jpeg",
7
+ png: "image/png",
8
+ webp: "image/webp",
9
+ gif: "image/gif",
10
+ };
11
+ /**
12
+ * @complexity O(1).
13
+ * @overallScore 100
14
+ */
15
+ export function mimeForTransformFormat(requiredArgs, optionalArgs = {}) {
16
+ const { format } = requiredArgs;
17
+ return MIME_BY_TRANSFORM_FORMAT[format];
18
+ }
19
+ //# sourceMappingURL=transform-format.js.map
@@ -1,3 +1,4 @@
1
+ export { isValidMediaSlugFormat } from "./slug.js";
1
2
  import type { Clock, IdGenerator, UUID } from "@jini-ai/core/primitives";
2
3
  import { MediaConflictError, MediaNotFoundError, MediaSourceImmutableError, MediaStillReferencedError, MediaValidationError, type MediaRecord, type MediaSource } from "./types.js";
3
4
  import type { AssetBlobRepoPort, AssetRenditionRepoPort, BlobStorePort, MediaRepoPort } from "./ports.js";
@@ -53,9 +54,6 @@ export declare function resolveWriteOnceSource(required: {
53
54
  existing: MediaSource | undefined;
54
55
  requestedSha256: string;
55
56
  }, _optional?: Record<string, never>): MediaSource;
56
- export declare function isValidMediaSlugFormat(requiredArgs: {
57
- slug: string;
58
- }, optionalArgs?: Record<string, never>): boolean;
59
57
  export interface UploadMediaInput {
60
58
  workspaceId: UUID;
61
59
  bytes: Uint8Array;
@@ -1,3 +1,5 @@
1
+ import { isValidMediaSlugFormat } from "./slug.js";
2
+ export { isValidMediaSlugFormat } from "./slug.js";
1
3
  import { createSystemClock } from "@jini-ai/core/primitives";
2
4
  import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
3
5
  /**
@@ -145,28 +147,6 @@ function deriveTitleFromFilename(filename) {
145
147
  const base = filename.trim().replace(/\.[^./\\]+$/, "");
146
148
  return base.trim() || "Untitled";
147
149
  }
148
- /** Shared slug-format rule (2026-09-07) — same shape as `post`'s `SLUG_FORMAT_PATTERN`: lowercase
149
- * letters, digits, and dashes only. Media slugs have no reserved-word list (`post`'s `admin`/`api`
150
- * exclusions exist because a post slug can become a literal URL path segment a route dispatches
151
- * on; a media slug is only ever a lookup key, never a route itself, so that hazard doesn't apply). */
152
- const MEDIA_SLUG_FORMAT_PATTERN = /^[a-z0-9-]+$/;
153
- /**
154
- * Slugs that spell a UUID are refused outright (2026-09-07 fix).
155
- *
156
- * A lowercase UUID is `[a-z0-9-]+` character for character, so {@link MEDIA_SLUG_FORMAT_PATTERN}
157
- * happily accepted one — and {@link findMediaByIdOrSlug} used to resolve SLUG FIRST, so pointing one
158
- * asset's slug at another asset's `id` silently took over every reference already authored against
159
- * that id (every `/m/{id}/…` URL a host's renderer has ever emitted for the victim). That resolution
160
- * order is now id-first, which closes the hijack for rows that already carry such a slug; this rule
161
- * is the write-path half — a slug that can only ever shadow an id is not a slug anyone wants, so it
162
- * is refused where a human can still see why rather than silently shadowed at read time.
163
- *
164
- * Shape-based, not a lookup: it rejects EVERY UUID-shaped value, not only ones that currently
165
- * collide, so a slug cannot become a hijack later when some future upload happens to mint that id.
166
- * Ordinary hex-and-dash slugs (`abc-123-def`, `2026-09-07-launch-clip`) are unaffected — nothing but
167
- * the exact 8-4-4-4-12 hex grouping matches.
168
- */
169
- const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
170
150
  /** Shared slug max-length rule (2026-09-07) — same bound as `post.ts`'s `MAX_SLUG_LENGTH`, reused
171
151
  * rather than inventing a second number. `post.ts` only enforces this on its EXPLICIT/caller-
172
152
  * supplied slug path (`resolveExplicitSlug`, reject-with-error); its own title-derived path is
@@ -177,10 +157,6 @@ const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9
177
157
  * human for a shorter value — silently truncating an internally-derived value is the same
178
158
  * "coerce, don't fail" convention `deriveTitleFromFilename`/`slugifyMediaTitle` already use. */
179
159
  const MEDIA_MAX_SLUG_LENGTH = 120;
180
- export function isValidMediaSlugFormat(requiredArgs, optionalArgs = {}) {
181
- const { slug } = requiredArgs;
182
- return MEDIA_SLUG_FORMAT_PATTERN.test(slug) && !MEDIA_SLUG_UUID_SHAPE_PATTERN.test(slug);
183
- }
184
160
  /** Truncates a slug candidate to `maxLength`, stripping a trailing dash the cut can introduce (so a
185
161
  * truncation never leaves a slug ending mid-word with a dangling "-"), falling back to `"untitled"`
186
162
  * if nothing legible survives (only reachable when `maxLength` itself is tiny, e.g. while reserving
@@ -0,0 +1,8 @@
1
+ /** Return whether a media lookup slug has the accepted shape, without I/O.
2
+ * @complexity O(n) time in the slug length, O(1) space.
3
+ * @example isValidMediaSlugFormat({ slug: "summer-photo" }); // true
4
+ */
5
+ export declare function isValidMediaSlugFormat(requiredArgs: {
6
+ slug: string;
7
+ }, optionalArgs?: Record<string, never>): boolean;
8
+ //# sourceMappingURL=slug.d.ts.map
@@ -0,0 +1,32 @@
1
+ /** @file Pure media slug policy; writes and public URL rendering share the same format and UUID-shadowing rule. */
2
+ /** Shared slug-format rule (2026-09-07) — same shape as `post`'s `SLUG_FORMAT_PATTERN`: lowercase
3
+ * letters, digits, and dashes only. Media slugs have no reserved-word list (`post`'s `admin`/`api`
4
+ * exclusions exist because a post slug can become a literal URL path segment a route dispatches
5
+ * on; a media slug is only ever a lookup key, never a route itself, so that hazard doesn't apply). */
6
+ const MEDIA_SLUG_FORMAT_PATTERN = /^[a-z0-9-]+$/;
7
+ /**
8
+ * Slugs that spell a UUID are refused outright (2026-09-07 fix).
9
+ *
10
+ * A lowercase UUID is `[a-z0-9-]+` character for character, so {@link MEDIA_SLUG_FORMAT_PATTERN}
11
+ * happily accepted one — and {@link findMediaByIdOrSlug} used to resolve SLUG FIRST, so pointing one
12
+ * asset's slug at another asset's `id` silently took over every reference already authored against
13
+ * that id (every `/m/{id}/…` URL a host's renderer has ever emitted for the victim). That resolution
14
+ * order is now id-first, which closes the hijack for rows that already carry such a slug; this rule
15
+ * is the write-path half — a slug that can only ever shadow an id is not a slug anyone wants, so it
16
+ * is refused where a human can still see why rather than silently shadowed at read time.
17
+ *
18
+ * Shape-based, not a lookup: it rejects EVERY UUID-shaped value, not only ones that currently
19
+ * collide, so a slug cannot become a hijack later when some future upload happens to mint that id.
20
+ * Ordinary hex-and-dash slugs (`abc-123-def`, `2026-09-07-launch-clip`) are unaffected — nothing but
21
+ * the exact 8-4-4-4-12 hex grouping matches.
22
+ */
23
+ const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
24
+ /** Return whether a media lookup slug has the accepted shape, without I/O.
25
+ * @complexity O(n) time in the slug length, O(1) space.
26
+ * @example isValidMediaSlugFormat({ slug: "summer-photo" }); // true
27
+ */
28
+ export function isValidMediaSlugFormat(requiredArgs, optionalArgs = {}) {
29
+ const { slug } = requiredArgs;
30
+ return MEDIA_SLUG_FORMAT_PATTERN.test(slug) && !MEDIA_SLUG_UUID_SHAPE_PATTERN.test(slug);
31
+ }
32
+ //# sourceMappingURL=slug.js.map
@@ -22,14 +22,10 @@
22
22
  * permission catalog.
23
23
  */
24
24
  import type { ISODateTime, UUID } from "@jini-ai/core/primitives";
25
+ import { type TransformFormat } from "../core/transform-format.js";
26
+ export { mimeForTransformFormat, type TransformFormat } from "../core/transform-format.js";
25
27
  /** Resize behavior, mirrors `sharp`'s `fit` vocabulary (a deliberately small subset). */
26
28
  export type TransformFit = "cover" | "contain" | "fill" | "inside" | "outside";
27
- /**
28
- * Output re-encode format. Matches `media-service.ts`'s
29
- * `DEFAULT_ALLOWED_MIME_TYPES` image set minus SVG (never a transform output —
30
- * SVG has no raster pixels to resize).
31
- */
32
- export type TransformFormat = "jpeg" | "png" | "webp" | "gif";
33
29
  /**
34
30
  * Declarative transform parameters (named definitions only, never
35
31
  * caller-supplied dimensions). `width`/`height` are optional so a
@@ -66,13 +62,6 @@ export declare class TransformValidationError extends Error {
66
62
  message: string;
67
63
  }, optionalArgs?: ErrorOptions);
68
64
  }
69
- /**
70
- * @complexity O(1).
71
- * @overallScore 100
72
- */
73
- export declare function mimeForTransformFormat(requiredArgs: {
74
- format: TransformFormat;
75
- }, optionalArgs?: Record<string, never>): string;
76
65
  /** Upper bound on a requested resize dimension — an output-side resource-use guard (not the
77
66
  * full pixel-bomb ingest cap, which polices upload input; this polices the transform
78
67
  * definition itself so a registered definition can't ask the image transformer to allocate an
@@ -1,23 +1,10 @@
1
+ import { MIME_BY_TRANSFORM_FORMAT } from "../core/transform-format.js";
2
+ export { mimeForTransformFormat } from "../core/transform-format.js";
1
3
  export class TransformValidationError extends Error {
2
4
  constructor({ message }, optionalArgs = {}) {
3
5
  super(message, optionalArgs);
4
6
  }
5
7
  }
6
- /** Maps a {@link TransformFormat} to the MIME type served on the rendition response. */
7
- const MIME_BY_TRANSFORM_FORMAT = {
8
- jpeg: "image/jpeg",
9
- png: "image/png",
10
- webp: "image/webp",
11
- gif: "image/gif",
12
- };
13
- /**
14
- * @complexity O(1).
15
- * @overallScore 100
16
- */
17
- export function mimeForTransformFormat(requiredArgs, optionalArgs = {}) {
18
- const { format } = requiredArgs;
19
- return MIME_BY_TRANSFORM_FORMAT[format];
20
- }
21
8
  /** Upper bound on a requested resize dimension — an output-side resource-use guard (not the
22
9
  * full pixel-bomb ingest cap, which polices upload input; this polices the transform
23
10
  * definition itself so a registered definition can't ask the image transformer to allocate an
@@ -27,7 +27,7 @@ export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, menuVersionConflictEr
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
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";
30
+ export { MAX_MENU_HTML_LENGTH, isMenuHtmlAuthoring, menuHtmlInputProblem, mergeMenuHtml, type MenuHtmlAuthoring, type MenuHtmlNormalizer, type MenuHtmlOptions, } 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
33
  /** Dropdown menus (2026-10-11): toggle markup + the inline keyboard/touch script (see `dropdown.ts`). */
@@ -1,3 +1,17 @@
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 merges. The injected
8
+ * normalizer balances the fragment so a stray `</nav>` or an unclosed `<div>` cannot close or
9
+ * swallow the theme element the menu renders inside. The Node adapter supplies parse5; the
10
+ * universal entry never loads a parser or assumes a browser DOM.
11
+ *
12
+ * Both modes' data live side by side in the doc: switching mode never deletes the other one, so an
13
+ * author can flip back and find their items (or their HTML) still there.
14
+ */
1
15
  import type { NavMenuDoc, NavMenuMode } from "./types.js";
2
16
  /** Same ceiling HTML-mode forms use. */
3
17
  export declare const MAX_MENU_HTML_LENGTH = 200000;
@@ -12,6 +26,14 @@ export interface MenuHtmlAuthoring {
12
26
  * @complexity O(1).
13
27
  */
14
28
  export declare function isMenuHtmlAuthoring({ mode, html }: MenuHtmlAuthoring, _optional?: Record<string, never>): boolean;
29
+ /** Balance a trusted HTML fragment while preserving scripts and authored styling. */
30
+ export type MenuHtmlNormalizer = (required: {
31
+ html: string;
32
+ }, optional?: Record<string, never>) => string;
33
+ /** A host supplies its environment's fragment parser; omitted is valid for item-only writes. */
34
+ export interface MenuHtmlOptions {
35
+ normalizeHtml?: MenuHtmlNormalizer | undefined;
36
+ }
15
37
  /**
16
38
  * Why a write's `mode`/`html` is invalid, or `null` when it is fine. A message rather than a throw so
17
39
  * `menu-service.ts` raises its own `MenuValidationError` without this file importing it back.
@@ -22,10 +44,12 @@ export declare function menuHtmlInputProblem({ mode, html }: MenuHtmlAuthoring,
22
44
  * The doc's `mode`/`html` after a valid write: submitted values win, omitted ones keep what is
23
45
  * stored, and a menu that never used HTML mode gets neither key, so its stored doc (and publish
24
46
  * hash) is unchanged. Call {@link menuHtmlInputProblem} first.
47
+ * @param optional Host fragment normalizer, required when input.html is supplied.
48
+ * @throws {Error} Submitted markup has no configured normalizer; never saves unbalanced HTML.
25
49
  * @complexity O(h) in the submitted html length (one parse + serialize).
26
50
  */
27
51
  export declare function mergeMenuHtml({ input, existing }: {
28
52
  input: MenuHtmlAuthoring;
29
53
  existing?: Pick<NavMenuDoc, "mode" | "html"> | undefined;
30
- }, _optional?: Record<string, never>): Pick<NavMenuDoc, "mode" | "html">;
54
+ }, optional?: MenuHtmlOptions): Pick<NavMenuDoc, "mode" | "html">;
31
55
  //# sourceMappingURL=menu-html.d.ts.map
@@ -1,17 +1,3 @@
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
1
  /** Same ceiling HTML-mode forms use. */
16
2
  export const MAX_MENU_HTML_LENGTH = 200_000;
17
3
  /**
@@ -22,10 +8,6 @@ export const MAX_MENU_HTML_LENGTH = 200_000;
22
8
  export function isMenuHtmlAuthoring({ mode, html }, _optional = {}) {
23
9
  return html !== undefined || mode === "html";
24
10
  }
25
- /** Balance the fragment; scripts and authored styling stay as written (trusted markup). */
26
- function normalizeMenuHtml(html) {
27
- return serialize(parseFragment(html));
28
- }
29
11
  /**
30
12
  * Why a write's `mode`/`html` is invalid, or `null` when it is fine. A message rather than a throw so
31
13
  * `menu-service.ts` raises its own `MenuValidationError` without this file importing it back.
@@ -43,11 +25,18 @@ export function menuHtmlInputProblem({ mode, html }, _optional = {}) {
43
25
  * The doc's `mode`/`html` after a valid write: submitted values win, omitted ones keep what is
44
26
  * stored, and a menu that never used HTML mode gets neither key, so its stored doc (and publish
45
27
  * hash) is unchanged. Call {@link menuHtmlInputProblem} first.
28
+ * @param optional Host fragment normalizer, required when input.html is supplied.
29
+ * @throws {Error} Submitted markup has no configured normalizer; never saves unbalanced HTML.
46
30
  * @complexity O(h) in the submitted html length (one parse + serialize).
47
31
  */
48
- export function mergeMenuHtml({ input, existing }, _optional = {}) {
32
+ export function mergeMenuHtml({ input, existing }, optional = {}) {
49
33
  const mode = input.mode ?? existing?.mode;
50
- const html = input.html !== undefined ? normalizeMenuHtml(input.html) : existing?.html;
34
+ let html = existing?.html;
35
+ if (input.html !== undefined) {
36
+ if (!optional.normalizeHtml)
37
+ throw new Error("Menu HTML normalization is not configured");
38
+ html = optional.normalizeHtml({ html: input.html });
39
+ }
51
40
  return { ...(mode !== undefined ? { mode } : {}), ...(html !== undefined ? { html } : {}) };
52
41
  }
53
42
  //# sourceMappingURL=menu-html.js.map
@@ -2,6 +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 MenuHtmlNormalizer } from "./menu-html.js";
5
6
  import { type NavItemNode, type NavLocationBindingRow, type NavLocationKey, type NavMenuEntry, type NavMenuMode } from "./types.js";
6
7
  export declare class MenuNotFoundError extends Error {
7
8
  constructor({ message }: {
@@ -135,6 +136,8 @@ export declare function validateAndCloneTree({ items }: {
135
136
  items: readonly NavItemNode[];
136
137
  }, limits?: TreeValidationLimits): NavItemNode[];
137
138
  export interface CreateMenuDeps {
139
+ /** Required for submitted HTML; Node hosts can use the navigation/node adapter. */
140
+ normalizeMenuHtml?: MenuHtmlNormalizer | undefined;
138
141
  repo: MenuRepoPort;
139
142
  clock: Clock;
140
143
  idGen: IdGenerator;
@@ -177,6 +180,8 @@ export declare function createMenu(required: CreateMenuRequired, optional?: Crea
177
180
  menu: NavMenuEntry;
178
181
  }>;
179
182
  export interface UpdateMenuTreeDeps {
183
+ /** Required for submitted HTML; omitted markup retains the already-normalized stored value. */
184
+ normalizeMenuHtml?: MenuHtmlNormalizer | undefined;
180
185
  repo: MenuRepoPort;
181
186
  clock: Clock;
182
187
  /** Not previously present on this deps bag — needed to mint the outbox event's id. */
@@ -283,11 +283,11 @@ function assertValidTitleAndSlug(title, slug) {
283
283
  }
284
284
  }
285
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 }) {
286
+ function menuHtmlFields({ input, existing, normalizeHtml }) {
287
287
  const problem = menuHtmlInputProblem(input);
288
288
  if (problem)
289
289
  throw new MenuValidationError({ message: problem });
290
- return mergeMenuHtml({ input, existing });
290
+ return mergeMenuHtml({ input, existing }, { normalizeHtml });
291
291
  }
292
292
  /**
293
293
  * Creates a new `menu` entry in `published` status with no location
@@ -313,7 +313,7 @@ export async function createMenu(required, optional = {}) {
313
313
  if (duplicate)
314
314
  throw new MenuConflictError({ message: `slug '${slug}' already exists` });
315
315
  const items = validateAndCloneTree({ items: input.items ?? [] }, optional.limits);
316
- const htmlFields = menuHtmlFields({ input });
316
+ const htmlFields = menuHtmlFields({ input, normalizeHtml: deps.normalizeMenuHtml });
317
317
  const menu = {
318
318
  id: deps.idGen.newId(),
319
319
  workspaceId: input.workspaceId,
@@ -422,7 +422,7 @@ export async function updateMenuTree(required, optional = {}) {
422
422
  });
423
423
  // Every writer (admin, agent tool, publish repoint, reverters) comes through here, so the stored
424
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 });
425
+ const htmlFields = menuHtmlFields({ input, existing: existing.doc, normalizeHtml: deps.normalizeMenuHtml });
426
426
  const menu = {
427
427
  ...existing,
428
428
  title,
@@ -0,0 +1,30 @@
1
+ import { mergeMenuHtml as mergeUniversalMenuHtml, type MenuHtmlOptions } from "../menu-html.js";
2
+ import { createMenu as createUniversalMenu, updateMenuTree as updateUniversalMenuTree, type CreateMenuRequired, type CreateMenuOptional, type UpdateMenuTreeRequired, type UpdateMenuTreeOptional } from "../menu-service.js";
3
+ import { buildMenusRegistrations as buildUniversalMenusRegistrations, type MenusToolDeps } from "../tool-registrations.js";
4
+ export * from "../index.js";
5
+ /** Balance the fragment; scripts and authored styling stay as written (trusted markup).
6
+ * @param required Trusted authored markup; the service enforces the length cap before calling.
7
+ * @returns Balanced fragment, without dangling outer closers.
8
+ * @complexity O(h) time and space in submitted HTML length.
9
+ */
10
+ export declare function normalizeMenuHtml({ html }: {
11
+ html: string;
12
+ }, _optional?: Record<string, never>): string;
13
+ /** Supply Node normalization to the universal create owner; validation, errors, writes and
14
+ * outbox publication are unchanged. Caller-provided normalization takes precedence.
15
+ * @complexity Same tree/slug scans as the universal owner, plus O(h) HTML time and space.
16
+ */
17
+ export declare function createMenu(required: CreateMenuRequired, optional?: CreateMenuOptional): ReturnType<typeof createUniversalMenu>;
18
+ /** Supply Node normalization to the universal update owner, retaining its OCC and event contract.
19
+ * @complexity Same tree/slug scans as the universal owner, plus O(h) HTML time and space.
20
+ */
21
+ export declare function updateMenuTree(required: UpdateMenuTreeRequired, optional?: UpdateMenuTreeOptional): ReturnType<typeof updateUniversalMenuTree>;
22
+ /** Preserve the merge API for Node consumers, balancing only submitted HTML.
23
+ * @complexity O(h) time and space in submitted HTML length; omitted HTML is O(1).
24
+ */
25
+ export declare function mergeMenuHtml(required: Parameters<typeof mergeUniversalMenuHtml>[0], optional?: MenuHtmlOptions): ReturnType<typeof mergeUniversalMenuHtml>;
26
+ /** Bind Node normalization once for the existing tool builder; permission checks and saves
27
+ * remain in their universal owners. Construction is O(1) time and space (fixed catalog).
28
+ */
29
+ export declare function buildMenusRegistrations(required: MenusToolDeps, _optional?: Record<string, never>): ReturnType<typeof buildUniversalMenusRegistrations>;
30
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,43 @@
1
+ /** Node navigation adapter: the public barrel stays compatible, while HTML writes receive
2
+ * parse5 normalization. Item services and all persistence/permission policy stay universal.
3
+ * @example import { createMenu } from "@jini-ai/cms/navigation/node";
4
+ */
5
+ import { parseFragment, serialize } from "parse5";
6
+ import { mergeMenuHtml as mergeUniversalMenuHtml } from "../menu-html.js";
7
+ import { createMenu as createUniversalMenu, updateMenuTree as updateUniversalMenuTree, } from "../menu-service.js";
8
+ import { buildMenusRegistrations as buildUniversalMenusRegistrations } from "../tool-registrations.js";
9
+ export * from "../index.js";
10
+ /** Balance the fragment; scripts and authored styling stay as written (trusted markup).
11
+ * @param required Trusted authored markup; the service enforces the length cap before calling.
12
+ * @returns Balanced fragment, without dangling outer closers.
13
+ * @complexity O(h) time and space in submitted HTML length.
14
+ */
15
+ export function normalizeMenuHtml({ html }, _optional = {}) {
16
+ return serialize(parseFragment(html));
17
+ }
18
+ /** Supply Node normalization to the universal create owner; validation, errors, writes and
19
+ * outbox publication are unchanged. Caller-provided normalization takes precedence.
20
+ * @complexity Same tree/slug scans as the universal owner, plus O(h) HTML time and space.
21
+ */
22
+ export function createMenu(required, optional = {}) {
23
+ return createUniversalMenu({ ...required, deps: { ...required.deps, normalizeMenuHtml: required.deps.normalizeMenuHtml ?? normalizeMenuHtml } }, optional);
24
+ }
25
+ /** Supply Node normalization to the universal update owner, retaining its OCC and event contract.
26
+ * @complexity Same tree/slug scans as the universal owner, plus O(h) HTML time and space.
27
+ */
28
+ export function updateMenuTree(required, optional = {}) {
29
+ return updateUniversalMenuTree({ ...required, deps: { ...required.deps, normalizeMenuHtml: required.deps.normalizeMenuHtml ?? normalizeMenuHtml } }, optional);
30
+ }
31
+ /** Preserve the merge API for Node consumers, balancing only submitted HTML.
32
+ * @complexity O(h) time and space in submitted HTML length; omitted HTML is O(1).
33
+ */
34
+ export function mergeMenuHtml(required, optional = {}) {
35
+ return mergeUniversalMenuHtml(required, { ...optional, normalizeHtml: optional.normalizeHtml ?? normalizeMenuHtml });
36
+ }
37
+ /** Bind Node normalization once for the existing tool builder; permission checks and saves
38
+ * remain in their universal owners. Construction is O(1) time and space (fixed catalog).
39
+ */
40
+ export function buildMenusRegistrations(required, _optional = {}) {
41
+ return buildUniversalMenusRegistrations({ ...required, normalizeMenuHtml: required.normalizeMenuHtml ?? normalizeMenuHtml });
42
+ }
43
+ //# sourceMappingURL=index.js.map
@@ -3,6 +3,7 @@ import type { Clock } from "@jini-ai/core/primitives";
3
3
  import type { AuthorizeFn } from "../core/commands/command.js";
4
4
  import type { OutboxPort } from "../core/ports.js";
5
5
  import { type DerivedRiskByToolId, type ToolRegistration } from "@jini-ai/core";
6
+ import { type MenuHtmlNormalizer } from "./menu-html.js";
6
7
  import type { NavLocationBindingRepoPort } from "./ports.js";
7
8
  import type { MenuRepoPort } from "./repo.memory.js";
8
9
  /**
@@ -12,6 +13,8 @@ import type { MenuRepoPort } from "./repo.memory.js";
12
13
  * route-deps object after binding the kernel clock/ID contracts.
13
14
  */
14
15
  export interface MenusToolDeps {
16
+ /** Environment-owned fragment normalizer, forwarded to the audited menu write services. */
17
+ normalizeMenuHtml?: MenuHtmlNormalizer | undefined;
15
18
  authorize: AuthorizeFn;
16
19
  workspaceId: string;
17
20
  clock: Clock;
@@ -80,7 +80,7 @@ async function requireHtmlAuthoringPermission({ routeDeps, principalId, authorin
80
80
  await requireToolPermission({ authorize: adaptLegacyAuthorize({ authorize: routeDeps.authorize }), workspaceId: routeDeps.workspaceId, principalId, permission: routeDeps.rawHtmlPermission }, { entityType: "menu", ...(menuId ? { entityId: menuId } : {}) });
81
81
  }
82
82
  function menusDeps(routeDeps) {
83
- return { repo: routeDeps.menuRepo, clock: routeDeps.clock, idGen: routeDeps.idGen, outbox: routeDeps.outbox };
83
+ return { repo: routeDeps.menuRepo, clock: routeDeps.clock, idGen: routeDeps.idGen, outbox: routeDeps.outbox, normalizeMenuHtml: routeDeps.normalizeMenuHtml };
84
84
  }
85
85
  export function buildMenusRegistrations(routeDeps) {
86
86
  const handlers = {
package/dist/seo/media.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mimeForTransformFormat } from "../media/transform-types.js";
1
+ import { mimeForTransformFormat } from "../core/transform-format.js";
2
2
  /**
3
3
  * @file `resolveSeoImageRef` (ADR-PIPE-008 Decision §6, C-013, EC-07;
4
4
  * EC-07's "never generates" clause AMENDED 2026-09-05 — see the ADR's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/cms",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
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": {
@@ -29,6 +29,15 @@
29
29
  "./trash": "universal",
30
30
  "./settings/express": "node",
31
31
  "./media/node": "node",
32
+ "./forms/html-render": "universal",
33
+ "./forms/submission-attempts": "universal",
34
+ "./widgets/page-embed-types": "universal",
35
+ "./widgets/html/html-embeds": "node",
36
+ "./widgets/html/gallery-render": "universal",
37
+ "./navigation/types": "universal",
38
+ "./navigation/dropdown": "universal",
39
+ "./navigation/node": "node",
40
+ "./media/slug": "universal",
32
41
  "./forms": "universal",
33
42
  "./forms/html": "node",
34
43
  "./forms/sql": "node",
@@ -214,6 +223,51 @@
214
223
  "import": "./dist/media/node.js",
215
224
  "default": "./dist/media/node.js"
216
225
  },
226
+ "./forms/html-render": {
227
+ "types": "./dist/forms/html-render.d.ts",
228
+ "import": "./dist/forms/html-render.js",
229
+ "default": "./dist/forms/html-render.js"
230
+ },
231
+ "./forms/submission-attempts": {
232
+ "types": "./dist/forms/submission-attempts.d.ts",
233
+ "import": "./dist/forms/submission-attempts.js",
234
+ "default": "./dist/forms/submission-attempts.js"
235
+ },
236
+ "./widgets/page-embed-types": {
237
+ "types": "./dist/widgets/page-embed-types.d.ts",
238
+ "import": "./dist/widgets/page-embed-types.js",
239
+ "default": "./dist/widgets/page-embed-types.js"
240
+ },
241
+ "./widgets/html/html-embeds": {
242
+ "types": "./dist/widgets/html/html-embeds.d.ts",
243
+ "import": "./dist/widgets/html/html-embeds.js",
244
+ "default": "./dist/widgets/html/html-embeds.js"
245
+ },
246
+ "./widgets/html/gallery-render": {
247
+ "types": "./dist/widgets/html/gallery-render.d.ts",
248
+ "import": "./dist/widgets/html/gallery-render.js",
249
+ "default": "./dist/widgets/html/gallery-render.js"
250
+ },
251
+ "./navigation/types": {
252
+ "types": "./dist/navigation/types.d.ts",
253
+ "import": "./dist/navigation/types.js",
254
+ "default": "./dist/navigation/types.js"
255
+ },
256
+ "./navigation/dropdown": {
257
+ "types": "./dist/navigation/dropdown.d.ts",
258
+ "import": "./dist/navigation/dropdown.js",
259
+ "default": "./dist/navigation/dropdown.js"
260
+ },
261
+ "./navigation/node": {
262
+ "types": "./dist/navigation/node/index.d.ts",
263
+ "import": "./dist/navigation/node/index.js",
264
+ "default": "./dist/navigation/node/index.js"
265
+ },
266
+ "./media/slug": {
267
+ "types": "./dist/media/slug.d.ts",
268
+ "import": "./dist/media/slug.js",
269
+ "default": "./dist/media/slug.js"
270
+ },
217
271
  "./forms": {
218
272
  "types": "./dist/forms/index.d.ts",
219
273
  "import": "./dist/forms/index.js",
@@ -364,12 +418,12 @@
364
418
  "@types/better-sqlite3": "^7.6.13",
365
419
  "@electric-sql/pglite": "0.5.8",
366
420
  "jsdom": "^25.0.1",
421
+ "@jini-ai/core": "0.4.4",
367
422
  "@jini-ai/http-kit": "0.4.1",
368
- "@jini-ai/db": "0.3.2",
369
- "@jini-ai/core": "0.4.3"
423
+ "@jini-ai/db": "0.3.3"
370
424
  },
371
425
  "dependencies": {
372
- "@jini-ai/core": "^0.4.3"
426
+ "@jini-ai/core": "^0.4.4"
373
427
  },
374
428
  "peerDependencies": {
375
429
  "sharp": "^0.35.3",