@texuli/deck 0.0.0-stage → 0.5.0
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/LICENSE +21 -0
- package/README.md +183 -2
- package/dist/authoring/builder.d.ts +351 -0
- package/dist/authoring/builder.js +64 -0
- package/dist/authoring/index.d.ts +6 -0
- package/dist/authoring/index.js +6 -0
- package/dist/authoring/lint.d.ts +36 -0
- package/dist/authoring/lint.js +325 -0
- package/dist/authoring/outlines.d.ts +34 -0
- package/dist/authoring/outlines.js +255 -0
- package/dist/authoring/patch.d.ts +23 -0
- package/dist/authoring/patch.js +166 -0
- package/dist/authoring/select-layouts.d.ts +19 -0
- package/dist/authoring/select-layouts.js +90 -0
- package/dist/engine/auto-slide.d.ts +32 -0
- package/dist/engine/auto-slide.js +67 -0
- package/dist/engine/canvas.d.ts +56 -0
- package/dist/engine/canvas.js +48 -0
- package/dist/engine/controller.d.ts +126 -0
- package/dist/engine/controller.js +248 -0
- package/dist/engine/controls.d.ts +128 -0
- package/dist/engine/controls.js +460 -0
- package/dist/engine/hash.d.ts +42 -0
- package/dist/engine/hash.js +86 -0
- package/dist/engine/index.d.ts +88 -0
- package/dist/engine/index.js +88 -0
- package/dist/engine/messages.d.ts +138 -0
- package/dist/engine/messages.js +140 -0
- package/dist/engine/motion.d.ts +64 -0
- package/dist/engine/motion.js +112 -0
- package/dist/engine/navigation.d.ts +87 -0
- package/dist/engine/navigation.js +156 -0
- package/dist/engine/presenter.d.ts +58 -0
- package/dist/engine/presenter.js +157 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/markdown/attributes.d.ts +24 -0
- package/dist/markdown/attributes.js +94 -0
- package/dist/markdown/attrs-closed.d.ts +52 -0
- package/dist/markdown/attrs-closed.js +520 -0
- package/dist/markdown/blocks.d.ts +112 -0
- package/dist/markdown/blocks.js +766 -0
- package/dist/markdown/frontmatter.d.ts +30 -0
- package/dist/markdown/frontmatter.js +165 -0
- package/dist/markdown/index.d.ts +29 -0
- package/dist/markdown/index.js +29 -0
- package/dist/markdown/issues.d.ts +68 -0
- package/dist/markdown/issues.js +83 -0
- package/dist/markdown/json.d.ts +21 -0
- package/dist/markdown/json.js +236 -0
- package/dist/markdown/limits.d.ts +32 -0
- package/dist/markdown/limits.js +32 -0
- package/dist/markdown/parse.d.ts +31 -0
- package/dist/markdown/parse.js +283 -0
- package/dist/markdown/scan.d.ts +26 -0
- package/dist/markdown/scan.js +138 -0
- package/dist/markdown/slides.d.ts +32 -0
- package/dist/markdown/slides.js +854 -0
- package/dist/markdown/text.d.ts +76 -0
- package/dist/markdown/text.js +447 -0
- package/dist/markdown/twin.d.ts +41 -0
- package/dist/markdown/twin.js +374 -0
- package/dist/markdown/write.d.ts +17 -0
- package/dist/markdown/write.js +688 -0
- package/dist/math/index.d.ts +14 -0
- package/dist/math/index.js +14 -0
- package/dist/math/parse.d.ts +9 -0
- package/dist/math/parse.js +648 -0
- package/dist/math/symbols.d.ts +44 -0
- package/dist/math/symbols.js +333 -0
- package/dist/math/types.d.ts +59 -0
- package/dist/math/types.js +69 -0
- package/dist/model/backgrounds/contrast.d.ts +77 -0
- package/dist/model/backgrounds/contrast.js +186 -0
- package/dist/model/backgrounds/index.d.ts +7 -0
- package/dist/model/backgrounds/index.js +7 -0
- package/dist/model/backgrounds/resolve.d.ts +86 -0
- package/dist/model/backgrounds/resolve.js +215 -0
- package/dist/model/backgrounds/schema.d.ts +178 -0
- package/dist/model/backgrounds/schema.js +203 -0
- package/dist/model/codes.d.ts +73 -0
- package/dist/model/codes.js +67 -0
- package/dist/model/deck.d.ts +392 -0
- package/dist/model/deck.js +338 -0
- package/dist/model/index.d.ts +19 -0
- package/dist/model/index.js +19 -0
- package/dist/model/layouts/catalog.d.ts +433 -0
- package/dist/model/layouts/catalog.js +495 -0
- package/dist/model/layouts/index.d.ts +5 -0
- package/dist/model/layouts/index.js +5 -0
- package/dist/model/layouts/props.d.ts +302 -0
- package/dist/model/layouts/props.js +272 -0
- package/dist/model/loader.d.ts +37 -0
- package/dist/model/loader.js +317 -0
- package/dist/model/migrate.d.ts +34 -0
- package/dist/model/migrate.js +453 -0
- package/dist/model/motion/index.d.ts +6 -0
- package/dist/model/motion/index.js +6 -0
- package/dist/model/motion/transition.d.ts +96 -0
- package/dist/model/motion/transition.js +127 -0
- package/dist/model/parse.d.ts +57 -0
- package/dist/model/parse.js +307 -0
- package/dist/model/plain.d.ts +40 -0
- package/dist/model/plain.js +275 -0
- package/dist/model/shared.d.ts +63 -0
- package/dist/model/shared.js +86 -0
- package/dist/model/steps/index.d.ts +7 -0
- package/dist/model/steps/index.js +7 -0
- package/dist/model/steps/phase.d.ts +34 -0
- package/dist/model/steps/phase.js +50 -0
- package/dist/model/steps/slide-steps.d.ts +129 -0
- package/dist/model/steps/slide-steps.js +256 -0
- package/dist/model/style/index.d.ts +8 -0
- package/dist/model/style/index.js +8 -0
- package/dist/model/style/resolve.d.ts +87 -0
- package/dist/model/style/resolve.js +174 -0
- package/dist/model/style/schema.d.ts +85 -0
- package/dist/model/style/schema.js +53 -0
- package/dist/model/style/theme.d.ts +69 -0
- package/dist/model/style/theme.js +84 -0
- package/dist/schema/builders.d.ts +146 -0
- package/dist/schema/builders.js +285 -0
- package/dist/schema/diagnostics.d.ts +78 -0
- package/dist/schema/diagnostics.js +81 -0
- package/dist/schema/index.d.ts +15 -0
- package/dist/schema/index.js +14 -0
- package/dist/schema/internal.d.ts +40 -0
- package/dist/schema/internal.js +209 -0
- package/dist/schema/json-schema.d.ts +12 -0
- package/dist/schema/json-schema.js +213 -0
- package/dist/schema/types.d.ts +166 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/validate.d.ts +51 -0
- package/dist/schema/validate.js +765 -0
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.js +2 -0
- package/dist/templates/templates.d.ts +11 -0
- package/dist/templates/templates.js +47 -0
- package/package.json +60 -4
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one issue shape of the deck engine (model-v2 §3.9): JSON validation, the
|
|
3
|
+
* Markdown dialect, lint and the untrusted loader all report `DeckDiagnostic`.
|
|
4
|
+
* This module owns the shape, the path formats and the schema kit's own codes.
|
|
5
|
+
*/
|
|
6
|
+
/** One step of a path: an object key or an array index. */
|
|
7
|
+
export type PathSegment = string | number;
|
|
8
|
+
export type DiagnosticSeverity = 'error' | 'warning';
|
|
9
|
+
/** A source span, set by the Markdown dialect (line and column are 1-based). */
|
|
10
|
+
export interface DiagnosticSource {
|
|
11
|
+
line: number;
|
|
12
|
+
column: number;
|
|
13
|
+
endLine?: number;
|
|
14
|
+
endColumn?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One issue. `path` is canonical; `jsonPath` (`$.slides[2].props.title`) and
|
|
18
|
+
* `pointer` (RFC 6901, `/slides/2/props/title`) are derived from it. The model
|
|
19
|
+
* narrows `Code` and `Rule` to its documented unions.
|
|
20
|
+
*/
|
|
21
|
+
export interface DeckDiagnostic<Code extends string = string, Rule extends string = string> {
|
|
22
|
+
code: Code;
|
|
23
|
+
severity: DiagnosticSeverity;
|
|
24
|
+
path: PathSegment[];
|
|
25
|
+
jsonPath: string;
|
|
26
|
+
pointer: string;
|
|
27
|
+
message: string;
|
|
28
|
+
/** An actionable hint. */
|
|
29
|
+
fix?: string;
|
|
30
|
+
slideId?: string;
|
|
31
|
+
source?: DiagnosticSource;
|
|
32
|
+
/** Lint only: the rule that raised the issue. */
|
|
33
|
+
rule?: Rule;
|
|
34
|
+
}
|
|
35
|
+
/** The schema kit's own stable codes (kebab-case; the model adds its own). */
|
|
36
|
+
export declare const SCHEMA_CODES: Readonly<{
|
|
37
|
+
readonly invalidType: "invalid-type";
|
|
38
|
+
readonly required: "required";
|
|
39
|
+
readonly unknownKey: "unknown-key";
|
|
40
|
+
readonly tooShort: "too-short";
|
|
41
|
+
readonly tooLong: "too-long";
|
|
42
|
+
readonly tooFew: "too-few";
|
|
43
|
+
readonly tooMany: "too-many";
|
|
44
|
+
readonly outOfRange: "out-of-range";
|
|
45
|
+
readonly notInteger: "not-integer";
|
|
46
|
+
readonly notFinite: "not-finite";
|
|
47
|
+
readonly pattern: "pattern";
|
|
48
|
+
readonly reserved: "reserved";
|
|
49
|
+
readonly enum: "enum";
|
|
50
|
+
readonly unknownKind: "unknown-kind";
|
|
51
|
+
readonly unsafeText: "unsafe-text";
|
|
52
|
+
readonly unsafeKey: "unsafe-key";
|
|
53
|
+
readonly tooDeep: "too-deep";
|
|
54
|
+
readonly cycle: "cycle";
|
|
55
|
+
readonly tooLarge: "too-large";
|
|
56
|
+
readonly invalidInput: "invalid-input";
|
|
57
|
+
}>;
|
|
58
|
+
export type SchemaCode = (typeof SCHEMA_CODES)[keyof typeof SCHEMA_CODES];
|
|
59
|
+
/** At most this many diagnostics are kept by default; the rest set `truncated`. */
|
|
60
|
+
export declare const MAX_DIAGNOSTICS = 200;
|
|
61
|
+
/** `['slides', 2, 'props', 'title']` → `$.slides[2].props.title`; odd keys use `['…']`. */
|
|
62
|
+
export declare function toJsonPath(path: readonly PathSegment[]): string;
|
|
63
|
+
/** RFC 6901 JSON Pointer: `~` → `~0`, `/` → `~1`; the root is `''`. */
|
|
64
|
+
export declare function toPointer(path: readonly PathSegment[]): string;
|
|
65
|
+
export interface DiagnosticExtra {
|
|
66
|
+
severity?: DiagnosticSeverity;
|
|
67
|
+
fix?: string;
|
|
68
|
+
slideId?: string;
|
|
69
|
+
source?: DiagnosticSource;
|
|
70
|
+
rule?: string;
|
|
71
|
+
}
|
|
72
|
+
/** Build a diagnostic with both derived path forms (the path is copied). */
|
|
73
|
+
export declare function createDiagnostic<Code extends string>(code: Code, message: string, path: readonly PathSegment[], extra?: DiagnosticExtra): DeckDiagnostic<Code>;
|
|
74
|
+
/**
|
|
75
|
+
* One line for logs, CLIs and agent feedback:
|
|
76
|
+
* `error unknown-key at $.style.colour (3:5): Unknown key "colour". Fix: …`.
|
|
77
|
+
*/
|
|
78
|
+
export declare function formatDiagnostic(diagnostic: DeckDiagnostic): string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one issue shape of the deck engine (model-v2 §3.9): JSON validation, the
|
|
3
|
+
* Markdown dialect, lint and the untrusted loader all report `DeckDiagnostic`.
|
|
4
|
+
* This module owns the shape, the path formats and the schema kit's own codes.
|
|
5
|
+
*/
|
|
6
|
+
/** The schema kit's own stable codes (kebab-case; the model adds its own). */
|
|
7
|
+
export const SCHEMA_CODES = Object.freeze({
|
|
8
|
+
invalidType: 'invalid-type',
|
|
9
|
+
required: 'required',
|
|
10
|
+
unknownKey: 'unknown-key',
|
|
11
|
+
tooShort: 'too-short',
|
|
12
|
+
tooLong: 'too-long',
|
|
13
|
+
tooFew: 'too-few',
|
|
14
|
+
tooMany: 'too-many',
|
|
15
|
+
outOfRange: 'out-of-range',
|
|
16
|
+
notInteger: 'not-integer',
|
|
17
|
+
notFinite: 'not-finite',
|
|
18
|
+
pattern: 'pattern',
|
|
19
|
+
reserved: 'reserved',
|
|
20
|
+
enum: 'enum',
|
|
21
|
+
unknownKind: 'unknown-kind',
|
|
22
|
+
unsafeText: 'unsafe-text',
|
|
23
|
+
unsafeKey: 'unsafe-key',
|
|
24
|
+
tooDeep: 'too-deep',
|
|
25
|
+
cycle: 'cycle',
|
|
26
|
+
tooLarge: 'too-large',
|
|
27
|
+
invalidInput: 'invalid-input',
|
|
28
|
+
});
|
|
29
|
+
/** At most this many diagnostics are kept by default; the rest set `truncated`. */
|
|
30
|
+
export const MAX_DIAGNOSTICS = 200;
|
|
31
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
32
|
+
/** `['slides', 2, 'props', 'title']` → `$.slides[2].props.title`; odd keys use `['…']`. */
|
|
33
|
+
export function toJsonPath(path) {
|
|
34
|
+
let out = '$';
|
|
35
|
+
for (const segment of path) {
|
|
36
|
+
if (typeof segment === 'number')
|
|
37
|
+
out += `[${segment}]`;
|
|
38
|
+
else if (IDENTIFIER.test(segment))
|
|
39
|
+
out += `.${segment}`;
|
|
40
|
+
else
|
|
41
|
+
out += `['${segment.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}']`;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** RFC 6901 JSON Pointer: `~` → `~0`, `/` → `~1`; the root is `''`. */
|
|
46
|
+
export function toPointer(path) {
|
|
47
|
+
let out = '';
|
|
48
|
+
for (const segment of path) {
|
|
49
|
+
out += `/${String(segment).replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** Build a diagnostic with both derived path forms (the path is copied). */
|
|
54
|
+
export function createDiagnostic(code, message, path, extra = {}) {
|
|
55
|
+
const diagnostic = {
|
|
56
|
+
code,
|
|
57
|
+
severity: extra.severity ?? 'error',
|
|
58
|
+
path: [...path],
|
|
59
|
+
jsonPath: toJsonPath(path),
|
|
60
|
+
pointer: toPointer(path),
|
|
61
|
+
message,
|
|
62
|
+
};
|
|
63
|
+
if (extra.fix !== undefined)
|
|
64
|
+
diagnostic.fix = extra.fix;
|
|
65
|
+
if (extra.slideId !== undefined)
|
|
66
|
+
diagnostic.slideId = extra.slideId;
|
|
67
|
+
if (extra.source !== undefined)
|
|
68
|
+
diagnostic.source = { ...extra.source };
|
|
69
|
+
if (extra.rule !== undefined)
|
|
70
|
+
diagnostic.rule = extra.rule;
|
|
71
|
+
return diagnostic;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* One line for logs, CLIs and agent feedback:
|
|
75
|
+
* `error unknown-key at $.style.colour (3:5): Unknown key "colour". Fix: …`.
|
|
76
|
+
*/
|
|
77
|
+
export function formatDiagnostic(diagnostic) {
|
|
78
|
+
const where = diagnostic.source ? ` (${diagnostic.source.line}:${diagnostic.source.column})` : '';
|
|
79
|
+
const fix = diagnostic.fix ? ` Fix: ${diagnostic.fix}` : '';
|
|
80
|
+
return `${diagnostic.severity} ${diagnostic.code} at ${diagnostic.jsonPath}${where}: ${diagnostic.message}${fix}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/deck/schema: the owned schema kit (ADR-009, model-v2 §2.1 option B).
|
|
3
|
+
*
|
|
4
|
+
* Combinators are data: `t.*` builds frozen plain objects describing a shape,
|
|
5
|
+
* `validate` walks them (all issues, `DeckDiagnostic` with `jsonPath` and
|
|
6
|
+
* RFC 6901 `pointer`, capped at 200, never throws), `toJsonSchema` writes
|
|
7
|
+
* JSON Schema 2020-12 from the same data, and `Infer<typeof S>` gives the
|
|
8
|
+
* TypeScript type. String lengths count Unicode code points everywhere.
|
|
9
|
+
*/
|
|
10
|
+
export { type CodeOptions, type Described, JSON_DEFAULTS, type JsonOptions, type LazyOptions, type NumberOptions, type ObjectOptions, type OptionalOptions, objectOf, type RecordOptions, type RefineOptions, type SizeOptions, type StringOptions, t, UNSAFE_KEYS, type UnionOptions, } from './builders.js';
|
|
11
|
+
export { createDiagnostic, type DeckDiagnostic, type DiagnosticExtra, type DiagnosticSeverity, type DiagnosticSource, formatDiagnostic, MAX_DIAGNOSTICS, type PathSegment, SCHEMA_CODES, type SchemaCode, toJsonPath, toPointer, } from './diagnostics.js';
|
|
12
|
+
export { codePointLength, unsafeTextIndex } from './internal.js';
|
|
13
|
+
export { JSON_SCHEMA_DIALECT, type JsonSchema, type JsonSchemaOptions, toJsonSchema, } from './json-schema.js';
|
|
14
|
+
export type { ArraySchema, BooleanSchema, EnumSchema, Infer, InferShape, JsonValue, JsonValueSchema, LazySchema, LiteralSchema, LiteralValue, NullableSchema, NullSchema, NumberSchema, ObjectSchema, OptionalSchema, RecordSchema, RefineContext, RefineIssue, RefineSchema, Schema, SchemaKind, SchemaNode, Shape, StringSchema, TextMode, UnionSchema, } from './types.js';
|
|
15
|
+
export { DEFAULT_MAX_DEPTH, DEFAULT_MAX_NODES, is, type SchemaNormalization, type ValidateOptions, type ValidateResult, validate, } from './validate.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/deck/schema: the owned schema kit (ADR-009, model-v2 §2.1 option B).
|
|
3
|
+
*
|
|
4
|
+
* Combinators are data: `t.*` builds frozen plain objects describing a shape,
|
|
5
|
+
* `validate` walks them (all issues, `DeckDiagnostic` with `jsonPath` and
|
|
6
|
+
* RFC 6901 `pointer`, capped at 200, never throws), `toJsonSchema` writes
|
|
7
|
+
* JSON Schema 2020-12 from the same data, and `Infer<typeof S>` gives the
|
|
8
|
+
* TypeScript type. String lengths count Unicode code points everywhere.
|
|
9
|
+
*/
|
|
10
|
+
export { JSON_DEFAULTS, objectOf, t, UNSAFE_KEYS, } from "./builders.js";
|
|
11
|
+
export { createDiagnostic, formatDiagnostic, MAX_DIAGNOSTICS, SCHEMA_CODES, toJsonPath, toPointer, } from "./diagnostics.js";
|
|
12
|
+
export { codePointLength, unsafeTextIndex } from "./internal.js";
|
|
13
|
+
export { JSON_SCHEMA_DIALECT, toJsonSchema, } from "./json-schema.js";
|
|
14
|
+
export { DEFAULT_MAX_DEPTH, DEFAULT_MAX_NODES, is, validate, } from "./validate.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers shared by the builders, the validator and the JSON Schema emitter.
|
|
3
|
+
* Derived lookups (compiled patterns, discriminator maps) live in caches here
|
|
4
|
+
* so the schema nodes themselves stay plain frozen data.
|
|
5
|
+
*/
|
|
6
|
+
import type { LiteralValue, ObjectSchema, Schema, TextMode, UnionSchema } from './types.js';
|
|
7
|
+
/** Keys never accepted as data, in any object or record (prototype pollution). */
|
|
8
|
+
export declare const UNSAFE_KEYS: readonly string[];
|
|
9
|
+
export declare function isUnsafeKey(key: string): boolean;
|
|
10
|
+
/** Length in Unicode code points (a surrogate pair counts once, a lone surrogate once). */
|
|
11
|
+
export declare function codePointLength(value: string): number;
|
|
12
|
+
/**
|
|
13
|
+
* True when `value` has more than `max` code points. Strings longer than
|
|
14
|
+
* `2 × max` UTF-16 units are refused without counting.
|
|
15
|
+
*/
|
|
16
|
+
export declare function exceedsCodePoints(value: string, max: number): boolean;
|
|
17
|
+
/** Index of the first character `mode` refuses, or `-1`. */
|
|
18
|
+
export declare function unsafeTextIndex(value: string, mode: TextMode): number;
|
|
19
|
+
/** The same rule as `unsafeTextIndex`, as a JSON Schema pattern source. */
|
|
20
|
+
export declare function textPattern(mode: TextMode): string;
|
|
21
|
+
export declare function describeChar(value: string, index: number): string;
|
|
22
|
+
/** Patterns compile once, with the `u` flag (no `g`/`y`, so `test` is stateless). */
|
|
23
|
+
export declare function compilePattern(source: string): RegExp;
|
|
24
|
+
/** Follow refine wrappers down to an object schema, if there is one. */
|
|
25
|
+
export declare function unwrapObject(schema: Schema): ObjectSchema | undefined;
|
|
26
|
+
export interface DiscriminatorIndex {
|
|
27
|
+
/** Discriminator value → option index. */
|
|
28
|
+
readonly map: ReadonlyMap<LiteralValue, number>;
|
|
29
|
+
/** Options whose discriminator is a plain string schema, tried in order. */
|
|
30
|
+
readonly fallbacks: readonly number[];
|
|
31
|
+
/** Every mapped value, in declaration order. */
|
|
32
|
+
readonly values: readonly LiteralValue[];
|
|
33
|
+
}
|
|
34
|
+
/** Build (once) the lookup of a discriminated union; throws on a malformed union. */
|
|
35
|
+
export declare function discriminatorIndex(union: UnionSchema): DiscriminatorIndex;
|
|
36
|
+
export type JsonType = 'string' | 'number' | 'boolean' | 'null' | 'array' | 'object';
|
|
37
|
+
/** The JSON types a schema can accept, used to pick the relevant union option. */
|
|
38
|
+
export declare function acceptedTypes(schema: Schema): ReadonlySet<JsonType>;
|
|
39
|
+
/** The JSON type name of a value, or a JS name (`undefined`, `function`…) for non-JSON. */
|
|
40
|
+
export declare function typeName(value: unknown): string;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/** Keys never accepted as data, in any object or record (prototype pollution). */
|
|
2
|
+
export const UNSAFE_KEYS = Object.freeze([
|
|
3
|
+
'__proto__',
|
|
4
|
+
'constructor',
|
|
5
|
+
'prototype',
|
|
6
|
+
]);
|
|
7
|
+
const UNSAFE = new Set(UNSAFE_KEYS);
|
|
8
|
+
export function isUnsafeKey(key) {
|
|
9
|
+
return UNSAFE.has(key);
|
|
10
|
+
}
|
|
11
|
+
/** Length in Unicode code points (a surrogate pair counts once, a lone surrogate once). */
|
|
12
|
+
export function codePointLength(value) {
|
|
13
|
+
let count = 0;
|
|
14
|
+
for (let i = 0; i < value.length; i++) {
|
|
15
|
+
const unit = value.charCodeAt(i);
|
|
16
|
+
if (unit >= 0xd800 && unit <= 0xdbff && i + 1 < value.length) {
|
|
17
|
+
const next = value.charCodeAt(i + 1);
|
|
18
|
+
if (next >= 0xdc00 && next <= 0xdfff)
|
|
19
|
+
i++;
|
|
20
|
+
}
|
|
21
|
+
count++;
|
|
22
|
+
}
|
|
23
|
+
return count;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* True when `value` has more than `max` code points. Strings longer than
|
|
27
|
+
* `2 × max` UTF-16 units are refused without counting.
|
|
28
|
+
*/
|
|
29
|
+
export function exceedsCodePoints(value, max) {
|
|
30
|
+
if (value.length <= max)
|
|
31
|
+
return false;
|
|
32
|
+
if (value.length > max * 2)
|
|
33
|
+
return true;
|
|
34
|
+
return codePointLength(value) > max;
|
|
35
|
+
}
|
|
36
|
+
/** Index of the first character `mode` refuses, or `-1`. */
|
|
37
|
+
export function unsafeTextIndex(value, mode) {
|
|
38
|
+
const multiline = mode === 'multiline';
|
|
39
|
+
for (let i = 0; i < value.length; i++) {
|
|
40
|
+
const c = value.charCodeAt(i);
|
|
41
|
+
if (c < 0x20) {
|
|
42
|
+
if (multiline && (c === 0x09 || c === 0x0a || c === 0x0d))
|
|
43
|
+
continue;
|
|
44
|
+
return i;
|
|
45
|
+
}
|
|
46
|
+
if (c < 0x7f)
|
|
47
|
+
continue;
|
|
48
|
+
if (c <= 0x9f)
|
|
49
|
+
return i;
|
|
50
|
+
if (c < 0x2028)
|
|
51
|
+
continue;
|
|
52
|
+
if (c <= 0x2029) {
|
|
53
|
+
if (multiline)
|
|
54
|
+
continue;
|
|
55
|
+
return i;
|
|
56
|
+
}
|
|
57
|
+
if ((c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069))
|
|
58
|
+
return i;
|
|
59
|
+
}
|
|
60
|
+
return -1;
|
|
61
|
+
}
|
|
62
|
+
/** The same rule as `unsafeTextIndex`, as a JSON Schema pattern source. */
|
|
63
|
+
export function textPattern(mode) {
|
|
64
|
+
return mode === 'multiline'
|
|
65
|
+
? '^[^\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F\\u202A-\\u202E\\u2066-\\u2069]*$'
|
|
66
|
+
: '^[^\\u0000-\\u001F\\u007F-\\u009F\\u2028\\u2029\\u202A-\\u202E\\u2066-\\u2069]*$';
|
|
67
|
+
}
|
|
68
|
+
export function describeChar(value, index) {
|
|
69
|
+
const code = value.codePointAt(index) ?? 0;
|
|
70
|
+
return `U+${code.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
71
|
+
}
|
|
72
|
+
const patterns = new Map();
|
|
73
|
+
/** Patterns compile once, with the `u` flag (no `g`/`y`, so `test` is stateless). */
|
|
74
|
+
export function compilePattern(source) {
|
|
75
|
+
let compiled = patterns.get(source);
|
|
76
|
+
if (!compiled) {
|
|
77
|
+
compiled = new RegExp(source, 'u');
|
|
78
|
+
patterns.set(source, compiled);
|
|
79
|
+
}
|
|
80
|
+
return compiled;
|
|
81
|
+
}
|
|
82
|
+
/** Follow refine wrappers down to an object schema, if there is one. */
|
|
83
|
+
export function unwrapObject(schema) {
|
|
84
|
+
let node = schema;
|
|
85
|
+
for (let guard = 0; guard < 64; guard++) {
|
|
86
|
+
if (node.kind === 'object')
|
|
87
|
+
return node;
|
|
88
|
+
if (node.kind === 'refine')
|
|
89
|
+
node = node.inner;
|
|
90
|
+
else
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const discriminators = new WeakMap();
|
|
96
|
+
/** Build (once) the lookup of a discriminated union; throws on a malformed union. */
|
|
97
|
+
export function discriminatorIndex(union) {
|
|
98
|
+
const cached = discriminators.get(union);
|
|
99
|
+
if (cached)
|
|
100
|
+
return cached;
|
|
101
|
+
const key = union.discriminator;
|
|
102
|
+
if (key === undefined)
|
|
103
|
+
throw new TypeError('The union has no discriminator.');
|
|
104
|
+
const map = new Map();
|
|
105
|
+
const fallbacks = [];
|
|
106
|
+
const values = [];
|
|
107
|
+
union.options.forEach((option, index) => {
|
|
108
|
+
const object = unwrapObject(option);
|
|
109
|
+
const field = object?.shape[key];
|
|
110
|
+
if (!field) {
|
|
111
|
+
throw new TypeError(`Union option ${index} is not an object with a "${key}" field.`);
|
|
112
|
+
}
|
|
113
|
+
const add = (value) => {
|
|
114
|
+
if (map.has(value)) {
|
|
115
|
+
throw new TypeError(`Union options share the "${key}" value ${JSON.stringify(value)}.`);
|
|
116
|
+
}
|
|
117
|
+
map.set(value, index);
|
|
118
|
+
values.push(value);
|
|
119
|
+
};
|
|
120
|
+
if (field.kind === 'literal')
|
|
121
|
+
add(field.value);
|
|
122
|
+
else if (field.kind === 'enum')
|
|
123
|
+
for (const value of field.values)
|
|
124
|
+
add(value);
|
|
125
|
+
else if (field.kind === 'string')
|
|
126
|
+
fallbacks.push(index);
|
|
127
|
+
else {
|
|
128
|
+
throw new TypeError(`Union option ${index}: "${key}" must be a literal, an enum or a string schema.`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const index = Object.freeze({ map, fallbacks, values });
|
|
132
|
+
discriminators.set(union, index);
|
|
133
|
+
return index;
|
|
134
|
+
}
|
|
135
|
+
const ALL_TYPES = new Set([
|
|
136
|
+
'string',
|
|
137
|
+
'number',
|
|
138
|
+
'boolean',
|
|
139
|
+
'null',
|
|
140
|
+
'array',
|
|
141
|
+
'object',
|
|
142
|
+
]);
|
|
143
|
+
const accepted = new WeakMap();
|
|
144
|
+
/** The JSON types a schema can accept, used to pick the relevant union option. */
|
|
145
|
+
export function acceptedTypes(schema) {
|
|
146
|
+
const cached = accepted.get(schema);
|
|
147
|
+
if (cached)
|
|
148
|
+
return cached;
|
|
149
|
+
const node = schema;
|
|
150
|
+
let types;
|
|
151
|
+
switch (node.kind) {
|
|
152
|
+
case 'string':
|
|
153
|
+
case 'enum':
|
|
154
|
+
types = new Set(['string']);
|
|
155
|
+
break;
|
|
156
|
+
case 'number':
|
|
157
|
+
types = new Set(['number']);
|
|
158
|
+
break;
|
|
159
|
+
case 'boolean':
|
|
160
|
+
types = new Set(['boolean']);
|
|
161
|
+
break;
|
|
162
|
+
case 'null':
|
|
163
|
+
types = new Set(['null']);
|
|
164
|
+
break;
|
|
165
|
+
case 'literal':
|
|
166
|
+
types = new Set([node.value === null ? 'null' : typeof node.value]);
|
|
167
|
+
break;
|
|
168
|
+
case 'array':
|
|
169
|
+
types = new Set(['array']);
|
|
170
|
+
break;
|
|
171
|
+
case 'object':
|
|
172
|
+
case 'record':
|
|
173
|
+
types = new Set(['object']);
|
|
174
|
+
break;
|
|
175
|
+
case 'union': {
|
|
176
|
+
const all = new Set();
|
|
177
|
+
for (const option of node.options)
|
|
178
|
+
for (const type of acceptedTypes(option))
|
|
179
|
+
all.add(type);
|
|
180
|
+
types = all;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case 'nullable':
|
|
184
|
+
types = new Set([...acceptedTypes(node.inner), 'null']);
|
|
185
|
+
break;
|
|
186
|
+
case 'optional':
|
|
187
|
+
case 'refine':
|
|
188
|
+
types = acceptedTypes(node.inner);
|
|
189
|
+
break;
|
|
190
|
+
default:
|
|
191
|
+
// lazy (resolving it here could recurse forever) and json accept anything.
|
|
192
|
+
types = ALL_TYPES;
|
|
193
|
+
}
|
|
194
|
+
accepted.set(schema, types);
|
|
195
|
+
return types;
|
|
196
|
+
}
|
|
197
|
+
/** The JSON type name of a value, or a JS name (`undefined`, `function`…) for non-JSON. */
|
|
198
|
+
export function typeName(value) {
|
|
199
|
+
if (value === null)
|
|
200
|
+
return 'null';
|
|
201
|
+
if (typeof value !== 'object')
|
|
202
|
+
return typeof value;
|
|
203
|
+
try {
|
|
204
|
+
return Array.isArray(value) ? 'array' : 'object';
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return 'object';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Schema } from './types.js';
|
|
2
|
+
export type JsonSchema = {
|
|
3
|
+
[key: string]: unknown;
|
|
4
|
+
};
|
|
5
|
+
export declare const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
6
|
+
export interface JsonSchemaOptions {
|
|
7
|
+
$id?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
/** The JSON Schema 2020-12 document for `schema`. */
|
|
12
|
+
export declare function toJsonSchema(schema: Schema, options?: JsonSchemaOptions): JsonSchema;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema 2020-12 from the same schema data the validator walks, so the
|
|
3
|
+
* export never drifts. Lengths are code points on both sides (JSON Schema
|
|
4
|
+
* `minLength`/`maxLength` count code points). Refinements and trimming cannot
|
|
5
|
+
* be expressed, so they are documented in `description`.
|
|
6
|
+
*/
|
|
7
|
+
import { discriminatorIndex, textPattern, UNSAFE_KEYS } from "./internal.js";
|
|
8
|
+
export const JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema';
|
|
9
|
+
/** The JSON Schema 2020-12 document for `schema`. */
|
|
10
|
+
export function toJsonSchema(schema, options = {}) {
|
|
11
|
+
const state = { defs: {}, names: new Map() };
|
|
12
|
+
const body = emit(schema, state);
|
|
13
|
+
const doc = { $schema: JSON_SCHEMA_DIALECT };
|
|
14
|
+
if (options.$id !== undefined)
|
|
15
|
+
doc.$id = options.$id;
|
|
16
|
+
if (options.title !== undefined)
|
|
17
|
+
doc.title = options.title;
|
|
18
|
+
Object.assign(doc, body);
|
|
19
|
+
if (options.description !== undefined)
|
|
20
|
+
doc.description = options.description;
|
|
21
|
+
if (Object.keys(state.defs).length > 0)
|
|
22
|
+
doc.$defs = state.defs;
|
|
23
|
+
return doc;
|
|
24
|
+
}
|
|
25
|
+
function described(out, description) {
|
|
26
|
+
if (description === undefined)
|
|
27
|
+
return out;
|
|
28
|
+
const existing = out.description;
|
|
29
|
+
out.description = typeof existing === 'string' ? `${description} ${existing}` : description;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
function appendDescription(out, text) {
|
|
33
|
+
const existing = out.description;
|
|
34
|
+
out.description = typeof existing === 'string' ? `${existing} ${text}` : text;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function emitString(node) {
|
|
38
|
+
const out = { type: 'string' };
|
|
39
|
+
if (node.min !== undefined)
|
|
40
|
+
out.minLength = node.min;
|
|
41
|
+
if (node.max !== undefined)
|
|
42
|
+
out.maxLength = node.max;
|
|
43
|
+
const patterns = [];
|
|
44
|
+
if (node.pattern !== undefined)
|
|
45
|
+
patterns.push(node.pattern);
|
|
46
|
+
if (node.text !== undefined)
|
|
47
|
+
patterns.push(textPattern(node.text));
|
|
48
|
+
if (patterns.length === 1)
|
|
49
|
+
out.pattern = patterns[0];
|
|
50
|
+
else if (patterns.length > 1)
|
|
51
|
+
out.allOf = patterns.map((pattern) => ({ pattern }));
|
|
52
|
+
if (node.not && node.not.length > 0)
|
|
53
|
+
out.not = { enum: [...node.not] };
|
|
54
|
+
described(out, node.description);
|
|
55
|
+
if (node.trim)
|
|
56
|
+
appendDescription(out, 'Leading and trailing whitespace is trimmed before the length check.');
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function emit(node, state) {
|
|
60
|
+
switch (node.kind) {
|
|
61
|
+
case 'string':
|
|
62
|
+
return emitString(node);
|
|
63
|
+
case 'number': {
|
|
64
|
+
const out = { type: node.int ? 'integer' : 'number' };
|
|
65
|
+
if (node.min !== undefined)
|
|
66
|
+
out.minimum = node.min;
|
|
67
|
+
if (node.max !== undefined)
|
|
68
|
+
out.maximum = node.max;
|
|
69
|
+
return described(out, node.description);
|
|
70
|
+
}
|
|
71
|
+
case 'boolean':
|
|
72
|
+
return described({ type: 'boolean' }, node.description);
|
|
73
|
+
case 'null':
|
|
74
|
+
return described({ type: 'null' }, node.description);
|
|
75
|
+
case 'literal':
|
|
76
|
+
return described({ const: node.value }, node.description);
|
|
77
|
+
case 'enum':
|
|
78
|
+
return described({ type: 'string', enum: [...node.values] }, node.description);
|
|
79
|
+
case 'array': {
|
|
80
|
+
const out = { type: 'array', items: emit(node.item, state) };
|
|
81
|
+
if (node.min !== undefined)
|
|
82
|
+
out.minItems = node.min;
|
|
83
|
+
if (node.max !== undefined)
|
|
84
|
+
out.maxItems = node.max;
|
|
85
|
+
return described(out, node.description);
|
|
86
|
+
}
|
|
87
|
+
case 'object': {
|
|
88
|
+
const properties = {};
|
|
89
|
+
const required = [];
|
|
90
|
+
for (const key of Object.keys(node.shape)) {
|
|
91
|
+
const field = node.shape[key];
|
|
92
|
+
if (field.kind === 'optional') {
|
|
93
|
+
const property = described(emit(field.inner, state), field.description);
|
|
94
|
+
if (field.hasDefault)
|
|
95
|
+
property.default = field.default;
|
|
96
|
+
properties[key] = property;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
properties[key] = emit(field, state);
|
|
100
|
+
required.push(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const out = { type: 'object', properties };
|
|
104
|
+
if (required.length > 0)
|
|
105
|
+
out.required = required;
|
|
106
|
+
if (node.strict)
|
|
107
|
+
out.additionalProperties = false;
|
|
108
|
+
return described(out, node.description);
|
|
109
|
+
}
|
|
110
|
+
case 'record': {
|
|
111
|
+
const names = node.key ? emitString(node.key) : { type: 'string' };
|
|
112
|
+
names.not = { enum: [...UNSAFE_KEYS, ...(node.key?.not ?? [])] };
|
|
113
|
+
const out = {
|
|
114
|
+
type: 'object',
|
|
115
|
+
propertyNames: names,
|
|
116
|
+
additionalProperties: emit(node.value, state),
|
|
117
|
+
};
|
|
118
|
+
if (node.min !== undefined)
|
|
119
|
+
out.minProperties = node.min;
|
|
120
|
+
if (node.max !== undefined)
|
|
121
|
+
out.maxProperties = node.max;
|
|
122
|
+
return described(out, node.description);
|
|
123
|
+
}
|
|
124
|
+
case 'union': {
|
|
125
|
+
if (node.discriminator === undefined) {
|
|
126
|
+
return described({ anyOf: node.options.map((option) => emit(option, state)) }, node.description);
|
|
127
|
+
}
|
|
128
|
+
const key = node.discriminator;
|
|
129
|
+
const index = discriminatorIndex(node);
|
|
130
|
+
const fallbacks = new Set(index.fallbacks);
|
|
131
|
+
const oneOf = node.options.map((option, i) => {
|
|
132
|
+
const out = emit(option, state);
|
|
133
|
+
const properties = out.properties;
|
|
134
|
+
const field = properties?.[key];
|
|
135
|
+
if (fallbacks.has(i) && field && index.values.length > 0) {
|
|
136
|
+
// A pattern branch must not also match the listed values, or oneOf breaks.
|
|
137
|
+
const refused = [...index.values, ...(field.not?.enum ?? [])];
|
|
138
|
+
field.not = { enum: refused };
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
});
|
|
142
|
+
return described({ oneOf }, node.description);
|
|
143
|
+
}
|
|
144
|
+
case 'nullable':
|
|
145
|
+
return described({ anyOf: [emit(node.inner, state), { type: 'null' }] }, node.description);
|
|
146
|
+
case 'optional':
|
|
147
|
+
return described(emit(node.inner, state), node.description);
|
|
148
|
+
case 'lazy':
|
|
149
|
+
return emitLazy(node, state);
|
|
150
|
+
case 'refine': {
|
|
151
|
+
const out = described(emit(node.inner, state), node.description);
|
|
152
|
+
if (node.description === undefined)
|
|
153
|
+
appendDescription(out, `Checked by rule "${node.code}".`);
|
|
154
|
+
else
|
|
155
|
+
appendDescription(out, `(rule "${node.code}")`);
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
case 'json':
|
|
159
|
+
return emitJson(node, state);
|
|
160
|
+
default:
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function uniqueName(state, base) {
|
|
165
|
+
let name = base;
|
|
166
|
+
for (let i = 2; Object.hasOwn(state.defs, name); i++)
|
|
167
|
+
name = `${base}${i}`;
|
|
168
|
+
return name;
|
|
169
|
+
}
|
|
170
|
+
function emitLazy(node, state) {
|
|
171
|
+
let name = state.names.get(node);
|
|
172
|
+
if (name === undefined) {
|
|
173
|
+
name = uniqueName(state, node.name ?? 'Lazy');
|
|
174
|
+
state.names.set(node, name);
|
|
175
|
+
state.defs[name] = {}; // placeholder: a recursive reference resolves to it
|
|
176
|
+
let target;
|
|
177
|
+
try {
|
|
178
|
+
target = node.get();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
target = undefined; // unresolvable: the definition stays `{}` (accepts anything)
|
|
182
|
+
}
|
|
183
|
+
if (target)
|
|
184
|
+
state.defs[name] = emit(target, state);
|
|
185
|
+
}
|
|
186
|
+
return described({ $ref: `#/$defs/${name}` }, node.description);
|
|
187
|
+
}
|
|
188
|
+
/** Bounded JSON unrolls into one definition per remaining depth (exact limits). */
|
|
189
|
+
function emitJson(node, state) {
|
|
190
|
+
const id = `Json_d${node.depth}_k${node.maxKeys}_i${node.maxItems}_s${node.maxString}_n${node.maxKeyLength}${node.text ? `_${node.text}` : ''}`;
|
|
191
|
+
const name = (depth) => `${id}_${depth}`;
|
|
192
|
+
if (!Object.hasOwn(state.defs, name(node.depth))) {
|
|
193
|
+
const text = { type: 'string', maxLength: node.maxString };
|
|
194
|
+
if (node.text !== undefined)
|
|
195
|
+
text.pattern = textPattern(node.text);
|
|
196
|
+
const scalars = [text, { type: 'number' }, { type: 'boolean' }, { type: 'null' }];
|
|
197
|
+
for (let depth = 0; depth <= node.depth; depth++) {
|
|
198
|
+
const options = [...scalars.map((scalar) => ({ ...scalar }))];
|
|
199
|
+
if (depth > 0) {
|
|
200
|
+
const child = { $ref: `#/$defs/${name(depth - 1)}` };
|
|
201
|
+
options.push({ type: 'array', maxItems: node.maxItems, items: child });
|
|
202
|
+
options.push({
|
|
203
|
+
type: 'object',
|
|
204
|
+
maxProperties: node.maxKeys,
|
|
205
|
+
propertyNames: { maxLength: node.maxKeyLength, not: { enum: [...UNSAFE_KEYS] } },
|
|
206
|
+
additionalProperties: child,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
state.defs[name(depth)] = { anyOf: options };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return described({ $ref: `#/$defs/${name(node.depth)}` }, node.description);
|
|
213
|
+
}
|