@jini-ai/cms 0.5.5 → 0.5.7

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 (138) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/consent/express/index.d.ts +53 -0
  3. package/dist/consent/express/index.js +159 -0
  4. package/dist/consent/express/response.d.ts +21 -0
  5. package/dist/consent/express/response.js +89 -0
  6. package/dist/consent/html/client.d.ts +6 -0
  7. package/dist/consent/html/client.js +135 -0
  8. package/dist/consent/html/gate.d.ts +29 -0
  9. package/dist/consent/html/gate.js +104 -0
  10. package/dist/consent/html/index.d.ts +4 -0
  11. package/dist/consent/html/index.js +3 -0
  12. package/dist/consent/html/render.d.ts +27 -0
  13. package/dist/consent/html/render.js +62 -0
  14. package/dist/consent/html/static.d.ts +7 -0
  15. package/dist/consent/html/static.js +25 -0
  16. package/dist/consent/html/styles.d.ts +3 -0
  17. package/dist/consent/html/styles.js +22 -0
  18. package/dist/consent/index.d.ts +3 -0
  19. package/dist/consent/index.js +3 -0
  20. package/dist/consent/node/index.d.ts +47 -0
  21. package/dist/consent/node/index.js +169 -0
  22. package/dist/consent/policy.d.ts +47 -0
  23. package/dist/consent/policy.js +62 -0
  24. package/dist/consent/types.d.ts +40 -0
  25. package/dist/consent/types.js +5 -0
  26. package/dist/content-types/agent-tools.js +1 -1
  27. package/dist/content-types/tool-registrations.d.ts +2 -0
  28. package/dist/content-types/tool-registrations.js +1 -0
  29. package/dist/content-types/write-service.d.ts +2 -0
  30. package/dist/content-types/write-service.js +3 -0
  31. package/dist/forms/descriptor-rules.d.ts +9 -0
  32. package/dist/forms/descriptor-rules.js +112 -0
  33. package/dist/forms/email-format.d.ts +7 -0
  34. package/dist/forms/email-format.js +11 -0
  35. package/dist/forms/express/handler.js +4 -8
  36. package/dist/forms/field-registry.d.ts +193 -0
  37. package/dist/forms/field-registry.js +231 -0
  38. package/dist/forms/forms.d.ts +8 -5
  39. package/dist/forms/forms.js +29 -46
  40. package/dist/forms/html/html-authoring.js +63 -13
  41. package/dist/forms/html/html-submission-adapter.d.ts +5 -5
  42. package/dist/forms/html/html-submission-adapter.js +6 -13
  43. package/dist/forms/html-render.d.ts +10 -3
  44. package/dist/forms/html-render.js +116 -14
  45. package/dist/forms/index.d.ts +1 -0
  46. package/dist/forms/index.js +1 -0
  47. package/dist/forms/repo.memory.js +4 -4
  48. package/dist/forms/submission-attempts.d.ts +1 -1
  49. package/dist/forms/submission-attempts.js +13 -2
  50. package/dist/forms/submission-email-validation.js +2 -4
  51. package/dist/forms/submit-service.d.ts +3 -0
  52. package/dist/forms/submit-service.js +38 -5
  53. package/dist/forms/types.d.ts +50 -2
  54. package/dist/listing/batch.d.ts +53 -0
  55. package/dist/listing/batch.js +57 -0
  56. package/dist/listing/html.d.ts +72 -0
  57. package/dist/listing/html.js +109 -0
  58. package/dist/listing/index.d.ts +5 -0
  59. package/dist/listing/index.js +5 -0
  60. package/dist/listing/paths.d.ts +32 -0
  61. package/dist/listing/paths.js +37 -0
  62. package/dist/listing/see-more-script.d.ts +16 -0
  63. package/dist/listing/see-more-script.js +62 -0
  64. package/dist/media/content-type-sniffer.d.ts +1 -1
  65. package/dist/media/content-type-sniffer.js +15 -0
  66. package/dist/media/image-transformer.sharp.js +4 -1
  67. package/dist/media/media-service.d.ts +5 -3
  68. package/dist/media/media-service.js +10 -26
  69. package/dist/media/node.d.ts +1 -1
  70. package/dist/media/node.js +1 -1
  71. package/dist/media/slug.d.ts +8 -0
  72. package/dist/media/slug.js +32 -0
  73. package/dist/media/transform-registry.js +1 -1
  74. package/dist/media/transform-types.d.ts +2 -0
  75. package/dist/media/transform-types.js +3 -0
  76. package/dist/media/video-frames.ffmpeg.d.ts +15 -2
  77. package/dist/media/video-frames.ffmpeg.js +28 -13
  78. package/dist/navigation/dropdown.d.ts +41 -0
  79. package/dist/navigation/dropdown.js +85 -0
  80. package/dist/navigation/index.d.ts +2 -0
  81. package/dist/navigation/index.js +2 -0
  82. package/dist/redirects/capture.d.ts +2 -0
  83. package/dist/redirects/capture.js +4 -0
  84. package/dist/redirects/redirects.d.ts +8 -0
  85. package/dist/redirects/redirects.js +14 -0
  86. package/dist/routing/index.d.ts +5 -0
  87. package/dist/routing/index.js +2 -0
  88. package/dist/routing/ports.d.ts +51 -0
  89. package/dist/routing/ports.js +2 -0
  90. package/dist/routing/routing.d.ts +138 -0
  91. package/dist/routing/routing.js +443 -0
  92. package/dist/routing/types.d.ts +164 -0
  93. package/dist/routing/types.js +2 -0
  94. package/dist/seo/media.d.ts +23 -0
  95. package/dist/seo/media.js +30 -5
  96. package/dist/seo/page-head.js +8 -1
  97. package/dist/seo/seo.d.ts +2 -0
  98. package/dist/seo/seo.js +11 -6
  99. package/dist/seo/types.d.ts +8 -0
  100. package/dist/seo/xml-escape.d.ts +1 -3
  101. package/dist/seo/xml-escape.js +1 -6
  102. package/dist/widgets/concurrency.d.ts +27 -1
  103. package/dist/widgets/concurrency.js +12 -23
  104. package/dist/widgets/config-validation.js +6 -0
  105. package/dist/widgets/gallery-resolver.d.ts +23 -0
  106. package/dist/widgets/gallery-resolver.js +80 -0
  107. package/dist/widgets/html/gallery-render.d.ts +24 -0
  108. package/dist/widgets/html/gallery-render.js +65 -0
  109. package/dist/widgets/html/html-embeds.d.ts +4 -0
  110. package/dist/widgets/html/html-embeds.js +7 -1
  111. package/dist/widgets/html/index.d.ts +3 -0
  112. package/dist/widgets/html/index.js +3 -0
  113. package/dist/widgets/html/server-fragment.d.ts +9 -0
  114. package/dist/widgets/html/server-fragment.js +38 -0
  115. package/dist/widgets/markers/gallery.d.ts +50 -0
  116. package/dist/widgets/markers/gallery.js +164 -0
  117. package/dist/widgets/markers/html-attributes.d.ts +6 -1
  118. package/dist/widgets/markers/html-attributes.js +24 -9
  119. package/dist/widgets/markers/index.d.ts +2 -0
  120. package/dist/widgets/markers/index.js +2 -0
  121. package/dist/widgets/markers/marker.d.ts +4 -0
  122. package/dist/widgets/markers/marker.js +6 -0
  123. package/dist/widgets/markers/media-reference.d.ts +11 -0
  124. package/dist/widgets/markers/media-reference.js +11 -0
  125. package/dist/widgets/page-embed-types.d.ts +1 -1
  126. package/dist/widgets/page-embed-types.js +1 -1
  127. package/dist/widgets/registry.d.ts +144 -10
  128. package/dist/widgets/registry.js +40 -0
  129. package/dist/widgets/resolver-service.d.ts +10 -0
  130. package/dist/widgets/resolver-service.js +73 -29
  131. package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
  132. package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
  133. package/dist/widgets/resolvers/gallery.d.ts +12 -0
  134. package/dist/widgets/resolvers/gallery.js +33 -0
  135. package/dist/widgets/resolvers/index.d.ts +1 -0
  136. package/dist/widgets/resolvers/index.js +1 -0
  137. package/dist/widgets/types.d.ts +3 -1
  138. package/package.json +100 -7
