@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
|
@@ -151,7 +151,10 @@ export class SharpImageTransformer {
|
|
|
151
151
|
fit: input.params.fit ?? "cover",
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
// JPEG cannot carry alpha; explicit white avoids libvips' black background for logos.
|
|
155
|
+
if (input.params.format === "jpeg")
|
|
156
|
+
pipeline = pipeline.flatten({ background: "#ffffff" });
|
|
157
|
+
pipeline = pipeline.toFormat(input.params.format, input.params.quality === undefined ? undefined : { quality: input.params.quality });
|
|
155
158
|
const outBuffer = await pipeline.toBuffer();
|
|
156
159
|
return {
|
|
157
160
|
bytes: new Uint8Array(outBuffer),
|
|
@@ -27,6 +27,10 @@ export declare const DEFAULT_MAX_UPLOAD_BYTES: number;
|
|
|
27
27
|
* `routes/site/media-rendition.ts`). This widens the SAME advisory,
|
|
28
28
|
* client-declared-string check item 4 above already discloses as untrusted —
|
|
29
29
|
* no new ingress hardening was added for video.
|
|
30
|
+
*
|
|
31
|
+
* Audio (owner-approved mediawidget, 2026-10-10/11) follows that same original-byte
|
|
32
|
+
* lifecycle. Only formats recognized by the shared sniffer are allowed; no image
|
|
33
|
+
* transform is requested for them.
|
|
30
34
|
*/
|
|
31
35
|
export declare const DEFAULT_ALLOWED_MIME_TYPES: ReadonlySet<string>;
|
|
32
36
|
/**
|
|
@@ -100,6 +100,10 @@ function formatMaxUploadBytesAsMb(bytes) {
|
|
|
100
100
|
* `routes/site/media-rendition.ts`). This widens the SAME advisory,
|
|
101
101
|
* client-declared-string check item 4 above already discloses as untrusted —
|
|
102
102
|
* no new ingress hardening was added for video.
|
|
103
|
+
*
|
|
104
|
+
* Audio (owner-approved mediawidget, 2026-10-10/11) follows that same original-byte
|
|
105
|
+
* lifecycle. Only formats recognized by the shared sniffer are allowed; no image
|
|
106
|
+
* transform is requested for them.
|
|
103
107
|
*/
|
|
104
108
|
export const DEFAULT_ALLOWED_MIME_TYPES = new Set([
|
|
105
109
|
"image/jpeg",
|
|
@@ -109,6 +113,10 @@ export const DEFAULT_ALLOWED_MIME_TYPES = new Set([
|
|
|
109
113
|
"image/avif",
|
|
110
114
|
"video/mp4",
|
|
111
115
|
"video/webm",
|
|
116
|
+
"audio/wav",
|
|
117
|
+
"audio/mpeg",
|
|
118
|
+
"audio/flac",
|
|
119
|
+
"audio/mp4",
|
|
112
120
|
]);
|
|
113
121
|
/**
|
|
114
122
|
* Enforces the "settable-once-from-absent" write-once rule for
|
package/dist/media/node.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Node-bound media adapters; hosts supply the filesystem and optional codecs. */
|
|
2
2
|
export { LocalFsBlobStore, type LocalFsBlobStoreDeps } from "./blob-store.fs.js";
|
|
3
3
|
export { SharpImageTransformer, ImageTransformUnavailableError, ImageSourceCorruptError } from "./image-transformer.sharp.js";
|
|
4
|
-
export { createFfmpegVideoFrameExtractor, findVideoBinaries, runLowPriorityVideoProcess, type VideoBinaries, type VideoBinaryFinder, type VideoProcessRunner, } from './video-frames.ffmpeg.js';
|
|
4
|
+
export { createFfmpegVideoFrameExtractor, createVideoExtractionLock, findVideoBinaries, runLowPriorityVideoProcess, type VideoExtractionLock, type VideoBinaries, type VideoBinaryFinder, type VideoProcessRunner, } from './video-frames.ffmpeg.js';
|
|
5
5
|
//# sourceMappingURL=node.d.ts.map
|
package/dist/media/node.js
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
export { LocalFsBlobStore } from "./blob-store.fs.js";
|
|
3
3
|
export { SharpImageTransformer, ImageTransformUnavailableError, ImageSourceCorruptError } from "./image-transformer.sharp.js";
|
|
4
4
|
// Video previews: policy/port plus a bounded optional-host codec adapter.
|
|
5
|
-
export { createFfmpegVideoFrameExtractor, findVideoBinaries, runLowPriorityVideoProcess, } from './video-frames.ffmpeg.js';
|
|
5
|
+
export { createFfmpegVideoFrameExtractor, createVideoExtractionLock, findVideoBinaries, runLowPriorityVideoProcess, } from './video-frames.ffmpeg.js';
|
|
6
6
|
//# sourceMappingURL=node.js.map
|
|
@@ -26,7 +26,7 @@ export async function registerTransform(required, _optional = {}) {
|
|
|
26
26
|
const owner = input.owner.trim();
|
|
27
27
|
if (!owner)
|
|
28
28
|
throw new TransformValidationError({ message: "owner is required" });
|
|
29
|
-
assertValidTransformParams({ format: input.params.format }, { width: input.params.width, height: input.params.height, fit: input.params.fit });
|
|
29
|
+
assertValidTransformParams({ format: input.params.format }, { width: input.params.width, height: input.params.height, fit: input.params.fit, quality: input.params.quality });
|
|
30
30
|
return withTransformRegistryLock({ key: `${input.workspaceId}:${name}`, criticalSection: async () => {
|
|
31
31
|
const existingVersions = await deps.transformRepo.listByName({ workspaceId: input.workspaceId, name });
|
|
32
32
|
const currentMaxVersion = existingVersions.reduce((max, row) => Math.max(max, row.version), 0);
|
|
@@ -41,6 +41,8 @@ export interface TransformParams {
|
|
|
41
41
|
height?: number | undefined;
|
|
42
42
|
fit?: TransformFit | undefined;
|
|
43
43
|
format: TransformFormat;
|
|
44
|
+
/** Encoder quality, 1–100; omitted preserves the codec default. */
|
|
45
|
+
quality?: number | undefined;
|
|
44
46
|
}
|
|
45
47
|
/**
|
|
46
48
|
* `transform_registry` sidecar row. Identity is
|
|
@@ -45,6 +45,9 @@ export function assertValidTransformParams(required, optional = {}) {
|
|
|
45
45
|
throw new TransformValidationError({ message: `transform params.${key} must be an integer between 1 and ${MAX_TRANSFORM_DIMENSION_PX}` });
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
if (params.quality !== undefined && (!Number.isInteger(params.quality) || params.quality < 1 || params.quality > 100)) {
|
|
49
|
+
throw new TransformValidationError({ message: "transform params.quality must be an integer between 1 and 100" });
|
|
50
|
+
}
|
|
48
51
|
if (params.fit !== undefined && !(params.width && params.height)) {
|
|
49
52
|
throw new TransformValidationError({ message: "transform params.fit requires both width and height" });
|
|
50
53
|
}
|
|
@@ -5,7 +5,10 @@ export interface VideoBinaries {
|
|
|
5
5
|
readonly ffmpeg: string;
|
|
6
6
|
readonly ffprobe: string;
|
|
7
7
|
}
|
|
8
|
-
export type VideoBinaryFinder = (required: Record<string, never>, optional:
|
|
8
|
+
export type VideoBinaryFinder = (required: Record<string, never>, optional: {
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
platform?: NodeJS.Platform;
|
|
11
|
+
}) => Promise<VideoBinaries | undefined>;
|
|
9
12
|
export type VideoProcessRunner = (required: {
|
|
10
13
|
command: string;
|
|
11
14
|
args: readonly string[];
|
|
@@ -31,7 +34,17 @@ export declare const runLowPriorityVideoProcess: ({ command, args, spawnProcess,
|
|
|
31
34
|
maxOutputBytes: number;
|
|
32
35
|
signal?: AbortSignal;
|
|
33
36
|
}) => Promise<Uint8Array>;
|
|
34
|
-
export
|
|
37
|
+
export interface VideoExtractionLock {
|
|
38
|
+
/** Synchronous acquisition prevents races before the first awaited probe. */
|
|
39
|
+
tryAcquire(required: Record<string, never>, optional: Record<string, never>): (() => void) | undefined;
|
|
40
|
+
}
|
|
41
|
+
/** A host may share this lock across its adapters to enforce a decoder budget.
|
|
42
|
+
* Returns a synchronous try-acquire port; release follows decoder cleanup. @complexity O(1). */
|
|
43
|
+
export declare function createVideoExtractionLock(_required: Record<string, never>, _optional?: Record<string, never>): VideoExtractionLock;
|
|
44
|
+
export declare function createFfmpegVideoFrameExtractor({ findBinaries, runProcess, nowMs, lock, env, platform }: {
|
|
45
|
+
lock?: VideoExtractionLock;
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
47
|
+
platform?: NodeJS.Platform;
|
|
35
48
|
findBinaries?: VideoBinaryFinder;
|
|
36
49
|
runProcess?: VideoProcessRunner;
|
|
37
50
|
nowMs?: (required: Record<string, never>, optional: Record<string, never>) => number;
|
|
@@ -13,9 +13,9 @@ class VideoProcessError extends Error {
|
|
|
13
13
|
this.reason = reason;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
-
async function executable(required, { configured } = {}) {
|
|
16
|
+
async function executable(required, { configured, env = process.env } = {}) {
|
|
17
17
|
const candidates = configured ? [configured] : [
|
|
18
|
-
...(
|
|
18
|
+
...(env['PATH'] ?? '').split(delimiter).filter(isAbsolute).map(directory => join(directory, required.name)),
|
|
19
19
|
join('/opt/homebrew/bin', required.name), join('/usr/local/bin', required.name), join('/usr/bin', required.name),
|
|
20
20
|
];
|
|
21
21
|
for (const candidate of candidates) {
|
|
@@ -32,12 +32,12 @@ async function executable(required, { configured } = {}) {
|
|
|
32
32
|
}
|
|
33
33
|
/** Discovery never executes a version/probe command. Configured paths are host configuration,
|
|
34
34
|
* never tool arguments, and all subprocesses (including ffprobe) still go through nice. */
|
|
35
|
-
export const findVideoBinaries = async (_required,
|
|
36
|
-
if (
|
|
35
|
+
export const findVideoBinaries = async (_required, { env = process.env, platform = process.platform } = {}) => {
|
|
36
|
+
if (platform === 'win32')
|
|
37
37
|
return undefined; // Never silently omit the low-priority guarantee.
|
|
38
|
-
const nice = await executable({ name: 'nice' }, {});
|
|
39
|
-
const ffmpeg = await executable({ name: 'ffmpeg' }, { configured:
|
|
40
|
-
const ffprobe = await executable({ name: 'ffprobe' }, { configured:
|
|
38
|
+
const nice = await executable({ name: 'nice' }, { env });
|
|
39
|
+
const ffmpeg = await executable({ name: 'ffmpeg' }, { configured: env['FFMPEG_PATH'], env });
|
|
40
|
+
const ffprobe = await executable({ name: 'ffprobe' }, { configured: env['FFPROBE_PATH'], env });
|
|
41
41
|
return nice && ffmpeg && ffprobe ? { nice, ffmpeg, ffprobe } : undefined;
|
|
42
42
|
};
|
|
43
43
|
/** detached creates a group including nice and its eventual executable; killing the group prevents
|
|
@@ -99,25 +99,40 @@ export const runLowPriorityVideoProcess = async ({ command, args, spawnProcess =
|
|
|
99
99
|
abort();
|
|
100
100
|
});
|
|
101
101
|
};
|
|
102
|
+
/** A host may share this lock across its adapters to enforce a decoder budget.
|
|
103
|
+
* Returns a synchronous try-acquire port; release follows decoder cleanup. @complexity O(1). */
|
|
104
|
+
export function createVideoExtractionLock(_required, _optional = {}) {
|
|
105
|
+
let extracting = false;
|
|
106
|
+
return { tryAcquire() {
|
|
107
|
+
if (extracting)
|
|
108
|
+
return undefined;
|
|
109
|
+
extracting = true;
|
|
110
|
+
let released = false;
|
|
111
|
+
return () => { if (!released) {
|
|
112
|
+
released = true;
|
|
113
|
+
extracting = false;
|
|
114
|
+
} };
|
|
115
|
+
} };
|
|
116
|
+
}
|
|
102
117
|
// Shared across ALL adapter instances: a second catalog/tenant cannot run a second ffmpeg in this
|
|
103
118
|
// process. Fail busy instead of holding arbitrary numbers of uploaded videos in a pending queue.
|
|
104
|
-
|
|
119
|
+
const defaultExtractionLock = createVideoExtractionLock({});
|
|
105
120
|
const UNAVAILABLE = 'video preview unavailable on this host (nice, ffmpeg and ffprobe are required).';
|
|
106
121
|
// Eight amortizes process/seek overhead while bounding expensive source decoders to
|
|
107
122
|
// one eighth of the 64-frame maximum. Thread caps alone do not bound decoder memory.
|
|
108
123
|
// This is a decoder-count limit, not a measured host RSS guarantee.
|
|
109
124
|
const SOURCE_BATCH_SIZE = 8;
|
|
110
125
|
const localInputFlags = ['-threads', '1', '-protocol_whitelist', 'file', '-format_whitelist', 'mov,matroska,webm'];
|
|
111
|
-
export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinaries, runProcess = runLowPriorityVideoProcess, nowMs = () => performance.now() }, _optional = {}) {
|
|
126
|
+
export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinaries, runProcess = runLowPriorityVideoProcess, nowMs = () => performance.now(), lock = defaultExtractionLock, env, platform }, _optional = {}) {
|
|
112
127
|
return { extract: async ({ bytes }, options) => {
|
|
113
128
|
if (bytes.byteLength > VIDEO_MAX_INPUT_BYTES)
|
|
114
129
|
return { ok: false, reason: 'too-large', message: `Video preview input exceeds ${VIDEO_MAX_INPUT_BYTES} bytes.` };
|
|
115
130
|
const type = sniffContentType({ bytes });
|
|
116
131
|
if (type !== 'video/mp4' && type !== 'video/webm')
|
|
117
132
|
return { ok: false, reason: 'invalid', message: 'Video preview requires stored MP4 or WebM bytes.' };
|
|
118
|
-
|
|
133
|
+
const release = lock.tryAcquire({}, {});
|
|
134
|
+
if (!release)
|
|
119
135
|
return { ok: false, reason: 'busy', message: 'Video preview is busy. Try again after the current extraction finishes.' };
|
|
120
|
-
extracting = true;
|
|
121
136
|
const deadline = nowMs({}, {}) + VIDEO_EXTRACTION_TIMEOUT_MS;
|
|
122
137
|
let directory;
|
|
123
138
|
try {
|
|
@@ -125,7 +140,7 @@ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinari
|
|
|
125
140
|
throw new VideoProcessError('canceled', 'Video preview canceled.');
|
|
126
141
|
if (options.layout !== undefined && options.layout !== 'frames' && options.layout !== 'sheet')
|
|
127
142
|
throw new VideoProcessError('invalid', 'Video layout must be frames or sheet.');
|
|
128
|
-
const binaries = await findBinaries({}, {});
|
|
143
|
+
const binaries = await findBinaries({}, { ...(env ? { env } : {}), ...(platform ? { platform } : {}) });
|
|
129
144
|
if (!binaries)
|
|
130
145
|
return { ok: false, reason: 'unavailable', message: UNAVAILABLE };
|
|
131
146
|
directory = await mkdtemp(join(tmpdir(), 'video-preview-'));
|
|
@@ -253,7 +268,7 @@ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinari
|
|
|
253
268
|
return { ok: false, reason: 'invalid', message: 'Video preview temporary files could not be removed.' };
|
|
254
269
|
}
|
|
255
270
|
finally {
|
|
256
|
-
|
|
271
|
+
release();
|
|
257
272
|
}
|
|
258
273
|
}
|
|
259
274
|
} };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dropdown navigation (`"variant":"dropdown"` menus), following the APG disclosure-navigation
|
|
3
|
+
* pattern: a branch item carries a server-rendered `<button class="jini-menu-toggle" aria-expanded
|
|
4
|
+
* aria-controls>` pointing at its child `<ul id>`, and the root `<ul>` carries `data-jini-dropdown`.
|
|
5
|
+
* No `role="menu"` and no `aria-haspopup` — those make screen readers announce a menu and expect
|
|
6
|
+
* menubar keys, which site nav should not imitate.
|
|
7
|
+
*
|
|
8
|
+
* The toggles are real buttons in the markup (not injected by JS), so without JS Tab still reaches
|
|
9
|
+
* them and a theme's `:focus-within` CSS opens the panel. {@link MENU_DROPDOWN_SCRIPT} adds
|
|
10
|
+
* `jini-menu-js` to each root so a theme can switch that fallback off once JS owns the open state
|
|
11
|
+
* (otherwise Escape could not close a panel while focus sits on its toggle).
|
|
12
|
+
*/
|
|
13
|
+
/** A branch item's toggle. Split form (default): an empty button labelled "`label` submenu" that sits
|
|
14
|
+
* after the item's link. `asLabel`: the button IS the item's label (a link-less parent), and carries
|
|
15
|
+
* `menu-item-label` so theme scripts that read `.menu-item-label` treat it as an inert parent.
|
|
16
|
+
* @complexity O(n) over `label`. */
|
|
17
|
+
export declare function renderMenuToggle({ controlsId, label }: {
|
|
18
|
+
controlsId: string;
|
|
19
|
+
label: string;
|
|
20
|
+
}, { asLabel }?: {
|
|
21
|
+
asLabel?: boolean;
|
|
22
|
+
}): string;
|
|
23
|
+
/**
|
|
24
|
+
* The inline script behind every `[data-jini-dropdown]` menu ({@link injectMenuDropdownScript} adds it
|
|
25
|
+
* once per page). State lives in `li.jini-menu-open` and the toggle's `aria-expanded`.
|
|
26
|
+
* - click on a toggle opens/closes its item (mouse, touch tap, Enter/Space); any click or focus
|
|
27
|
+
* elsewhere closes every open item that does not contain the target (siblings, click-outside,
|
|
28
|
+
* tabbing out). Closing an item closes its open descendants too.
|
|
29
|
+
* - Escape closes the nearest open item around the focus and returns focus to its toggle.
|
|
30
|
+
* - ArrowDown on a toggle opens it and focuses the submenu's first link/button; ArrowDown/ArrowUp
|
|
31
|
+
* inside a submenu move between sibling items; ArrowLeft/ArrowRight on a top-level item move between
|
|
32
|
+
* top-level items. No wrap at either end. Handled arrows `preventDefault` so the page does not scroll.
|
|
33
|
+
* Hover stays pure theme CSS and never touches `aria-expanded`.
|
|
34
|
+
*/
|
|
35
|
+
export declare const MENU_DROPDOWN_SCRIPT = "(function(){\n var roots=document.querySelectorAll('[data-jini-dropdown]');\n for(var i=0;i<roots.length;i++)roots[i].classList.add('jini-menu-js');\n function inMenu(el){return el&&el.closest&&el.closest('[data-jini-dropdown]')?el:null;}\n function toggleOf(li){return li.querySelector(':scope > .jini-menu-toggle');}\n function panelOf(li){var b=toggleOf(li);return b?document.getElementById(b.getAttribute('aria-controls')):null;}\n function first(li){return li?li.querySelector('a[href], button'):null;}\n function setOpen(li,open){\n var items=open?[li]:[li].concat(Array.prototype.slice.call(li.querySelectorAll('li.jini-menu-open')));\n for(var i=0;i<items.length;i++){items[i].classList[open?'add':'remove']('jini-menu-open');var b=toggleOf(items[i]);if(b)b.setAttribute('aria-expanded',open?'true':'false');}\n }\n function closeAll(keep){\n var open=document.querySelectorAll('[data-jini-dropdown] li.jini-menu-open');\n for(var i=0;i<open.length;i++)if(!(keep&&keep.nodeType===1&&open[i].contains(keep)))setOpen(open[i],false);\n }\n document.addEventListener('focusin',function(e){closeAll(e.target);});\n document.addEventListener('click',function(e){\n closeAll(e.target);\n var b=inMenu(e.target)?e.target.closest('.jini-menu-toggle'):null;\n if(b)setOpen(b.parentNode,b.getAttribute('aria-expanded')!=='true');\n });\n document.addEventListener('keydown',function(e){\n var t=inMenu(e.target);if(!t)return;\n if(e.key==='Escape'){var o=t.closest('li.jini-menu-open');if(o){setOpen(o,false);toggleOf(o).focus();}return;}\n var li=t.closest('li'),next=null;if(!li)return;\n var top=li.parentNode.hasAttribute('data-jini-dropdown');\n if(e.key==='ArrowDown'&&t.classList.contains('jini-menu-toggle')){closeAll(li);setOpen(li,true);var p=panelOf(li);next=p?first(p):null;}\n else if(!top&&e.key==='ArrowDown')next=first(li.nextElementSibling);\n else if(!top&&e.key==='ArrowUp')next=first(li.previousElementSibling);\n else if(top&&e.key==='ArrowRight')next=first(li.nextElementSibling);\n else if(top&&e.key==='ArrowLeft')next=first(li.previousElementSibling);\n else return;\n e.preventDefault();\n if(next)next.focus();\n });\n })();";
|
|
36
|
+
/** Adds the one inline dropdown script before `</body>` when the page carries a `data-jini-dropdown`
|
|
37
|
+
* menu and does not already have the script. @complexity O(n). */
|
|
38
|
+
export declare function injectMenuDropdownScript({ html }: {
|
|
39
|
+
html: string;
|
|
40
|
+
}, _optional?: Record<string, never>): string;
|
|
41
|
+
//# sourceMappingURL=dropdown.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dropdown navigation (`"variant":"dropdown"` menus), following the APG disclosure-navigation
|
|
3
|
+
* pattern: a branch item carries a server-rendered `<button class="jini-menu-toggle" aria-expanded
|
|
4
|
+
* aria-controls>` pointing at its child `<ul id>`, and the root `<ul>` carries `data-jini-dropdown`.
|
|
5
|
+
* No `role="menu"` and no `aria-haspopup` — those make screen readers announce a menu and expect
|
|
6
|
+
* menubar keys, which site nav should not imitate.
|
|
7
|
+
*
|
|
8
|
+
* The toggles are real buttons in the markup (not injected by JS), so without JS Tab still reaches
|
|
9
|
+
* them and a theme's `:focus-within` CSS opens the panel. {@link MENU_DROPDOWN_SCRIPT} adds
|
|
10
|
+
* `jini-menu-js` to each root so a theme can switch that fallback off once JS owns the open state
|
|
11
|
+
* (otherwise Escape could not close a panel while focus sits on its toggle).
|
|
12
|
+
*/
|
|
13
|
+
const SCRIPT_MARKER = 'data-jini-menu-dropdown-script';
|
|
14
|
+
function escapeAttribute(value) {
|
|
15
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
16
|
+
}
|
|
17
|
+
/** A branch item's toggle. Split form (default): an empty button labelled "`label` submenu" that sits
|
|
18
|
+
* after the item's link. `asLabel`: the button IS the item's label (a link-less parent), and carries
|
|
19
|
+
* `menu-item-label` so theme scripts that read `.menu-item-label` treat it as an inert parent.
|
|
20
|
+
* @complexity O(n) over `label`. */
|
|
21
|
+
export function renderMenuToggle({ controlsId, label }, { asLabel = false } = {}) {
|
|
22
|
+
const id = escapeAttribute(controlsId);
|
|
23
|
+
const text = escapeAttribute(label);
|
|
24
|
+
return asLabel
|
|
25
|
+
? `<button type="button" class="menu-item-label jini-menu-toggle" aria-expanded="false" aria-controls="${id}">${text}</button>`
|
|
26
|
+
: `<button type="button" class="jini-menu-toggle" aria-expanded="false" aria-controls="${id}" aria-label="${text} submenu"></button>`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The inline script behind every `[data-jini-dropdown]` menu ({@link injectMenuDropdownScript} adds it
|
|
30
|
+
* once per page). State lives in `li.jini-menu-open` and the toggle's `aria-expanded`.
|
|
31
|
+
* - click on a toggle opens/closes its item (mouse, touch tap, Enter/Space); any click or focus
|
|
32
|
+
* elsewhere closes every open item that does not contain the target (siblings, click-outside,
|
|
33
|
+
* tabbing out). Closing an item closes its open descendants too.
|
|
34
|
+
* - Escape closes the nearest open item around the focus and returns focus to its toggle.
|
|
35
|
+
* - ArrowDown on a toggle opens it and focuses the submenu's first link/button; ArrowDown/ArrowUp
|
|
36
|
+
* inside a submenu move between sibling items; ArrowLeft/ArrowRight on a top-level item move between
|
|
37
|
+
* top-level items. No wrap at either end. Handled arrows `preventDefault` so the page does not scroll.
|
|
38
|
+
* Hover stays pure theme CSS and never touches `aria-expanded`.
|
|
39
|
+
*/
|
|
40
|
+
export const MENU_DROPDOWN_SCRIPT = `(function(){
|
|
41
|
+
var roots=document.querySelectorAll('[data-jini-dropdown]');
|
|
42
|
+
for(var i=0;i<roots.length;i++)roots[i].classList.add('jini-menu-js');
|
|
43
|
+
function inMenu(el){return el&&el.closest&&el.closest('[data-jini-dropdown]')?el:null;}
|
|
44
|
+
function toggleOf(li){return li.querySelector(':scope > .jini-menu-toggle');}
|
|
45
|
+
function panelOf(li){var b=toggleOf(li);return b?document.getElementById(b.getAttribute('aria-controls')):null;}
|
|
46
|
+
function first(li){return li?li.querySelector('a[href], button'):null;}
|
|
47
|
+
function setOpen(li,open){
|
|
48
|
+
var items=open?[li]:[li].concat(Array.prototype.slice.call(li.querySelectorAll('li.jini-menu-open')));
|
|
49
|
+
for(var i=0;i<items.length;i++){items[i].classList[open?'add':'remove']('jini-menu-open');var b=toggleOf(items[i]);if(b)b.setAttribute('aria-expanded',open?'true':'false');}
|
|
50
|
+
}
|
|
51
|
+
function closeAll(keep){
|
|
52
|
+
var open=document.querySelectorAll('[data-jini-dropdown] li.jini-menu-open');
|
|
53
|
+
for(var i=0;i<open.length;i++)if(!(keep&&keep.nodeType===1&&open[i].contains(keep)))setOpen(open[i],false);
|
|
54
|
+
}
|
|
55
|
+
document.addEventListener('focusin',function(e){closeAll(e.target);});
|
|
56
|
+
document.addEventListener('click',function(e){
|
|
57
|
+
closeAll(e.target);
|
|
58
|
+
var b=inMenu(e.target)?e.target.closest('.jini-menu-toggle'):null;
|
|
59
|
+
if(b)setOpen(b.parentNode,b.getAttribute('aria-expanded')!=='true');
|
|
60
|
+
});
|
|
61
|
+
document.addEventListener('keydown',function(e){
|
|
62
|
+
var t=inMenu(e.target);if(!t)return;
|
|
63
|
+
if(e.key==='Escape'){var o=t.closest('li.jini-menu-open');if(o){setOpen(o,false);toggleOf(o).focus();}return;}
|
|
64
|
+
var li=t.closest('li'),next=null;if(!li)return;
|
|
65
|
+
var top=li.parentNode.hasAttribute('data-jini-dropdown');
|
|
66
|
+
if(e.key==='ArrowDown'&&t.classList.contains('jini-menu-toggle')){closeAll(li);setOpen(li,true);var p=panelOf(li);next=p?first(p):null;}
|
|
67
|
+
else if(!top&&e.key==='ArrowDown')next=first(li.nextElementSibling);
|
|
68
|
+
else if(!top&&e.key==='ArrowUp')next=first(li.previousElementSibling);
|
|
69
|
+
else if(top&&e.key==='ArrowRight')next=first(li.nextElementSibling);
|
|
70
|
+
else if(top&&e.key==='ArrowLeft')next=first(li.previousElementSibling);
|
|
71
|
+
else return;
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
if(next)next.focus();
|
|
74
|
+
});
|
|
75
|
+
})();`;
|
|
76
|
+
/** Adds the one inline dropdown script before `</body>` when the page carries a `data-jini-dropdown`
|
|
77
|
+
* menu and does not already have the script. @complexity O(n). */
|
|
78
|
+
export function injectMenuDropdownScript({ html }, _optional = {}) {
|
|
79
|
+
if (!html.includes('data-jini-dropdown') || html.includes(SCRIPT_MARKER))
|
|
80
|
+
return html;
|
|
81
|
+
const script = `<script ${SCRIPT_MARKER}>${MENU_DROPDOWN_SCRIPT}</script>`;
|
|
82
|
+
const bodyClose = html.lastIndexOf('</body>');
|
|
83
|
+
return bodyClose === -1 ? html + script : html.slice(0, bodyClose) + script + html.slice(bodyClose);
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=dropdown.js.map
|
|
@@ -30,6 +30,8 @@ export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndClon
|
|
|
30
30
|
export { MAX_MENU_HTML_LENGTH, isMenuHtmlAuthoring, menuHtmlInputProblem, mergeMenuHtml, type MenuHtmlAuthoring, } from "./menu-html.js";
|
|
31
31
|
export { resolveForLocation, resolveMenuDoc, type ResolveTargetHrefFn, type ResolvedTargetHref, type ResolveForLocationDeps, type ResolveForLocationServiceInput, type ResolveForLocationRequired, type ResolveForLocationOptional, } from "./resolver.js";
|
|
32
32
|
export { rebuildNavLocationBindings, type RebuildNavLocationBindingsDeps, type RebuildNavLocationBindingsResult, } from "./reconcile.js";
|
|
33
|
+
/** Dropdown menus (2026-10-11): toggle markup + the inline keyboard/touch script (see `dropdown.ts`). */
|
|
34
|
+
export { renderMenuToggle, injectMenuDropdownScript, MENU_DROPDOWN_SCRIPT } from "./dropdown.js";
|
|
33
35
|
/** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
|
|
34
36
|
export { menusAgentToolCatalog, } from "./agent-tools.js";
|
|
35
37
|
/**
|
package/dist/navigation/index.js
CHANGED
|
@@ -21,6 +21,8 @@ export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndClon
|
|
|
21
21
|
export { MAX_MENU_HTML_LENGTH, isMenuHtmlAuthoring, menuHtmlInputProblem, mergeMenuHtml, } from "./menu-html.js";
|
|
22
22
|
export { resolveForLocation, resolveMenuDoc, } from "./resolver.js";
|
|
23
23
|
export { rebuildNavLocationBindings, } from "./reconcile.js";
|
|
24
|
+
/** Dropdown menus (2026-10-11): toggle markup + the inline keyboard/touch script (see `dropdown.ts`). */
|
|
25
|
+
export { renderMenuToggle, injectMenuDropdownScript, MENU_DROPDOWN_SCRIPT } from "./dropdown.js";
|
|
24
26
|
/** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
|
|
25
27
|
export { menusAgentToolCatalog, } from "./agent-tools.js";
|
|
26
28
|
/**
|
|
@@ -55,6 +55,8 @@ export interface RedirectSlugChangeCaptureReadDeps {
|
|
|
55
55
|
}, optional?: Record<string, never>): Promise<RedirectRecord | null>;
|
|
56
56
|
}
|
|
57
57
|
export interface RedirectSlugChangeCaptureDeps {
|
|
58
|
+
/** Optional for existing hosts; route-owning hosts supply their reserved segment policy. */
|
|
59
|
+
reservedSegments?: ReadonlySet<string>;
|
|
58
60
|
repo: RedirectSlugChangeCaptureReadDeps;
|
|
59
61
|
db: RedirectDbHandle;
|
|
60
62
|
clock: ClockPort;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { nowIso as clockNowIso } from "@jini-ai/core/primitives";
|
|
2
|
+
import { assertRedirectSourceAllowed } from "./redirects.js";
|
|
2
3
|
import { insertRedirectAndRevision } from "./ports.internal.js";
|
|
3
4
|
/** Default priority/statusCode/override for an auto-captured rule (behavior.spec.md §3 analog). */
|
|
4
5
|
const AUTO_CAPTURE_STATUS_CODE = 301;
|
|
@@ -21,6 +22,9 @@ export class RedirectSlugChangeCapture {
|
|
|
21
22
|
* @complexity O(1) — one read (idempotency check) + two writes.
|
|
22
23
|
*/
|
|
23
24
|
async onSlugChange(input, _optional = {}) {
|
|
25
|
+
if (this.deps.reservedSegments) {
|
|
26
|
+
assertRedirectSourceAllowed({ fromPattern: input.oldPath, reservedSegments: this.deps.reservedSegments });
|
|
27
|
+
}
|
|
24
28
|
const existing = await this.deps.repo.lookupExact({
|
|
25
29
|
workspaceId: input.workspaceId,
|
|
26
30
|
path: input.oldPath,
|
|
@@ -100,6 +100,14 @@ export interface RedirectsWriteDeps {
|
|
|
100
100
|
idGen: IdGeneratorPort;
|
|
101
101
|
outbox: OutboxPort;
|
|
102
102
|
}
|
|
103
|
+
/** Refuse source patterns whose decoded first segment belongs to a host route.
|
|
104
|
+
* Uses the same decoding and case folding as the target gate; wildcard tails remain patterns.
|
|
105
|
+
* @throws RedirectValidationError before any write. @complexity O(n) in the pattern length.
|
|
106
|
+
*/
|
|
107
|
+
export declare function assertRedirectSourceAllowed({ fromPattern, reservedSegments }: {
|
|
108
|
+
fromPattern: string;
|
|
109
|
+
reservedSegments: ReadonlySet<string>;
|
|
110
|
+
}, _optional?: Record<string, never>): void;
|
|
103
111
|
/**
|
|
104
112
|
* The site-relative half of the write gate: routing's structural + reserved-path verdict, then the
|
|
105
113
|
* redirect oracle's own raw-character rule. The read path hands a relative location to that oracle
|
|
@@ -144,6 +144,16 @@ function siteRelativeRefusalReason(check) {
|
|
|
144
144
|
return "it contains, or decodes to, a backslash or a control character";
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
/** Refuse source patterns whose decoded first segment belongs to a host route.
|
|
148
|
+
* Uses the same decoding and case folding as the target gate; wildcard tails remain patterns.
|
|
149
|
+
* @throws RedirectValidationError before any write. @complexity O(n) in the pattern length.
|
|
150
|
+
*/
|
|
151
|
+
export function assertRedirectSourceAllowed({ fromPattern, reservedSegments }, _optional = {}) {
|
|
152
|
+
const verdict = checkSiteRelativeTarget({ raw: fromPattern, reservedSegments }, {});
|
|
153
|
+
if (verdict.kind === "reserved") {
|
|
154
|
+
throw new RedirectValidationError(`fromPattern '${fromPattern}' is reserved for the '${verdict.surface}' surface`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
147
157
|
/**
|
|
148
158
|
* The site-relative half of the write gate: routing's structural + reserved-path verdict, then the
|
|
149
159
|
* redirect oracle's own raw-character rule. The read path hands a relative location to that oracle
|
|
@@ -257,6 +267,7 @@ export async function checkRedirectFieldsWouldWrite({ deps, fields }, { excludeI
|
|
|
257
267
|
validateToTargetLength(fields.toTarget);
|
|
258
268
|
validateStatusCode(fields.statusCode);
|
|
259
269
|
validatePriority(fields.priority);
|
|
270
|
+
assertRedirectSourceAllowed({ fromPattern: fields.fromPattern, reservedSegments: deps.reservedSegments });
|
|
260
271
|
await assertTargetAllowed(deps.originRegistry, fields.workspaceId, fields.toTarget, deps.reservedSegments);
|
|
261
272
|
const finalTarget = await resolveCollapsedTarget(deps.repo, fields.workspaceId, fields.fromPattern, fields.toTarget);
|
|
262
273
|
if (finalTarget !== fields.toTarget) {
|
|
@@ -300,6 +311,7 @@ export async function createRedirect(required, _optional = {}) {
|
|
|
300
311
|
validateStatusCode(input.statusCode);
|
|
301
312
|
const priority = input.priority ?? DEFAULT_PRIORITY;
|
|
302
313
|
validatePriority(priority);
|
|
314
|
+
assertRedirectSourceAllowed({ fromPattern: input.fromPattern, reservedSegments: deps.reservedSegments });
|
|
303
315
|
await assertTargetAllowed(deps.originRegistry, input.workspaceId, input.toTarget, deps.reservedSegments);
|
|
304
316
|
const finalTarget = await resolveCollapsedTarget(deps.repo, input.workspaceId, input.fromPattern, input.toTarget);
|
|
305
317
|
if (finalTarget !== input.toTarget) {
|
|
@@ -411,6 +423,7 @@ async function validateUpdateFields(deps, workspaceId, fields, existingId) {
|
|
|
411
423
|
validateStatusCode(fields.statusCode);
|
|
412
424
|
validateStatus(fields.status);
|
|
413
425
|
validatePriority(fields.priority);
|
|
426
|
+
assertRedirectSourceAllowed({ fromPattern: fields.fromPattern, reservedSegments: deps.reservedSegments });
|
|
414
427
|
await assertTargetAllowed(deps.originRegistry, workspaceId, fields.toTarget, deps.reservedSegments);
|
|
415
428
|
const finalTarget = await resolveCollapsedTarget(deps.repo, workspaceId, fields.fromPattern, fields.toTarget);
|
|
416
429
|
if (finalTarget !== fields.toTarget) {
|
|
@@ -442,6 +455,7 @@ export async function updateRedirect(required, _optional = {}) {
|
|
|
442
455
|
if (!isRestoreOnlyUpdate(input)) {
|
|
443
456
|
assertEntityLive({ entityType: "redirect", entityId: input.id, state: "trashed" });
|
|
444
457
|
}
|
|
458
|
+
assertRedirectSourceAllowed({ fromPattern: existing.fromPattern, reservedSegments: deps.reservedSegments });
|
|
445
459
|
const outcome = await deps.restore({
|
|
446
460
|
workspaceId: input.workspaceId,
|
|
447
461
|
id: input.id,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createRouting, RouteResolutionError } from './routing.js';
|
|
2
|
+
export type { CreateRoutingRequired, CreateRoutingOptional, EmptyOptions, Routing, IsActiveOptional, IsActiveRequired, RegisterNamedRouteInput, RegisterResolvePhaseOptions, ResolveInput, ResolveOptional, ResolveRequired, UrlForOptional, UrlForRequired } from './routing.js';
|
|
3
|
+
export type { RouteResolverDeps, RoutingContentRecord, RoutingContentReadPort, RoutingPorts } from './ports.js';
|
|
4
|
+
export type { EntryRefTarget, RouteRefTarget, RouteResolveContext, RouteResolvePhaseHandler, RouteResolvePhaseName, RouteResolvePhaseOutcome, RouteResolveResult, RouteTarget, RouteTargetKind, RouteUrl, SlugChangeCapture, SlugChangeCaptureInput, TermRefTarget, UrlTarget } from './types.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,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
|