@jini-ai/cms 0.5.5 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/consent/express/index.d.ts +53 -0
- package/dist/consent/express/index.js +159 -0
- package/dist/consent/express/response.d.ts +21 -0
- package/dist/consent/express/response.js +89 -0
- package/dist/consent/html/client.d.ts +6 -0
- package/dist/consent/html/client.js +135 -0
- package/dist/consent/html/gate.d.ts +29 -0
- package/dist/consent/html/gate.js +104 -0
- package/dist/consent/html/index.d.ts +4 -0
- package/dist/consent/html/index.js +3 -0
- package/dist/consent/html/render.d.ts +27 -0
- package/dist/consent/html/render.js +62 -0
- package/dist/consent/html/static.d.ts +7 -0
- package/dist/consent/html/static.js +25 -0
- package/dist/consent/html/styles.d.ts +3 -0
- package/dist/consent/html/styles.js +22 -0
- package/dist/consent/index.d.ts +3 -0
- package/dist/consent/index.js +3 -0
- package/dist/consent/node/index.d.ts +47 -0
- package/dist/consent/node/index.js +169 -0
- package/dist/consent/policy.d.ts +47 -0
- package/dist/consent/policy.js +62 -0
- package/dist/consent/types.d.ts +40 -0
- package/dist/consent/types.js +5 -0
- package/dist/content-types/agent-tools.js +1 -1
- package/dist/content-types/tool-registrations.d.ts +2 -0
- package/dist/content-types/tool-registrations.js +1 -0
- package/dist/content-types/write-service.d.ts +2 -0
- package/dist/content-types/write-service.js +3 -0
- package/dist/forms/descriptor-rules.d.ts +9 -0
- package/dist/forms/descriptor-rules.js +112 -0
- package/dist/forms/email-format.d.ts +7 -0
- package/dist/forms/email-format.js +11 -0
- package/dist/forms/express/handler.js +4 -8
- package/dist/forms/field-registry.d.ts +193 -0
- package/dist/forms/field-registry.js +231 -0
- package/dist/forms/forms.d.ts +8 -5
- package/dist/forms/forms.js +29 -46
- package/dist/forms/html/html-authoring.js +63 -13
- package/dist/forms/html/html-submission-adapter.d.ts +5 -5
- package/dist/forms/html/html-submission-adapter.js +6 -13
- package/dist/forms/html-render.d.ts +10 -3
- package/dist/forms/html-render.js +116 -14
- package/dist/forms/index.d.ts +1 -0
- package/dist/forms/index.js +1 -0
- package/dist/forms/repo.memory.js +4 -4
- package/dist/forms/submission-attempts.d.ts +1 -1
- package/dist/forms/submission-attempts.js +13 -2
- package/dist/forms/submission-email-validation.js +2 -4
- package/dist/forms/submit-service.d.ts +3 -0
- package/dist/forms/submit-service.js +38 -5
- package/dist/forms/types.d.ts +50 -2
- package/dist/listing/batch.d.ts +53 -0
- package/dist/listing/batch.js +57 -0
- package/dist/listing/html.d.ts +72 -0
- package/dist/listing/html.js +109 -0
- package/dist/listing/index.d.ts +5 -0
- package/dist/listing/index.js +5 -0
- package/dist/listing/paths.d.ts +32 -0
- package/dist/listing/paths.js +37 -0
- package/dist/listing/see-more-script.d.ts +16 -0
- package/dist/listing/see-more-script.js +62 -0
- package/dist/media/content-type-sniffer.d.ts +1 -1
- package/dist/media/content-type-sniffer.js +15 -0
- package/dist/media/image-transformer.sharp.js +4 -1
- package/dist/media/media-service.d.ts +4 -0
- package/dist/media/media-service.js +8 -0
- package/dist/media/node.d.ts +1 -1
- package/dist/media/node.js +1 -1
- package/dist/media/transform-registry.js +1 -1
- package/dist/media/transform-types.d.ts +2 -0
- package/dist/media/transform-types.js +3 -0
- package/dist/media/video-frames.ffmpeg.d.ts +15 -2
- package/dist/media/video-frames.ffmpeg.js +28 -13
- package/dist/navigation/dropdown.d.ts +41 -0
- package/dist/navigation/dropdown.js +85 -0
- package/dist/navigation/index.d.ts +2 -0
- package/dist/navigation/index.js +2 -0
- package/dist/redirects/capture.d.ts +2 -0
- package/dist/redirects/capture.js +4 -0
- package/dist/redirects/redirects.d.ts +8 -0
- package/dist/redirects/redirects.js +14 -0
- package/dist/routing/index.d.ts +5 -0
- package/dist/routing/index.js +2 -0
- package/dist/routing/ports.d.ts +51 -0
- package/dist/routing/ports.js +2 -0
- package/dist/routing/routing.d.ts +138 -0
- package/dist/routing/routing.js +443 -0
- package/dist/routing/types.d.ts +164 -0
- package/dist/routing/types.js +2 -0
- package/dist/seo/media.d.ts +23 -0
- package/dist/seo/media.js +30 -5
- package/dist/seo/page-head.js +8 -1
- package/dist/seo/seo.d.ts +2 -0
- package/dist/seo/seo.js +11 -6
- package/dist/seo/types.d.ts +8 -0
- package/dist/seo/xml-escape.d.ts +1 -3
- package/dist/seo/xml-escape.js +1 -6
- package/dist/widgets/concurrency.d.ts +27 -1
- package/dist/widgets/concurrency.js +12 -23
- package/dist/widgets/config-validation.js +6 -0
- package/dist/widgets/gallery-resolver.d.ts +23 -0
- package/dist/widgets/gallery-resolver.js +80 -0
- package/dist/widgets/html/gallery-render.d.ts +24 -0
- package/dist/widgets/html/gallery-render.js +65 -0
- package/dist/widgets/html/html-embeds.d.ts +4 -0
- package/dist/widgets/html/html-embeds.js +7 -1
- package/dist/widgets/html/index.d.ts +3 -0
- package/dist/widgets/html/index.js +3 -0
- package/dist/widgets/html/server-fragment.d.ts +9 -0
- package/dist/widgets/html/server-fragment.js +38 -0
- package/dist/widgets/markers/gallery.d.ts +50 -0
- package/dist/widgets/markers/gallery.js +164 -0
- package/dist/widgets/markers/html-attributes.d.ts +6 -1
- package/dist/widgets/markers/html-attributes.js +24 -9
- package/dist/widgets/markers/index.d.ts +2 -0
- package/dist/widgets/markers/index.js +2 -0
- package/dist/widgets/markers/marker.d.ts +4 -0
- package/dist/widgets/markers/marker.js +6 -0
- package/dist/widgets/markers/media-reference.d.ts +11 -0
- package/dist/widgets/markers/media-reference.js +11 -0
- package/dist/widgets/page-embed-types.d.ts +1 -1
- package/dist/widgets/page-embed-types.js +1 -1
- package/dist/widgets/registry.d.ts +144 -10
- package/dist/widgets/registry.js +40 -0
- package/dist/widgets/resolver-service.d.ts +10 -0
- package/dist/widgets/resolver-service.js +73 -29
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
- package/dist/widgets/resolvers/gallery.d.ts +12 -0
- package/dist/widgets/resolvers/gallery.js +33 -0
- package/dist/widgets/resolvers/index.d.ts +1 -0
- package/dist/widgets/resolvers/index.js +1 -0
- package/dist/widgets/types.d.ts +3 -1
- package/package.json +51 -6
|
@@ -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
|