@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.
- package/CHANGELOG.md +4 -0
- 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 +5 -3
- package/dist/media/media-service.js +10 -26
- package/dist/media/node.d.ts +1 -1
- package/dist/media/node.js +1 -1
- package/dist/media/slug.d.ts +8 -0
- package/dist/media/slug.js +32 -0
- 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 +100 -7
|
@@ -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),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { isValidMediaSlugFormat } from "./slug.js";
|
|
1
2
|
import type { Clock, IdGenerator, UUID } from "@jini-ai/core/primitives";
|
|
2
3
|
import { MediaConflictError, MediaNotFoundError, MediaSourceImmutableError, MediaStillReferencedError, MediaValidationError, type MediaRecord, type MediaSource } from "./types.js";
|
|
3
4
|
import type { AssetBlobRepoPort, AssetRenditionRepoPort, BlobStorePort, MediaRepoPort } from "./ports.js";
|
|
@@ -27,6 +28,10 @@ export declare const DEFAULT_MAX_UPLOAD_BYTES: number;
|
|
|
27
28
|
* `routes/site/media-rendition.ts`). This widens the SAME advisory,
|
|
28
29
|
* client-declared-string check item 4 above already discloses as untrusted —
|
|
29
30
|
* no new ingress hardening was added for video.
|
|
31
|
+
*
|
|
32
|
+
* Audio (owner-approved mediawidget, 2026-10-10/11) follows that same original-byte
|
|
33
|
+
* lifecycle. Only formats recognized by the shared sniffer are allowed; no image
|
|
34
|
+
* transform is requested for them.
|
|
30
35
|
*/
|
|
31
36
|
export declare const DEFAULT_ALLOWED_MIME_TYPES: ReadonlySet<string>;
|
|
32
37
|
/**
|
|
@@ -49,9 +54,6 @@ export declare function resolveWriteOnceSource(required: {
|
|
|
49
54
|
existing: MediaSource | undefined;
|
|
50
55
|
requestedSha256: string;
|
|
51
56
|
}, _optional?: Record<string, never>): MediaSource;
|
|
52
|
-
export declare function isValidMediaSlugFormat(requiredArgs: {
|
|
53
|
-
slug: string;
|
|
54
|
-
}, optionalArgs?: Record<string, never>): boolean;
|
|
55
57
|
export interface UploadMediaInput {
|
|
56
58
|
workspaceId: UUID;
|
|
57
59
|
bytes: Uint8Array;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isValidMediaSlugFormat } from "./slug.js";
|
|
2
|
+
export { isValidMediaSlugFormat } from "./slug.js";
|
|
1
3
|
import { createSystemClock } from "@jini-ai/core/primitives";
|
|
2
4
|
import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
|
|
3
5
|
/**
|
|
@@ -100,6 +102,10 @@ function formatMaxUploadBytesAsMb(bytes) {
|
|
|
100
102
|
* `routes/site/media-rendition.ts`). This widens the SAME advisory,
|
|
101
103
|
* client-declared-string check item 4 above already discloses as untrusted —
|
|
102
104
|
* no new ingress hardening was added for video.
|
|
105
|
+
*
|
|
106
|
+
* Audio (owner-approved mediawidget, 2026-10-10/11) follows that same original-byte
|
|
107
|
+
* lifecycle. Only formats recognized by the shared sniffer are allowed; no image
|
|
108
|
+
* transform is requested for them.
|
|
103
109
|
*/
|
|
104
110
|
export const DEFAULT_ALLOWED_MIME_TYPES = new Set([
|
|
105
111
|
"image/jpeg",
|
|
@@ -109,6 +115,10 @@ export const DEFAULT_ALLOWED_MIME_TYPES = new Set([
|
|
|
109
115
|
"image/avif",
|
|
110
116
|
"video/mp4",
|
|
111
117
|
"video/webm",
|
|
118
|
+
"audio/wav",
|
|
119
|
+
"audio/mpeg",
|
|
120
|
+
"audio/flac",
|
|
121
|
+
"audio/mp4",
|
|
112
122
|
]);
|
|
113
123
|
/**
|
|
114
124
|
* Enforces the "settable-once-from-absent" write-once rule for
|
|
@@ -137,28 +147,6 @@ function deriveTitleFromFilename(filename) {
|
|
|
137
147
|
const base = filename.trim().replace(/\.[^./\\]+$/, "");
|
|
138
148
|
return base.trim() || "Untitled";
|
|
139
149
|
}
|
|
140
|
-
/** Shared slug-format rule (2026-09-07) — same shape as `post`'s `SLUG_FORMAT_PATTERN`: lowercase
|
|
141
|
-
* letters, digits, and dashes only. Media slugs have no reserved-word list (`post`'s `admin`/`api`
|
|
142
|
-
* exclusions exist because a post slug can become a literal URL path segment a route dispatches
|
|
143
|
-
* on; a media slug is only ever a lookup key, never a route itself, so that hazard doesn't apply). */
|
|
144
|
-
const MEDIA_SLUG_FORMAT_PATTERN = /^[a-z0-9-]+$/;
|
|
145
|
-
/**
|
|
146
|
-
* Slugs that spell a UUID are refused outright (2026-09-07 fix).
|
|
147
|
-
*
|
|
148
|
-
* A lowercase UUID is `[a-z0-9-]+` character for character, so {@link MEDIA_SLUG_FORMAT_PATTERN}
|
|
149
|
-
* happily accepted one — and {@link findMediaByIdOrSlug} used to resolve SLUG FIRST, so pointing one
|
|
150
|
-
* asset's slug at another asset's `id` silently took over every reference already authored against
|
|
151
|
-
* that id (every `/m/{id}/…` URL a host's renderer has ever emitted for the victim). That resolution
|
|
152
|
-
* order is now id-first, which closes the hijack for rows that already carry such a slug; this rule
|
|
153
|
-
* is the write-path half — a slug that can only ever shadow an id is not a slug anyone wants, so it
|
|
154
|
-
* is refused where a human can still see why rather than silently shadowed at read time.
|
|
155
|
-
*
|
|
156
|
-
* Shape-based, not a lookup: it rejects EVERY UUID-shaped value, not only ones that currently
|
|
157
|
-
* collide, so a slug cannot become a hijack later when some future upload happens to mint that id.
|
|
158
|
-
* Ordinary hex-and-dash slugs (`abc-123-def`, `2026-09-07-launch-clip`) are unaffected — nothing but
|
|
159
|
-
* the exact 8-4-4-4-12 hex grouping matches.
|
|
160
|
-
*/
|
|
161
|
-
const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
162
150
|
/** Shared slug max-length rule (2026-09-07) — same bound as `post.ts`'s `MAX_SLUG_LENGTH`, reused
|
|
163
151
|
* rather than inventing a second number. `post.ts` only enforces this on its EXPLICIT/caller-
|
|
164
152
|
* supplied slug path (`resolveExplicitSlug`, reject-with-error); its own title-derived path is
|
|
@@ -169,10 +157,6 @@ const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9
|
|
|
169
157
|
* human for a shorter value — silently truncating an internally-derived value is the same
|
|
170
158
|
* "coerce, don't fail" convention `deriveTitleFromFilename`/`slugifyMediaTitle` already use. */
|
|
171
159
|
const MEDIA_MAX_SLUG_LENGTH = 120;
|
|
172
|
-
export function isValidMediaSlugFormat(requiredArgs, optionalArgs = {}) {
|
|
173
|
-
const { slug } = requiredArgs;
|
|
174
|
-
return MEDIA_SLUG_FORMAT_PATTERN.test(slug) && !MEDIA_SLUG_UUID_SHAPE_PATTERN.test(slug);
|
|
175
|
-
}
|
|
176
160
|
/** Truncates a slug candidate to `maxLength`, stripping a trailing dash the cut can introduce (so a
|
|
177
161
|
* truncation never leaves a slug ending mid-word with a dangling "-"), falling back to `"untitled"`
|
|
178
162
|
* if nothing legible survives (only reachable when `maxLength` itself is tiny, e.g. while reserving
|
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
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Return whether a media lookup slug has the accepted shape, without I/O.
|
|
2
|
+
* @complexity O(n) time in the slug length, O(1) space.
|
|
3
|
+
* @example isValidMediaSlugFormat({ slug: "summer-photo" }); // true
|
|
4
|
+
*/
|
|
5
|
+
export declare function isValidMediaSlugFormat(requiredArgs: {
|
|
6
|
+
slug: string;
|
|
7
|
+
}, optionalArgs?: Record<string, never>): boolean;
|
|
8
|
+
//# sourceMappingURL=slug.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** @file Pure media slug policy; writes and public URL rendering share the same format and UUID-shadowing rule. */
|
|
2
|
+
/** Shared slug-format rule (2026-09-07) — same shape as `post`'s `SLUG_FORMAT_PATTERN`: lowercase
|
|
3
|
+
* letters, digits, and dashes only. Media slugs have no reserved-word list (`post`'s `admin`/`api`
|
|
4
|
+
* exclusions exist because a post slug can become a literal URL path segment a route dispatches
|
|
5
|
+
* on; a media slug is only ever a lookup key, never a route itself, so that hazard doesn't apply). */
|
|
6
|
+
const MEDIA_SLUG_FORMAT_PATTERN = /^[a-z0-9-]+$/;
|
|
7
|
+
/**
|
|
8
|
+
* Slugs that spell a UUID are refused outright (2026-09-07 fix).
|
|
9
|
+
*
|
|
10
|
+
* A lowercase UUID is `[a-z0-9-]+` character for character, so {@link MEDIA_SLUG_FORMAT_PATTERN}
|
|
11
|
+
* happily accepted one — and {@link findMediaByIdOrSlug} used to resolve SLUG FIRST, so pointing one
|
|
12
|
+
* asset's slug at another asset's `id` silently took over every reference already authored against
|
|
13
|
+
* that id (every `/m/{id}/…` URL a host's renderer has ever emitted for the victim). That resolution
|
|
14
|
+
* order is now id-first, which closes the hijack for rows that already carry such a slug; this rule
|
|
15
|
+
* is the write-path half — a slug that can only ever shadow an id is not a slug anyone wants, so it
|
|
16
|
+
* is refused where a human can still see why rather than silently shadowed at read time.
|
|
17
|
+
*
|
|
18
|
+
* Shape-based, not a lookup: it rejects EVERY UUID-shaped value, not only ones that currently
|
|
19
|
+
* collide, so a slug cannot become a hijack later when some future upload happens to mint that id.
|
|
20
|
+
* Ordinary hex-and-dash slugs (`abc-123-def`, `2026-09-07-launch-clip`) are unaffected — nothing but
|
|
21
|
+
* the exact 8-4-4-4-12 hex grouping matches.
|
|
22
|
+
*/
|
|
23
|
+
const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
24
|
+
/** Return whether a media lookup slug has the accepted shape, without I/O.
|
|
25
|
+
* @complexity O(n) time in the slug length, O(1) space.
|
|
26
|
+
* @example isValidMediaSlugFormat({ slug: "summer-photo" }); // true
|
|
27
|
+
*/
|
|
28
|
+
export function isValidMediaSlugFormat(requiredArgs, optionalArgs = {}) {
|
|
29
|
+
const { slug } = requiredArgs;
|
|
30
|
+
return MEDIA_SLUG_FORMAT_PATTERN.test(slug) && !MEDIA_SLUG_UUID_SHAPE_PATTERN.test(slug);
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=slug.js.map
|
|
@@ -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
|