@@ -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
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @file Resolution logic for the Tovu `routing` Tier-2 library (ADR-039 v0).
3
+ *
4
+ * Purpose:
5
+ * Implements the inverse resolver (`urlFor`/`isActive`) Menus (ADR-029) and
6
+ * SEO (ADR-032) consume, the forward resolution pipeline registration seam
7
+ * Redirects (ADR-033) will consume, the named-route registry backing `route`
8
+ * targets, and the `SlugChangeCapture` registration slot (ADR-039 §4).
9
+ *
10
+ * How it relates to the project:
11
+ * - `routing/types.ts` supplies the `RouteTarget` vocabulary and result shapes.
12
+ * - `routing/ports.ts` supplies `RouteResolverDeps` (reuses `PostRepoPort`).
13
+ * - Only `post` is a real implemented content type today, so `entryRef`
14
+ * resolution goes through `PostRepoPort` directly; `termRef` is a documented
15
+ * stub (taxonomy doesn't exist yet — ADR-029 Round-3 fold item 1).
16
+ *
17
+ * Architectural role:
18
+ * Core-owned Tier-2 library code. No Drizzle/SQLite adapter and no HTTP
19
+ * wiring live here (that is out of scope for this v0 build) — this file is
20
+ * pure resolution logic over injected ports and in-module registries.
21
+ */
22
+ import type { RouteResolverDeps, RoutingContentRecord, RoutingPorts } from './ports.js';
23
+ import type { RouteResolveContext, RouteResolvePhaseHandler, RouteResolvePhaseName, RouteResolvePhaseOutcome, RouteResolveResult, RouteTarget, RouteUrl, SlugChangeCapture } from './types.js';
24
+ /** Raised when a `RouteTarget` carries a discriminant this library does not recognize. */
25
+ export declare class RouteResolutionError extends Error {
26
+ }
27
+ export interface RegisterNamedRouteInput {
28
+ readonly name: string;
29
+ readonly path: string;
30
+ }
31
+ export interface UrlForRequired {
32
+ deps: RouteResolverDeps;
33
+ target: RouteTarget;
34
+ ctx: RouteResolveContext;
35
+ }
36
+ export interface UrlForOptional {
37
+ }
38
+ export interface IsActiveRequired {
39
+ deps: RouteResolverDeps;
40
+ target: RouteTarget;
41
+ currentPath: string;
42
+ ctx: RouteResolveContext;
43
+ }
44
+ export interface IsActiveOptional {
45
+ }
46
+ export interface RegisterResolvePhaseOptions {
47
+ /**
48
+ * The registrant's identity. Registering under an owner that already holds
49
+ * handlers in this phase REPLACES them rather than appending, so a registrant
50
+ * whose handlers close over a per-boot resource (a site's database handle, a
51
+ * request-scoped client) cannot leave the previous boot's closures reachable.
52
+ *
53
+ * Omit it and the registration is appended and never superseded — the v0
54
+ * behavior this library's own tests still use.
55
+ */
56
+ readonly owner?: unknown;
57
+ /**
58
+ * This registration's fault policy when its handler throws. `"fail"` (the default) rethrows
59
+ * out of the phase, so the caller's own error handling runs (the public site routes answer
60
+ * `500 <h1>Site error</h1>`, on BOTH phases as of 2026-09-16, t91 F4.3-A: `post_content` on
61
+ * `GET /:slug` used to leave a default-policy throw with no response at all — see
62
+ * `routes/site/pages.ts`'s `handlePostNotFoundInSlugCatch`). `"skip"` logs `[routing] a <phase>
63
+ * resolver failed for <path> — skipping it` and continues to the phase's remaining handlers.
64
+ *
65
+ * Choose `"skip"` only for a handler whose failure can only DECLINE an outcome — it can never
66
+ * fabricate one, so a swallowed failure cannot weaken any guard. Redirects opts into it for
67
+ * exactly that reason (INV-03: a lookup failure means "no redirect", never "gone"). A future
68
+ * guard-type handler (a takedown or reserved-path check, which must BLOCK on failure, not wave
69
+ * a request through) fails CLOSED by default unless it deliberately opts into `"skip"` too.
70
+ */
71
+ readonly onError?: "skip" | "fail";
72
+ }
73
+ export interface ResolveInput {
74
+ path: string;
75
+ ctx: RouteResolveContext;
76
+ }
77
+ export interface ResolveRequired {
78
+ input: ResolveInput;
79
+ }
80
+ export interface ResolveOptional {
81
+ }
82
+ export type EmptyOptions = Record<string, never>;
83
+ export interface CreateRoutingRequired {
84
+ readonly ports: RoutingPorts;
85
+ }
86
+ export interface CreateRoutingOptional {
87
+ readonly namedRoutes?: ReadonlyArray<readonly [string, string]>;
88
+ }
89
+ /** Bound routing API. All methods share one instance's registrations and host ports. */
90
+ export interface Routing {
91
+ entryPublicPath(required: {
92
+ post: RoutingContentRecord;
93
+ ctx: RouteResolveContext;
94
+ }, optional?: EmptyOptions): RouteUrl | null;
95
+ postPublicPath(required: {
96
+ slug: string;
97
+ }, optional?: EmptyOptions): string;
98
+ getNamedRoute(required: {
99
+ name: string;
100
+ }, optional?: EmptyOptions): string | null;
101
+ registerNamedRoute(required: RegisterNamedRouteInput, optional?: EmptyOptions): void;
102
+ urlFor(required: UrlForRequired, optional?: UrlForOptional): Promise<RouteUrl | null>;
103
+ isActive(required: IsActiveRequired, optional?: IsActiveOptional): Promise<boolean>;
104
+ resolve(required: ResolveRequired, optional?: ResolveOptional): Promise<RouteResolveResult>;
105
+ registerResolvePhase(required: {
106
+ phase: RouteResolvePhaseName;
107
+ resolver: RouteResolvePhaseHandler;
108
+ }, optional?: RegisterResolvePhaseOptions): () => void;
109
+ unregisterResolvePhaseOwner(required: {
110
+ owner: unknown;
111
+ }, optional?: EmptyOptions): void;
112
+ runPreContentPhase(required: {
113
+ path: string;
114
+ ctx: RouteResolveContext;
115
+ }, optional?: EmptyOptions): Promise<RouteResolvePhaseOutcome | null>;
116
+ runPostContentPhase(required: {
117
+ path: string;
118
+ ctx: RouteResolveContext;
119
+ }, optional?: EmptyOptions): Promise<RouteResolvePhaseOutcome | null>;
120
+ registerSlugChangeCapture(required: {
121
+ impl: SlugChangeCapture;
122
+ }, optional?: EmptyOptions): () => void;
123
+ getSlugChangeCapture(required: EmptyOptions, optional?: EmptyOptions): SlugChangeCapture | undefined;
124
+ /** Test-only reset of this instance; never resets another instance's registrations. */
125
+ resetRoutingRegistrationsForTests(required: EmptyOptions, optional?: EmptyOptions): void;
126
+ }
127
+ /**
128
+ * Create routing over host-owned content visibility and fault reporting.
129
+ * Each instance owns its registrations; a host that historically shared one process registry
130
+ * must bind one instance and reuse it across composition roots. No database or clock is constructed.
131
+ * @param required Host ports; visibility errors propagate unchanged.
132
+ * @param optional Initial named-route configuration (home/search by default).
133
+ * @returns Resolvers and registration lifecycles sharing this instance's state.
134
+ * @complexity O(r) initialization for r configured named routes.
135
+ * @example const routing = createRouting({ ports }, { namedRoutes: [['home', '/']] });
136
+ */
137
+ export declare function createRouting(required: CreateRoutingRequired, optional?: CreateRoutingOptional): Routing;
138
+ //# sourceMappingURL=routing.d.ts.map
@@ -0,0 +1,443 @@
1
+ /** Raised when a `RouteTarget` carries a discriminant this library does not recognize. */
2
+ export class RouteResolutionError extends Error {
3
+ }
4
+ /**
5
+ * Create routing over host-owned content visibility and fault reporting.
6
+ * Each instance owns its registrations; a host that historically shared one process registry
7
+ * must bind one instance and reuse it across composition roots. No database or clock is constructed.
8
+ * @param required Host ports; visibility errors propagate unchanged.
9
+ * @param optional Initial named-route configuration (home/search by default).
10
+ * @returns Resolvers and registration lifecycles sharing this instance's state.
11
+ * @complexity O(r) initialization for r configured named routes.
12
+ * @example const routing = createRouting({ ports }, { namedRoutes: [['home', '/']] });
13
+ */
14
+ export function createRouting(required, optional = {}) {
15
+ // ---------------------------------------------------------------------------
16
+ // Named-route registry (backs `route` targets — ADR-039 §3)
17
+ // ---------------------------------------------------------------------------
18
+ const DEFAULT_NAMED_ROUTES = [
19
+ ["home", "/"],
20
+ ["search", "/search"],
21
+ ];
22
+ const initialNamedRoutes = optional.namedRoutes ?? DEFAULT_NAMED_ROUTES;
23
+ const namedRoutes = new Map(initialNamedRoutes);
24
+ /**
25
+ * Register (or overwrite) a named core route. Core-owned registration, not an
26
+ * open plugin hook — matches ADR-039 §1's treatment of the forward pipeline.
27
+ *
28
+ * @complexity O(1) time/space.
29
+ */
30
+ function registerNamedRoute(input, _optional = {}) {
31
+ namedRoutes.set(input.name, input.path);
32
+ }
33
+ /**
34
+ * Look up a named route's fixed path.
35
+ *
36
+ * @returns the registered path, or `null` if `name` has no registration
37
+ * (mirrors the library-wide "unavailable → null" contract).
38
+ * @complexity O(1) time/space.
39
+ */
40
+ function getNamedRoute({ name }, _optional = {}) {
41
+ return namedRoutes.get(name) ?? null;
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Path normalization + canonical URL composition
45
+ // ---------------------------------------------------------------------------
46
+ const ABSOLUTE_URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
47
+ function isAbsoluteUrl(value) {
48
+ return ABSOLUTE_URL_PATTERN.test(value) || value.startsWith("//");
49
+ }
50
+ /**
51
+ * Normalize a request/target path: strip query/hash, force a leading slash,
52
+ * and drop a trailing slash (except for the root path itself).
53
+ *
54
+ * @complexity O(n) in path length.
55
+ */
56
+ function normalizePath(rawPath) {
57
+ const withoutQueryOrHash = rawPath.split(/[?#]/)[0] ?? "";
58
+ const withLeadingSlash = withoutQueryOrHash.startsWith("/")
59
+ ? withoutQueryOrHash
60
+ : `/${withoutQueryOrHash}`;
61
+ if (withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")) {
62
+ return withLeadingSlash.slice(0, -1);
63
+ }
64
+ return withLeadingSlash;
65
+ }
66
+ /**
67
+ * Builds a post/page's own public path from its `slug` — the one point every call site that turns a
68
+ * `PostRecord.slug` into a route string goes through, so the content-owned-homepage `"/"` root-slug
69
+ * exception (`post.ts`'s `ROOT_SLUG`) has exactly one implementation instead of N independently
70
+ * re-deriving it. `/${slug}` is correct for every ordinary slug; the one deliberate exception is the
71
+ * literal `"/"` slug itself, which a naive `/${slug}` template would double up into `"//"` — a Page
72
+ * that has claimed the site's own homepage (`post.ts`'s `ROOT_SLUG` doc has the write-time gate that
73
+ * makes `"/"` the only slug this can ever matter for).
74
+ *
75
+ * @complexity O(1).
76
+ */
77
+ function postPublicPath({ slug }, _optional = {}) {
78
+ return slug === "/" ? "/" : `/${slug}`;
79
+ }
80
+ /**
81
+ * Compose the `canonicalUrl` a resolved `path` implies.
82
+ *
83
+ * TODO(ADR-040): compose canonicalUrl from OriginRegistryPort.canonicalOrigin(ctx)
84
+ * once `src/origin` exists, instead of `ctx.originOverride`. `src/origin` is
85
+ * being built in parallel by another agent and does not exist yet, so this
86
+ * library must not import it.
87
+ *
88
+ * @complexity O(1).
89
+ */
90
+ function composeCanonicalUrl(path, ctx) {
91
+ if (isAbsoluteUrl(path))
92
+ return path;
93
+ const origin = ctx.originOverride?.replace(/\/$/, "");
94
+ return origin ? `${origin}${path}` : path;
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // Per-kind target resolvers (internal — dispatched by `urlFor`)
98
+ // ---------------------------------------------------------------------------
99
+ /**
100
+ * `published` is the only status that yields a live, linkable path (mirrors `getPublishedPostBySlug`,
101
+ * `post.ts`). Checks `isTrashed` too, not just `status` — `softDelete` (post.ts) stamps only
102
+ * `deletedAt`/`updatedAt`/`version`, never `status`, so a post that was `published` when trashed
103
+ * stays `status: "published"` forever, and `postRepo.findById` (this module's `resolveEntryRefTarget`
104
+ * and every other caller of {@link entryPublicPath}) is documented trash-BLIND. Without this check a
105
+ * trashed post would resolve to a live, linkable URL through `urlFor`/menu href resolution
106
+ * (`resolveStaticMenusForRender`, `server/inbound/public-http/routes/site/pages.ts`) even though the
107
+ * post itself 404s at every real render entrypoint. Same defect pattern as
108
+ * caa116115c103630ba5253f9bbe3bceb234347d8 (sitemap.xml/llms.txt) and a5c9bac8 (media rendition
109
+ * gating).
110
+ */
111
+ function isPublished(post) {
112
+ // Scheduled publishing (2026-10-05): a published row whose `publishAt` is still ahead gets no
113
+ // live link yet either — `isLiveAt` is `status === "published" && !isTrashed` plus that check.
114
+ return required.ports.isLive({ post });
115
+ }
116
+ /**
117
+ * Pure half of `entryRef` resolution: given a `PostRecord` already in hand, decides its public path
118
+ * with no repo call of its own. Factored out of {@link resolveEntryRefTarget} (and exported) so a
119
+ * caller that already holds the record — e.g. `features/post/tool-registrations.ts`'s
120
+ * `content_post_list`, iterating rows a prior `postRepo.list()` already fetched — can resolve
121
+ * `publicUrl` without a redundant `postRepo.findById` per row. `resolveEntryRefTarget`/`urlFor`
122
+ * still call this internally, so a caller resolving by id and a caller resolving from an
123
+ * already-held record always compute the identical path/canonicalUrl — the one-resolver property
124
+ * SEO's canonical tags depend on is unchanged.
125
+ *
126
+ * @returns `null` for a draft, unpublished, or otherwise non-live row — never a link a visitor
127
+ * would 404 on.
128
+ * @complexity O(1).
129
+ */
130
+ function entryPublicPath({ post, ctx }, _optional = {}) {
131
+ if (!isPublished(post))
132
+ return null;
133
+ const path = postPublicPath({ slug: post.slug });
134
+ return { path, canonicalUrl: composeCanonicalUrl(path, ctx) };
135
+ }
136
+ async function resolveEntryRefTarget(deps, target, ctx) {
137
+ const post = await deps.postRepo.findById({ workspaceId: ctx.workspaceId, id: target.entryId });
138
+ if (!post)
139
+ return null;
140
+ return entryPublicPath({ post: post, ctx: ctx });
141
+ }
142
+ /**
143
+ * Stub: taxonomy isn't implemented in this repo. ADR-029's Round-3 audit fold
144
+ * promoted "term-link schema" from an Open item to a Wave-1 acceptance
145
+ * blocker — ADR-022 §5's `entry_refs` covers entry-to-entry references only,
146
+ * with no term-target schema yet (content-lib sign-off on a typed `term_refs`
147
+ * index, or an accepted `entry_refs` extension for term targets, is owed).
148
+ * Always resolves `null` until that schema lands.
149
+ */
150
+ function resolveTermRefTarget(_target, _ctx) {
151
+ return null;
152
+ }
153
+ const UNSAFE_URL_SCHEMES = ["javascript:", "data:"];
154
+ /**
155
+ * @complexity O(1) (bounded scheme-prefix check).
156
+ */
157
+ function resolveUrlTarget(target, ctx) {
158
+ const href = target.href.trim();
159
+ const lowered = href.toLowerCase();
160
+ if (UNSAFE_URL_SCHEMES.some((scheme) => lowered.startsWith(scheme))) {
161
+ return null;
162
+ }
163
+ return { path: href, canonicalUrl: composeCanonicalUrl(href, ctx) };
164
+ }
165
+ function resolveRouteRefTarget(target, ctx) {
166
+ const path = getNamedRoute({ name: target.route });
167
+ if (path === null)
168
+ return null;
169
+ return { path, canonicalUrl: composeCanonicalUrl(path, ctx) };
170
+ }
171
+ /**
172
+ * Resolve a `RouteTarget` to its concrete path + canonical URL.
173
+ *
174
+ * Purpose: the one inverse-resolution entrypoint Menus and SEO depend on.
175
+ * Deviates from ADR-039 §2's 2-arg pseudocode (`urlFor(target, ctx)`) by
176
+ * taking a required-object first parameter carrying `deps` — `entryRef`
177
+ * resolution needs an async `PostRepoPort` call, so `urlFor` must be async and
178
+ * (per this repo's house style) DI'd rather than reaching for a singleton.
179
+ * The semantic contract (target + workspace-scoped ctx → `RouteUrl | null`) is
180
+ * unchanged.
181
+ *
182
+ * @returns `null` when the target is unavailable (not found, unpublished,
183
+ * unresolvable taxonomy term, unsafe URL scheme, or unregistered route name) —
184
+ * never throws for those cases, so callers can treat "no link" uniformly.
185
+ * @throws {RouteResolutionError} only if `target.kind` is not one of the four
186
+ * known discriminants (defensive; unreachable through the typed union).
187
+ * @complexity O(1) plus the cost of one repo call for `entryRef` targets.
188
+ */
189
+ async function urlFor(required, _optional = {}) {
190
+ const { deps, target, ctx } = required;
191
+ switch (target.kind) {
192
+ case "entryRef":
193
+ return resolveEntryRefTarget(deps, target, ctx);
194
+ case "termRef":
195
+ return resolveTermRefTarget(target, ctx);
196
+ case "url":
197
+ return resolveUrlTarget(target, ctx);
198
+ case "route":
199
+ return resolveRouteRefTarget(target, ctx);
200
+ default: {
201
+ const unreachable = target;
202
+ throw new RouteResolutionError(`unrecognized route target kind: ${JSON.stringify(unreachable)}`);
203
+ }
204
+ }
205
+ }
206
+ /**
207
+ * Whether `target` resolves to `currentPath` — the active-state Menus needs
208
+ * for nav highlighting.
209
+ *
210
+ * v0 does exact-path matching only, after normalization. Ancestor/prefix
211
+ * semantics for nested menu highlighting (is `/blog` active on `/blog/post`?)
212
+ * are explicitly deferred to v0.1 (ADR-039 "Open" item 3) since they also
213
+ * need the `urlFor → null` caller-render contract (hide vs. render inert)
214
+ * pinned first.
215
+ *
216
+ * @returns `false` when the target is unavailable (delegates to `urlFor`).
217
+ * @complexity Same as `urlFor` plus O(n) path normalization.
218
+ */
219
+ async function isActive(required, _optional = {}) {
220
+ const resolved = await urlFor({ deps: required.deps, target: required.target, ctx: required.ctx });
221
+ if (!resolved)
222
+ return false;
223
+ return normalizePath(resolved.path) === normalizePath(required.currentPath);
224
+ }
225
+ const phaseRegistry = {
226
+ pre_content: [],
227
+ post_content: [],
228
+ };
229
+ /** Every phase, so an owner-wide revoke never has to be told which phases it used. */
230
+ const ALL_PHASES = ["pre_content", "post_content"];
231
+ /**
232
+ * Register a resolver into an ordered, core-owned pipeline phase
233
+ * (`normalize → pre_content → content-resolve → post_content → 404`,
234
+ * ADR-039 §1). Not an open ADR-009 plugin hook in v1 — this is the seam
235
+ * Redirects (ADR-033) will register into once it exists; no real rules are
236
+ * registered by this library itself.
237
+ *
238
+ * Resolvers within a phase run in registration order; the first non-`null`
239
+ * outcome short-circuits the remaining resolvers in that phase and the rest
240
+ * of the pipeline. Per ADR-039 Round-2 amendment 5, a `pre_content` resolver
241
+ * must only ever return a redirect/gone decision with an audited rule — it
242
+ * must never fabricate a `not_found` for a path that live content could still
243
+ * resolve. This library does not enforce that invariant (no rules exist yet
244
+ * to violate it); it is a contract note for the future Redirects implementer.
245
+ *
246
+ * Lifecycle (2026-09-16). This registry is module-level and therefore
247
+ * process-wide, but its registrants are not: a composition root registers a
248
+ * handler that has CLOSED OVER that composition's resources, and more than one
249
+ * composition runs per process (an integration test boots one site per case,
250
+ * and `createRouteDeps()` plus `createSiteRouteDeps()` can both run in one
251
+ * test process). Append-only, that left every previous composition's closure
252
+ * live on the request path forever — benign
253
+ * while the superseded resources were in-memory, a `TypeError: The database
254
+ * connection is not open` 500 on every phase-running route once one of them
255
+ * was a site database that had since closed. Pass {@link
256
+ * RegisterResolvePhaseOptions.owner} to opt into supersede-on-re-registration;
257
+ * {@link unregisterResolvePhaseOwner} revokes an owner outright.
258
+ *
259
+ * @returns a disposer that removes exactly this registration (idempotent, and
260
+ * a no-op once an owner-scoped re-registration has already superseded it).
261
+ * @complexity O(1) for an unowned registration; O(n) in the phase's existing
262
+ * registrations for an owned one (it first evicts that owner's). O(1) space.
263
+ */
264
+ function registerResolvePhase({ phase, resolver }, options = {}) {
265
+ const owner = options.owner;
266
+ if (owner !== undefined) {
267
+ phaseRegistry[phase] = phaseRegistry[phase].filter((entry) => entry.owner !== owner);
268
+ }
269
+ const registration = { owner, handler: resolver, onError: options.onError ?? "fail" };
270
+ phaseRegistry[phase].push(registration);
271
+ return () => {
272
+ phaseRegistry[phase] = phaseRegistry[phase].filter((entry) => entry !== registration);
273
+ };
274
+ }
275
+ /**
276
+ * Revoke every handler `owner` registered, across every phase — the explicit
277
+ * teardown half of {@link RegisterResolvePhaseOptions.owner}, for a host that
278
+ * knows when a site is finished rather than only when the next one starts.
279
+ *
280
+ * Unowned registrations are never matched: `undefined` is the "no owner" marker,
281
+ * not an owner, so `unregisterResolvePhaseOwner(undefined)` is a deliberate no-op.
282
+ *
283
+ * @complexity O(n) in total registered handlers. O(1) space.
284
+ */
285
+ function unregisterResolvePhaseOwner({ owner }, _optional = {}) {
286
+ if (owner === undefined)
287
+ return;
288
+ for (const phase of ALL_PHASES) {
289
+ phaseRegistry[phase] = phaseRegistry[phase].filter((entry) => entry.owner !== owner);
290
+ }
291
+ }
292
+ /**
293
+ * Run one phase's handlers in registration order, first non-`null` outcome wins.
294
+ *
295
+ * A handler's fault policy is its own registration's {@link RegisterResolvePhaseOptions.onError}
296
+ * (2026-09-16, t91 F4.3 — previously every throw was unconditionally isolated). `"skip"` logs the
297
+ * failure and continues with the next handler, rather than propagating out of the phase — correct
298
+ * for a handler whose failure can only DECLINE an outcome (a redirect, a gone), never fabricate
299
+ * one, which is why Redirects opts into it (`redirects/phase-handler.ts`'s INV-03). `"fail"` (the
300
+ * default) rethrows instead: a handler that guards content (a takedown, a reserved path) must not
301
+ * silently wave a request through just because its own check broke. No log on the `"fail"` path —
302
+ * the caller's own catch reports it (`routes/site/pages.ts`'s `reportSiteRenderFault`), so a fault
303
+ * produces exactly one log line either way.
304
+ *
305
+ * The old universal-skip default is why a stale closed-database handler could take down `/` and
306
+ * `/pricing` while `/products` (which runs no phase) kept serving — that direction (never let one
307
+ * registrant 500 every route) is still correct for `"skip"`; it is just no longer assumed for
308
+ * every registrant.
309
+ *
310
+ * @complexity O(h) in the phase's registered handlers, plus each handler's own
311
+ * opaque cost; short-circuits on the first outcome or the first `"fail"`-policy throw.
312
+ */
313
+ async function runPhase(phase, path, ctx) {
314
+ // Snapshot: a handler is free to register or revoke during its own run, and
315
+ // mutating the live array mid-iteration would silently skip a sibling.
316
+ for (const { handler, onError } of [...phaseRegistry[phase]]) {
317
+ let outcome;
318
+ try {
319
+ outcome = await handler(path, ctx);
320
+ }
321
+ catch (err) {
322
+ if (onError !== "skip")
323
+ throw err;
324
+ // The host retains its operator-facing route fault channel.
325
+ required.ports.reportPhaseError({ message: `[routing] a ${phase} resolver failed for ${path} — skipping it`, error: err });
326
+ continue;
327
+ }
328
+ if (outcome)
329
+ return outcome;
330
+ }
331
+ return null;
332
+ }
333
+ /**
334
+ * Run the forward resolution pipeline for an inbound request path.
335
+ *
336
+ * v0 behavior: normalize the path, run `pre_content` resolvers, then (with no
337
+ * `content-resolve` step wired here — that is fixed core content lookup, out
338
+ * of scope for this library) run `post_content` resolvers. Falls through to
339
+ * `{ matched: false }` (404) if nothing resolves. This is a registration seam
340
+ * for Redirects, not full pipeline behavior — no real rules exist yet.
341
+ *
342
+ * @complexity O(h) where h is the total number of registered phase handlers
343
+ * that run before a match (or all of them, on a full miss); each handler's own
344
+ * cost is opaque to this function.
345
+ */
346
+ async function resolve(required, _optional = {}) {
347
+ const path = normalizePath(required.input.path);
348
+ const ctx = required.input.ctx;
349
+ const preOutcome = await runPhase("pre_content", path, ctx);
350
+ if (preOutcome)
351
+ return { matched: true, phase: "pre_content", outcome: preOutcome };
352
+ // content-resolve: fixed core content lookup, not a registered phase of this
353
+ // library in v0 — intentionally a no-op here (see file header).
354
+ const postOutcome = await runPhase("post_content", path, ctx);
355
+ if (postOutcome)
356
+ return { matched: true, phase: "post_content", outcome: postOutcome };
357
+ return { matched: false };
358
+ }
359
+ /**
360
+ * Run only the `pre_content` phase for `path` (ADR-PIPE-009 Decision B / C-012).
361
+ *
362
+ * The Decision B seam: `resolve()`'s v0 shape bundles `pre_content` and
363
+ * `post_content` back-to-back with no pause for a caller's own content
364
+ * lookup in between. This additive, thin wrapper around the already-tested
365
+ * module-private `runPhase()` lets a real site route run ONLY `pre_content`
366
+ * before its own content lookup, and (separately) `runPostContentPhase` only
367
+ * after that lookup has failed — exactly ADR-039 §1's documented pipeline
368
+ * order (`pre_content -> content-resolve -> post_content`), which the single
369
+ * `resolve()` export cannot reproduce when a real content lookup must run in
370
+ * the middle. `resolve()`'s own SHAPE is UNCHANGED by this addition — this
371
+ * paragraph is about these wrapper functions existing alongside it, not about
372
+ * `resolve()`'s own throw behavior. Separately (2026-09-16, t91 F4.3):
373
+ * `resolve()` now CAN reject when a `"fail"`-policy handler throws, exactly
374
+ * like these wrappers — both go through the same `runPhase()`, which gained
375
+ * that throw path in the same change. `resolve()` has no production caller
376
+ * today (grep; only tests call it), so this is a latent property, not a
377
+ * behavior change any caller currently observes.
378
+ *
379
+ * @complexity O(h) in registered `pre_content` handlers.
380
+ */
381
+ async function runPreContentPhase({ path, ctx }, _optional = {}) {
382
+ return runPhase("pre_content", normalizePath(path), ctx);
383
+ }
384
+ /**
385
+ * Run only the `post_content` phase for `path` (ADR-PIPE-009 Decision B / C-013).
386
+ * See {@link runPreContentPhase}'s doc for the full rationale.
387
+ *
388
+ * @complexity O(h) in registered `post_content` handlers.
389
+ */
390
+ async function runPostContentPhase({ path, ctx }, _optional = {}) {
391
+ return runPhase("post_content", normalizePath(path), ctx);
392
+ }
393
+ // ---------------------------------------------------------------------------
394
+ // SlugChangeCapture slot (ADR-039 §4)
395
+ // ---------------------------------------------------------------------------
396
+ let boundSlugChangeCapture;
397
+ /**
398
+ * Bind the single `SlugChangeCapture` implementation the content write
399
+ * chokepoint calls inside a rename transaction. Per ADR-039 Round-2
400
+ * amendment 1, no slot implementation may be plugin-supplied — this function
401
+ * has no plugin-facing registration path today, only a core composition-root
402
+ * call site.
403
+ *
404
+ * Fail-closed nuance (Round-2 amendment 3), NOT enforced here: an absent
405
+ * binding is only a safe no-op before Redirects is installed, or while
406
+ * slug-preservation is disabled for the workspace. Once link-preservation is
407
+ * enabled, a slug-changing rename with no bound capture MUST fail
408
+ * `LINK_PRESERVATION_UNAVAILABLE` rather than silently skip the redirect —
409
+ * that check belongs to the content write chokepoint (which knows whether
410
+ * preservation is enabled) and to Redirects (neither exists in this repo
411
+ * yet). This library only exposes the registration slot itself.
412
+ *
413
+ * @returns a disposer that unbinds exactly THIS registration: a no-op once a later call has bound
414
+ * another implementation, so a stale composition torn down after a newer one booted cannot clear
415
+ * the newer site's capture (the same identity scoping `registerResolvePhase`'s disposers use).
416
+ */
417
+ function registerSlugChangeCapture({ impl }, _optional = {}) {
418
+ boundSlugChangeCapture = impl;
419
+ return () => {
420
+ if (boundSlugChangeCapture === impl)
421
+ boundSlugChangeCapture = undefined;
422
+ };
423
+ }
424
+ /** The currently bound `SlugChangeCapture`, or `undefined` if none is bound. */
425
+ function getSlugChangeCapture(_required, _optional = {}) {
426
+ return boundSlugChangeCapture;
427
+ }
428
+ // ---------------------------------------------------------------------------
429
+ // Test-only registry resets (deep-imported directly by this library's own
430
+ // tests; deliberately NOT exported from index.ts's public barrel)
431
+ // ---------------------------------------------------------------------------
432
+ /** Restore all module-level registries to their v0 defaults. Test use only. */
433
+ function resetRoutingRegistrationsForTests(_required, _optional = {}) {
434
+ namedRoutes.clear();
435
+ for (const [name, path] of initialNamedRoutes)
436
+ namedRoutes.set(name, path);
437
+ phaseRegistry.pre_content = [];
438
+ phaseRegistry.post_content = [];
439
+ boundSlugChangeCapture = undefined;
440
+ }
441
+ return { entryPublicPath, getNamedRoute, getSlugChangeCapture, isActive, postPublicPath, registerNamedRoute, registerResolvePhase, registerSlugChangeCapture, resolve, runPostContentPhase, runPreContentPhase, unregisterResolvePhaseOwner, urlFor, resetRoutingRegistrationsForTests };
442
+ }
443
+ //# sourceMappingURL=routing.js.map