@glw907/cairn-cms 0.62.2 → 0.76.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/CHANGELOG.md +216 -0
- package/dist/ambient.d.ts +2 -0
- package/dist/auth/types.d.ts +7 -0
- package/dist/components/CairnAdmin.svelte.d.ts +2 -7
- package/dist/components/ComponentForm.svelte +44 -27
- package/dist/components/ComponentInsertDialog.svelte +22 -11
- package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
- package/dist/components/ConceptList.svelte +25 -4
- package/dist/components/EditPage.svelte +29 -107
- package/dist/components/EditPage.svelte.d.ts +2 -7
- package/dist/components/EntryPicker.svelte +117 -0
- package/dist/components/EntryPicker.svelte.d.ts +35 -0
- package/dist/components/FieldInput.svelte +218 -0
- package/dist/components/FieldInput.svelte.d.ts +51 -0
- package/dist/components/IconPicker.svelte +2 -2
- package/dist/components/IconPicker.svelte.d.ts +2 -0
- package/dist/components/LinkPicker.svelte +8 -75
- package/dist/components/LinkPicker.svelte.d.ts +4 -5
- package/dist/components/MediaHeroField.svelte +8 -5
- package/dist/components/MediaHeroField.svelte.d.ts +4 -0
- package/dist/components/ObjectGroupField.svelte +54 -0
- package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
- package/dist/components/ReferenceField.svelte +94 -0
- package/dist/components/ReferenceField.svelte.d.ts +27 -0
- package/dist/components/RepeatableField.svelte +221 -0
- package/dist/components/RepeatableField.svelte.d.ts +53 -0
- package/dist/components/cairn-admin.css +179 -2
- package/dist/components/preview-doc.js +5 -1
- package/dist/components/tidy-validate.js +1 -1
- package/dist/content/adapter.js +18 -0
- package/dist/content/advisories.d.ts +2 -2
- package/dist/content/advisories.js +3 -5
- package/dist/content/compose.d.ts +7 -6
- package/dist/content/compose.js +26 -20
- package/dist/content/concepts.d.ts +21 -15
- package/dist/content/concepts.js +55 -32
- package/dist/content/field-rules.d.ts +15 -0
- package/dist/content/field-rules.js +38 -0
- package/dist/content/fields.d.ts +169 -0
- package/dist/content/fields.js +41 -0
- package/dist/content/fieldset.d.ts +107 -0
- package/dist/content/fieldset.js +386 -0
- package/dist/content/frontmatter-region.d.ts +38 -0
- package/dist/content/frontmatter-region.js +75 -0
- package/dist/content/frontmatter.d.ts +35 -2
- package/dist/content/frontmatter.js +232 -11
- package/dist/content/manifest.d.ts +34 -0
- package/dist/content/manifest.js +80 -4
- package/dist/content/media-refs.d.ts +2 -2
- package/dist/content/media-rewrite.js +1 -69
- package/dist/content/reference-index.d.ts +56 -0
- package/dist/content/reference-index.js +95 -0
- package/dist/content/references.d.ts +40 -0
- package/dist/content/references.js +0 -0
- package/dist/content/standard-schema.d.ts +30 -0
- package/dist/content/standard-schema.js +4 -0
- package/dist/content/types.d.ts +127 -178
- package/dist/delivery/data.d.ts +2 -2
- package/dist/delivery/data.js +1 -1
- package/dist/delivery/public-routes.d.ts +10 -5
- package/dist/delivery/public-routes.js +25 -2
- package/dist/delivery/site-descriptors.d.ts +5 -1
- package/dist/delivery/site-descriptors.js +8 -3
- package/dist/delivery/site-indexes.d.ts +2 -2
- package/dist/delivery/site-resolver.d.ts +25 -0
- package/dist/delivery/site-resolver.js +49 -0
- package/dist/doctor/checks-local.js +6 -11
- package/dist/github/backend.d.ts +83 -0
- package/dist/github/backend.js +76 -0
- package/dist/github/credentials.d.ts +11 -5
- package/dist/github/credentials.js +3 -3
- package/dist/github/repo.d.ts +8 -19
- package/dist/github/repo.js +69 -80
- package/dist/github/types.d.ts +1 -1
- package/dist/github/types.js +4 -4
- package/dist/index.d.ts +18 -10
- package/dist/index.js +9 -5
- package/dist/islands/index.d.ts +12 -0
- package/dist/islands/index.js +83 -0
- package/dist/islands/types.d.ts +7 -0
- package/dist/islands/types.js +1 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/manifest.d.ts +11 -0
- package/dist/media/manifest.js +13 -0
- package/dist/media/rewrite-plan.d.ts +2 -3
- package/dist/media/rewrite-plan.js +2 -3
- package/dist/media/usage.d.ts +2 -2
- package/dist/media/usage.js +3 -5
- package/dist/nav/site-config.d.ts +0 -6
- package/dist/nav/site-config.js +6 -4
- package/dist/render/component-grammar.js +11 -11
- package/dist/render/component-reference.js +5 -3
- package/dist/render/component-validate.d.ts +4 -1
- package/dist/render/component-validate.js +10 -35
- package/dist/render/highlight.d.ts +9 -0
- package/dist/render/highlight.js +206 -0
- package/dist/render/pipeline.d.ts +0 -6
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +44 -36
- package/dist/render/registry.js +47 -6
- package/dist/render/rehype-dispatch.d.ts +6 -10
- package/dist/render/rehype-dispatch.js +38 -17
- package/dist/render/remark-directives.js +4 -5
- package/dist/render/sanitize-schema.d.ts +10 -0
- package/dist/render/sanitize-schema.js +30 -1
- package/dist/sveltekit/cairn-admin.d.ts +5 -5
- package/dist/sveltekit/cairn-admin.js +3 -4
- package/dist/sveltekit/content-routes.d.ts +10 -8
- package/dist/sveltekit/content-routes.js +269 -181
- package/dist/sveltekit/guard.js +10 -0
- package/dist/sveltekit/health.d.ts +7 -3
- package/dist/sveltekit/health.js +9 -3
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/nav-routes.d.ts +6 -5
- package/dist/sveltekit/nav-routes.js +22 -20
- package/dist/sveltekit/types.d.ts +2 -0
- package/dist/vite/index.d.ts +3 -3
- package/dist/vite/index.js +17 -8
- package/package.json +17 -2
- package/src/lib/ambient.ts +7 -0
- package/src/lib/auth/types.ts +7 -0
- package/src/lib/components/CairnAdmin.svelte +2 -6
- package/src/lib/components/ComponentForm.svelte +48 -27
- package/src/lib/components/ComponentInsertDialog.svelte +26 -14
- package/src/lib/components/ConceptList.svelte +41 -4
- package/src/lib/components/EditPage.svelte +43 -119
- package/src/lib/components/EntryPicker.svelte +154 -0
- package/src/lib/components/FieldInput.svelte +262 -0
- package/src/lib/components/IconPicker.svelte +4 -2
- package/src/lib/components/LinkPicker.svelte +10 -81
- package/src/lib/components/MediaHeroField.svelte +12 -5
- package/src/lib/components/ObjectGroupField.svelte +97 -0
- package/src/lib/components/ReferenceField.svelte +126 -0
- package/src/lib/components/RepeatableField.svelte +310 -0
- package/src/lib/components/preview-doc.ts +5 -1
- package/src/lib/components/tidy-validate.ts +1 -1
- package/src/lib/content/adapter.ts +21 -0
- package/src/lib/content/advisories.ts +4 -7
- package/src/lib/content/compose.ts +30 -23
- package/src/lib/content/concepts.ts +68 -40
- package/src/lib/content/field-rules.ts +39 -0
- package/src/lib/content/fields.ts +178 -0
- package/src/lib/content/fieldset.ts +470 -0
- package/src/lib/content/frontmatter-region.ts +90 -0
- package/src/lib/content/frontmatter.ts +231 -15
- package/src/lib/content/manifest.ts +101 -4
- package/src/lib/content/media-refs.ts +2 -2
- package/src/lib/content/media-rewrite.ts +7 -80
- package/src/lib/content/reference-index.ts +159 -0
- package/src/lib/content/references.ts +0 -0
- package/src/lib/content/standard-schema.ts +25 -0
- package/src/lib/content/types.ts +128 -195
- package/src/lib/delivery/data.ts +2 -2
- package/src/lib/delivery/public-routes.ts +36 -4
- package/src/lib/delivery/site-descriptors.ts +8 -3
- package/src/lib/delivery/site-indexes.ts +2 -2
- package/src/lib/delivery/site-resolver.ts +64 -0
- package/src/lib/doctor/checks-local.ts +6 -14
- package/src/lib/github/backend.ts +161 -0
- package/src/lib/github/credentials.ts +10 -7
- package/src/lib/github/repo.ts +79 -83
- package/src/lib/github/types.ts +5 -5
- package/src/lib/index.ts +40 -18
- package/src/lib/islands/index.ts +84 -0
- package/src/lib/islands/types.ts +11 -0
- package/src/lib/log/events.ts +1 -0
- package/src/lib/media/index.ts +1 -0
- package/src/lib/media/manifest.ts +14 -0
- package/src/lib/media/rewrite-plan.ts +4 -6
- package/src/lib/media/usage.ts +4 -7
- package/src/lib/nav/site-config.ts +8 -9
- package/src/lib/render/component-grammar.ts +10 -10
- package/src/lib/render/component-reference.ts +4 -3
- package/src/lib/render/component-validate.ts +10 -35
- package/src/lib/render/highlight.ts +259 -0
- package/src/lib/render/pipeline.ts +13 -8
- package/src/lib/render/registry.ts +88 -42
- package/src/lib/render/rehype-dispatch.ts +47 -16
- package/src/lib/render/remark-directives.ts +4 -5
- package/src/lib/render/sanitize-schema.ts +32 -1
- package/src/lib/sveltekit/cairn-admin.ts +8 -9
- package/src/lib/sveltekit/content-routes.ts +330 -221
- package/src/lib/sveltekit/guard.ts +15 -0
- package/src/lib/sveltekit/health.ts +13 -6
- package/src/lib/sveltekit/index.ts +2 -2
- package/src/lib/sveltekit/nav-routes.ts +33 -29
- package/src/lib/sveltekit/types.ts +5 -1
- package/src/lib/vite/index.ts +20 -11
- package/dist/content/schema.d.ts +0 -87
- package/dist/content/schema.js +0 -89
- package/dist/content/validate.d.ts +0 -17
- package/dist/content/validate.js +0 -93
- package/src/lib/content/schema.ts +0 -167
- package/src/lib/content/validate.ts +0 -90
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// cairn-cms: the fieldset primitive (Contract v2), the one live field system. A key-to-descriptor
|
|
2
|
+
// record becomes a schema carrying the descriptors as plain data, a server-derived validator, and the
|
|
3
|
+
// Standard Schema conformance property. The validator coerces per type, drops an empty optional field,
|
|
4
|
+
// and returns field-keyed errors or normalized data. The adapter contract, the editor form, the
|
|
5
|
+
// delivery inference, and the media extractor all read this.
|
|
6
|
+
import type { FieldDescriptor, ImageValue } from './fields.js';
|
|
7
|
+
import type { ValidationIssue, ValidationResult } from './types.js';
|
|
8
|
+
import type { StandardInput, StandardSchemaV1 } from './standard-schema.js';
|
|
9
|
+
import { datetimeInputValue, dateInputValue, isCalendarDate, referenceIdsFromValue } from './frontmatter.js';
|
|
10
|
+
import { compilePattern, dateBoundsError, patternError, stringLengthError } from './field-rules.js';
|
|
11
|
+
import { isValidId } from './ids.js';
|
|
12
|
+
|
|
13
|
+
/** Accept any URL using http or https with a non-empty rest, mirroring the conservative form check. */
|
|
14
|
+
const URL_RE = /^https?:\/\/\S+$/;
|
|
15
|
+
/** Accept a single address conservatively: exactly one at-sign and a dotted domain, nothing more. */
|
|
16
|
+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Function-valued behavior a field descriptor cannot carry as plain data, keyed by field name. A
|
|
20
|
+
* `validate` runs cross-field after per-field coercion; an `itemLabel` derives an array row's label.
|
|
21
|
+
* Resident in the app bundle, never in the `load` payload.
|
|
22
|
+
*/
|
|
23
|
+
export interface FieldBehavior {
|
|
24
|
+
/** A cross-field validator: returns an error string, or null when valid. `siblings` is the raw input record. */
|
|
25
|
+
validate?(value: unknown, siblings: Record<string, unknown>): string | null;
|
|
26
|
+
/** Derive an array row's label from its item value and zero-based index. */
|
|
27
|
+
itemLabel?(item: Record<string, unknown>, index: number): string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The behavior table co-bundled with a fieldset, keyed by field name. Empty for a behavior-free fieldset. */
|
|
31
|
+
export type BehaviorTable = Record<string, FieldBehavior>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Options for `fieldset`. `refine` runs after the per-field coercion and constraints pass, for
|
|
35
|
+
* cross-field and body-dependent checks. It is validation-only: it returns field-keyed errors to
|
|
36
|
+
* merge, or nothing, and never transforms the data. Server-only, since it may carry closures.
|
|
37
|
+
*/
|
|
38
|
+
export interface FieldsetOptions {
|
|
39
|
+
refine?: (data: Record<string, unknown>, body: string) => Record<string, string> | undefined;
|
|
40
|
+
/** Function-valued per-field behavior, keyed by field name. Each key must name a declared field. */
|
|
41
|
+
behavior?: BehaviorTable;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A concept's fieldset: the plain-data descriptors, the co-bundled behavior table, the server-derived
|
|
46
|
+
* validator, and the Standard Schema conformance property.
|
|
47
|
+
*/
|
|
48
|
+
export interface Fieldset<R extends Record<string, FieldDescriptor> = Record<string, FieldDescriptor>> {
|
|
49
|
+
/** The declared descriptors as plain serializable data, for the editor form. */
|
|
50
|
+
readonly fields: R;
|
|
51
|
+
/** Function-valued behavior keyed by field name; empty for a scalar-only fieldset. */
|
|
52
|
+
readonly behavior: BehaviorTable;
|
|
53
|
+
/** Validate raw frontmatter, returning field-keyed errors or the normalized data. */
|
|
54
|
+
validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
|
|
55
|
+
/** Standard Schema v1 conformance, for ecosystem interop. A thin adapter over `validate`. */
|
|
56
|
+
readonly '~standard': StandardSchemaV1<StandardInput, Record<string, unknown>>['~standard'];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Map one field descriptor to the TS type of its normalized value. number is number, boolean is
|
|
61
|
+
* boolean, image is the nested ImageValue object; a select with a literal option list is that
|
|
62
|
+
* option union, a multiselect with one is that union array (else string[]); everything else is a
|
|
63
|
+
* string.
|
|
64
|
+
*/
|
|
65
|
+
type ValueOf<D extends FieldDescriptor> = D extends { type: 'number' }
|
|
66
|
+
? number
|
|
67
|
+
: D extends { type: 'boolean' }
|
|
68
|
+
? boolean
|
|
69
|
+
: D extends { type: 'image' }
|
|
70
|
+
? ImageValue
|
|
71
|
+
: D extends { type: 'object'; fields: infer F extends Record<string, FieldDescriptor> }
|
|
72
|
+
? InferRecord<F>
|
|
73
|
+
: D extends { type: 'array'; item: infer I extends FieldDescriptor }
|
|
74
|
+
? ValueOf<I>[]
|
|
75
|
+
: D extends { type: 'select'; options: readonly (infer O extends string)[] }
|
|
76
|
+
? O
|
|
77
|
+
: D extends { type: 'multiselect'; options: readonly (infer O extends string)[] }
|
|
78
|
+
? O[]
|
|
79
|
+
: D extends { type: 'multiselect' }
|
|
80
|
+
? string[]
|
|
81
|
+
: string;
|
|
82
|
+
|
|
83
|
+
/** Flatten an intersection into a single readable object type. */
|
|
84
|
+
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
85
|
+
|
|
86
|
+
/** Drop an index signature so a captured literal record infers its own keys only, not `[x: string]`. */
|
|
87
|
+
type RemoveIndex<T> = {
|
|
88
|
+
[K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The normalized frontmatter type inferred from a fieldset's descriptor record. A descriptor
|
|
93
|
+
* declared `required: true` is a required key; every other descriptor is optional. The captured
|
|
94
|
+
* literal record carries an index signature (the constructor's `Record<string, FieldDescriptor>`
|
|
95
|
+
* intersected with the literal), so strip it first or every nested key would also infer `[x: string]`.
|
|
96
|
+
*/
|
|
97
|
+
type InferRecord<RR extends Record<string, FieldDescriptor>, R = RemoveIndex<RR>> = Prettify<
|
|
98
|
+
{ -readonly [K in keyof R as R[K] extends { required: true } ? K : never]: ValueOf<R[K] extends FieldDescriptor ? R[K] : never> } & {
|
|
99
|
+
-readonly [K in keyof R as R[K] extends { required: true } ? never : K]?: ValueOf<R[K] extends FieldDescriptor ? R[K] : never>;
|
|
100
|
+
}
|
|
101
|
+
>;
|
|
102
|
+
|
|
103
|
+
/** Extract the inferred frontmatter type from a `Fieldset`. */
|
|
104
|
+
export type InferFieldset<S> = S extends Fieldset<infer R> ? InferRecord<R> : never;
|
|
105
|
+
|
|
106
|
+
// Coerce a raw value to the trimmed string the empty check and constraints run on. A parsed value may
|
|
107
|
+
// arrive from parseMarkdown, not only a form string: a Date on a date or datetime field, a JS number on
|
|
108
|
+
// a number field. A finite 0 coerces to '0', never read as empty, since 0 is a real number a YAML scalar
|
|
109
|
+
// carries; a NaN or non-finite number stays '' and routes to the number error in validateField.
|
|
110
|
+
function coerceToText(type: FieldDescriptor['type'], value: unknown): string {
|
|
111
|
+
if (type === 'date' && value instanceof Date) return dateInputValue(value);
|
|
112
|
+
if (type === 'datetime' && value instanceof Date) return datetimeInputValue(value);
|
|
113
|
+
if (type === 'number' && typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
114
|
+
if (typeof value === 'string') return value.trim();
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The outcome of validating one field: the stored value when present, plus any located issues. */
|
|
119
|
+
interface FieldOutcome {
|
|
120
|
+
value?: unknown;
|
|
121
|
+
issues: ValidationIssue[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Build the structural key for a path by dropping numeric (row-index) segments, so a nested text
|
|
125
|
+
// field's compiled pattern is found regardless of which row it sits in: ['faq', 2, 'code'] -> 'faq.code'.
|
|
126
|
+
function structuralKey(path: (string | number)[]): string {
|
|
127
|
+
return path.filter((seg) => typeof seg === 'string').join('.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Validate one descriptor against its raw value and return its outcome. Empty or absent is
|
|
131
|
+
// "not provided" and is read BEFORE type coercion, uniformly: a required field returns an issue, an
|
|
132
|
+
// optional field drops (no value, no issue). Only a non-empty value is coerced. boolean is the
|
|
133
|
+
// exception: true stores true, anything else omits the value. number relies on the empty-first drop so
|
|
134
|
+
// an empty optional number never becomes Number('') === 0. A container (object, array) recurses one
|
|
135
|
+
// level, appending the leaf key or element index to `path` for each nested issue.
|
|
136
|
+
function validateField(
|
|
137
|
+
path: (string | number)[],
|
|
138
|
+
field: FieldDescriptor,
|
|
139
|
+
value: unknown,
|
|
140
|
+
patterns: Map<string, RegExp>,
|
|
141
|
+
): FieldOutcome {
|
|
142
|
+
const label = field.label ?? '';
|
|
143
|
+
|
|
144
|
+
// object: validate each leaf one level down, assembling a nested object value and concatenating
|
|
145
|
+
// issues with the leaf key appended to the path. An empty (all-leaves-dropped) object omits the
|
|
146
|
+
// value; a required empty object is an error on the object's own path.
|
|
147
|
+
if (field.type === 'object') {
|
|
148
|
+
const obj: Record<string, unknown> = {};
|
|
149
|
+
const issues: ValidationIssue[] = [];
|
|
150
|
+
const raw = value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
151
|
+
for (const [leafKey, leaf] of Object.entries(field.fields)) {
|
|
152
|
+
const outcome = validateField([...path, leafKey], leaf, raw[leafKey], patterns);
|
|
153
|
+
issues.push(...outcome.issues);
|
|
154
|
+
if ('value' in outcome) obj[leafKey] = outcome.value;
|
|
155
|
+
}
|
|
156
|
+
if (issues.length > 0) return { issues };
|
|
157
|
+
if (Object.keys(obj).length === 0) {
|
|
158
|
+
return field.required ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
|
|
159
|
+
}
|
|
160
|
+
return { value: obj, issues };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// array: a reference item keeps the shipped id-list path; any other item recurses per element with
|
|
164
|
+
// the element index appended to the path. A required empty list errors on the array's own path.
|
|
165
|
+
if (field.type === 'array') {
|
|
166
|
+
if (field.item.type === 'reference') {
|
|
167
|
+
// array(reference): coerceToText returns '' for an array, so the empty-first drop below would
|
|
168
|
+
// silently lose an optional list or falsely error a required one. The canonicalizer coerces a
|
|
169
|
+
// lone scalar to one element and a Date element to its id. Each element must pass isValidId (the
|
|
170
|
+
// item's reference rule this phase); a required empty list errors; the value is set only when the
|
|
171
|
+
// list is non-empty.
|
|
172
|
+
const list = referenceIdsFromValue(value);
|
|
173
|
+
if (field.required && list.length === 0) return { issues: [{ path, message: `${label} is required` }] };
|
|
174
|
+
const invalid = list.find((id) => !isValidId(id));
|
|
175
|
+
if (invalid !== undefined) return { issues: [{ path, message: `${label} is not a valid reference` }] };
|
|
176
|
+
return list.length > 0 ? { value: list, issues: [] } : { issues: [] };
|
|
177
|
+
}
|
|
178
|
+
const elements = Array.isArray(value) ? value : [];
|
|
179
|
+
const out: unknown[] = [];
|
|
180
|
+
const issues: ValidationIssue[] = [];
|
|
181
|
+
elements.forEach((element, i) => {
|
|
182
|
+
const outcome = validateField([...path, i], field.item, element, patterns);
|
|
183
|
+
issues.push(...outcome.issues);
|
|
184
|
+
if ('value' in outcome) out.push(outcome.value);
|
|
185
|
+
});
|
|
186
|
+
if (issues.length > 0) return { issues };
|
|
187
|
+
if (out.length === 0) {
|
|
188
|
+
return field.required ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
|
|
189
|
+
}
|
|
190
|
+
return { value: out, issues };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// boolean: presence is the value; an unchecked or absent box omits the value (no draft: false noise).
|
|
194
|
+
if (field.type === 'boolean') {
|
|
195
|
+
return value === true ? { value: true, issues: [] } : { issues: [] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// multiselect: a string array; drop empties, reject an unknown value when options is closed. An empty
|
|
199
|
+
// list omits the value (a required empty errors); the array path is the one non-string coercion. A
|
|
200
|
+
// lone non-empty scalar (a single tag a YAML scalar carries) coerces to a single-element list, rather
|
|
201
|
+
// than dropping to [] and reading as "required" while present. An empty string or a
|
|
202
|
+
// non-string-non-array stays the empty list.
|
|
203
|
+
if (field.type === 'multiselect') {
|
|
204
|
+
let raw: string[];
|
|
205
|
+
if (Array.isArray(value)) raw = value.map(String);
|
|
206
|
+
else if (typeof value === 'string' && value.trim() !== '') raw = [value.trim()];
|
|
207
|
+
else raw = [];
|
|
208
|
+
const list = raw.map((v) => v.trim()).filter((v) => v !== '');
|
|
209
|
+
if (field.required && list.length === 0) {
|
|
210
|
+
return { issues: [{ path, message: `${label} is required` }] };
|
|
211
|
+
}
|
|
212
|
+
const { options } = field;
|
|
213
|
+
if (options) {
|
|
214
|
+
const unknown = list.find((v) => !options.includes(v));
|
|
215
|
+
if (unknown !== undefined) {
|
|
216
|
+
return { issues: [{ path, message: `${label} contains an unknown value: ${unknown}` }] };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return list.length > 0 ? { value: list, issues: [] } : { issues: [] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// image: the nested object arm, dropping the value on empty src. Default a missing alt to empty (alt
|
|
223
|
+
// is debt, never a save block), trim and drop a blank caption, keep decorative only when an explicit
|
|
224
|
+
// true. A required image with an empty src is the one error this arm raises.
|
|
225
|
+
if (field.type === 'image') {
|
|
226
|
+
let src = '';
|
|
227
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
228
|
+
const obj = value as Record<string, unknown>;
|
|
229
|
+
src = typeof obj.src === 'string' ? obj.src.trim() : '';
|
|
230
|
+
if (src !== '') {
|
|
231
|
+
const normalized: ImageValue = {
|
|
232
|
+
src,
|
|
233
|
+
alt: typeof obj.alt === 'string' ? obj.alt : '',
|
|
234
|
+
};
|
|
235
|
+
const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
|
|
236
|
+
if (caption !== '') normalized.caption = caption;
|
|
237
|
+
if (obj.decorative === true) normalized.decorative = true;
|
|
238
|
+
return { value: normalized, issues: [] };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return field.required && src === '' ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Every other type is "not provided when empty" first, then coerced. `coerceToText` turns a parsed
|
|
245
|
+
// value into its string form BEFORE the empty check, so a real parsed value (a Date on a date or
|
|
246
|
+
// datetime field, a number on a number field) is not read as empty.
|
|
247
|
+
const text = coerceToText(field.type, value);
|
|
248
|
+
if (text === '') {
|
|
249
|
+
return field.required ? { issues: [{ path, message: `${label} is required` }] } : { issues: [] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const key = structuralKey(path);
|
|
253
|
+
switch (field.type) {
|
|
254
|
+
case 'number': {
|
|
255
|
+
const n = Number(text);
|
|
256
|
+
// Reject NaN and the non-finite values Number() yields for "Infinity"/"1e400", which an
|
|
257
|
+
// isNaN check alone would pass through and commit as a YAML .inf scalar.
|
|
258
|
+
if (!Number.isFinite(n)) return { issues: [{ path, message: `${label} must be a number` }] };
|
|
259
|
+
if (field.integer && !Number.isInteger(n)) return { issues: [{ path, message: `${label} must be a whole number` }] };
|
|
260
|
+
if (field.min != null && n < field.min) return { issues: [{ path, message: `${label} must be at least ${field.min}` }] };
|
|
261
|
+
if (field.max != null && n > field.max) return { issues: [{ path, message: `${label} must be at most ${field.max}` }] };
|
|
262
|
+
return { value: n, issues: [] };
|
|
263
|
+
}
|
|
264
|
+
case 'select': {
|
|
265
|
+
if (!field.options.includes(text)) return { issues: [{ path, message: `${label} contains an unknown value: ${text}` }] };
|
|
266
|
+
return { value: text, issues: [] };
|
|
267
|
+
}
|
|
268
|
+
case 'url': {
|
|
269
|
+
if (!URL_RE.test(text)) return { issues: [{ path, message: `${label} is not a valid URL` }] };
|
|
270
|
+
return { value: text, issues: [] };
|
|
271
|
+
}
|
|
272
|
+
case 'email': {
|
|
273
|
+
if (!EMAIL_RE.test(text)) return { issues: [{ path, message: `${label} is not a valid email address` }] };
|
|
274
|
+
return { value: text, issues: [] };
|
|
275
|
+
}
|
|
276
|
+
case 'date': {
|
|
277
|
+
if (!isCalendarDate(text)) return { issues: [{ path, message: `${label} must be a valid date (YYYY-MM-DD)` }] };
|
|
278
|
+
const boundsError = dateBoundsError(text, field, label);
|
|
279
|
+
if (boundsError != null) return { issues: [{ path, message: boundsError }] };
|
|
280
|
+
return { value: text, issues: [] };
|
|
281
|
+
}
|
|
282
|
+
case 'reference': {
|
|
283
|
+
// A scalar edge: the empty-first drop above already handled an absent optional, so a non-empty
|
|
284
|
+
// value must be a valid id. An invalid token is a corrupted edge, not a coercible value.
|
|
285
|
+
if (!isValidId(text)) return { issues: [{ path, message: `${label} is not a valid reference` }] };
|
|
286
|
+
return { value: text, issues: [] };
|
|
287
|
+
}
|
|
288
|
+
default: {
|
|
289
|
+
// text, textarea, datetime: a trimmed non-empty string. text and textarea also enforce the
|
|
290
|
+
// string-length and pattern constraints (v1 parity); datetime stays a plain string for now,
|
|
291
|
+
// since its bounds are out of scope this pass and v1 has no datetime equivalent to match.
|
|
292
|
+
if (field.type === 'text' || field.type === 'textarea') {
|
|
293
|
+
const lengthError = stringLengthError(text, field, label);
|
|
294
|
+
if (lengthError != null) return { issues: [{ path, message: lengthError }] };
|
|
295
|
+
const formatError = patternError(text, patterns.get(key), label);
|
|
296
|
+
if (formatError != null) return { issues: [{ path, message: formatError }] };
|
|
297
|
+
}
|
|
298
|
+
return { value: text, issues: [] };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// At most one image field may feed the social card, so the og:image is unambiguous. A v2 fieldset
|
|
304
|
+
// marks that field with an explicit `seo: true`; there is no field-name default, since the record key
|
|
305
|
+
// is arbitrary. Two seo images is a site config error, so fail loudly at declaration (v1 parity).
|
|
306
|
+
// The delivery seo reader resolves the social card off a hardcoded top-level key list, so a nested
|
|
307
|
+
// seo image cannot resolve at delivery; this phase forbids seo: true inside any container and defers
|
|
308
|
+
// nested seo to the pass that generalizes delivery seo resolution.
|
|
309
|
+
function checkSeoImageFields(record: Record<string, FieldDescriptor>): void {
|
|
310
|
+
const seo: string[] = [];
|
|
311
|
+
for (const [key, field] of Object.entries(record)) {
|
|
312
|
+
if (field.type === 'image' && field.seo === true) seo.push(`"${key}"`);
|
|
313
|
+
else if (field.type === 'object') {
|
|
314
|
+
for (const [leafKey, leaf] of Object.entries(field.fields)) {
|
|
315
|
+
if (leaf.type === 'image' && leaf.seo === true) {
|
|
316
|
+
throw new Error(`cairn: the image "${key}.${leafKey}" sets seo: true, but a nested seo image is not supported this phase. Put the social-card image at the top level.`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} else if (field.type === 'array') {
|
|
320
|
+
const item = field.item;
|
|
321
|
+
const nested = (item.type === 'image' && item.seo === true)
|
|
322
|
+
|| (item.type === 'object' && Object.values(item.fields).some((l) => l.type === 'image' && l.seo === true));
|
|
323
|
+
if (nested) {
|
|
324
|
+
throw new Error(`cairn: the array field "${key}" declares an seo image, but an array would mean one social card per row. Put seo: true on a top-level image.`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (seo.length > 1) {
|
|
329
|
+
throw new Error(`cairn: a concept declares at most one SEO image field, but found ${seo.length} (${seo.join(', ')}). Set seo: false on all but one.`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// A leaf is any non-container descriptor. A container (object, array) may hold leaves one level deep only.
|
|
334
|
+
function isLeaf(field: FieldDescriptor): boolean {
|
|
335
|
+
return field.type !== 'object' && field.type !== 'array';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Enforce the one-level nesting cap, the no-reference-in-object deferral, and the no-dot-in-key rule, all
|
|
339
|
+
// loudly at declaration. A deeper nesting, a nested reference, or a dotted key would otherwise mis-save or
|
|
340
|
+
// mis-decode at the edge, so fail here.
|
|
341
|
+
function checkContainerNesting(record: Record<string, FieldDescriptor>): void {
|
|
342
|
+
const checkKey = (k: string, where: string): void => {
|
|
343
|
+
if (k.includes('.')) throw new Error(`cairn: ${where} "${k}" must not contain a dot; field keys address the nested form by dotted path.`);
|
|
344
|
+
};
|
|
345
|
+
const checkObjectLeaves = (fieldsRecord: Record<string, FieldDescriptor>, where: string): void => {
|
|
346
|
+
for (const [k, leaf] of Object.entries(fieldsRecord)) {
|
|
347
|
+
checkKey(k, where);
|
|
348
|
+
if (!isLeaf(leaf)) {
|
|
349
|
+
throw new Error(`cairn: ${where} "${k}" must be a leaf field; containers nest one level only.`);
|
|
350
|
+
}
|
|
351
|
+
if (leaf.type === 'reference') {
|
|
352
|
+
throw new Error(`cairn: ${where} "${k}" is a reference; a reference inside an object is not supported this phase. Model it as the parent's own concept, or use a top-level array(reference).`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
for (const [key, field] of Object.entries(record)) {
|
|
357
|
+
checkKey(key, 'the field');
|
|
358
|
+
if (field.type === 'object') {
|
|
359
|
+
checkObjectLeaves(field.fields, `the object field "${key}" sub-field`);
|
|
360
|
+
} else if (field.type === 'array') {
|
|
361
|
+
const item = field.item;
|
|
362
|
+
if (item.type === 'object') {
|
|
363
|
+
checkObjectLeaves(item.fields, `the array field "${key}" row sub-field`);
|
|
364
|
+
} else if (!isLeaf(item)) {
|
|
365
|
+
throw new Error(`cairn: the array field "${key}" item must be a leaf or a flat object; an array of arrays is not allowed.`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Build a fieldset from a key-to-descriptor record. The returned schema carries the descriptors, a
|
|
373
|
+
* server-derived validator that coerces per type and returns field-keyed errors or normalized data,
|
|
374
|
+
* and the Standard Schema conformance property whose issues map each error to a single-segment path.
|
|
375
|
+
*/
|
|
376
|
+
export function fieldset<const R extends Record<string, FieldDescriptor>>(
|
|
377
|
+
record: R,
|
|
378
|
+
options: FieldsetOptions = {},
|
|
379
|
+
): Fieldset<R> {
|
|
380
|
+
checkSeoImageFields(record);
|
|
381
|
+
checkContainerNesting(record);
|
|
382
|
+
for (const key of Object.keys(options.behavior ?? {})) {
|
|
383
|
+
if (!(key in record)) throw new Error(`cairn: behavior names "${key}", which is not a declared field.`);
|
|
384
|
+
}
|
|
385
|
+
// Compile each text/textarea pattern once at construction, so a malformed pattern fails loudly here
|
|
386
|
+
// (mirroring v1's compilePatterns) rather than on every save. Keyed by the structural path
|
|
387
|
+
// ('faq.code', 'address.zip') so a nested leaf's compiled pattern is found regardless of row index,
|
|
388
|
+
// recursing one level into an object and an array(object).
|
|
389
|
+
const patterns = new Map<string, RegExp>();
|
|
390
|
+
const compilePatternsIn = (rec: Record<string, FieldDescriptor>, prefix: string[]): void => {
|
|
391
|
+
for (const [k, f] of Object.entries(rec)) {
|
|
392
|
+
if ((f.type === 'text' || f.type === 'textarea') && f.pattern != null) {
|
|
393
|
+
patterns.set([...prefix, k].join('.'), compilePattern(f.pattern, f.label));
|
|
394
|
+
} else if (f.type === 'object') {
|
|
395
|
+
compilePatternsIn(f.fields, [...prefix, k]);
|
|
396
|
+
} else if (f.type === 'array' && f.item.type === 'object') {
|
|
397
|
+
compilePatternsIn(f.item.fields, [...prefix, k]);
|
|
398
|
+
} else if (f.type === 'array' && (f.item.type === 'text' || f.item.type === 'textarea') && f.item.pattern != null) {
|
|
399
|
+
patterns.set([...prefix, k].join('.'), compilePattern(f.item.pattern, f.item.label));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
compilePatternsIn(record, []);
|
|
404
|
+
const validate = (frontmatter: Record<string, unknown>, body: string): ValidationResult => {
|
|
405
|
+
const data: Record<string, unknown> = {};
|
|
406
|
+
const issues: ValidationIssue[] = [];
|
|
407
|
+
for (const [key, field] of Object.entries(record)) {
|
|
408
|
+
const outcome = validateField([key], field, frontmatter[key], patterns);
|
|
409
|
+
issues.push(...outcome.issues);
|
|
410
|
+
if ('value' in outcome) data[key] = outcome.value;
|
|
411
|
+
if (outcome.issues.length === 0 && options.behavior?.[key]?.validate) {
|
|
412
|
+
let message: string | null = null;
|
|
413
|
+
try {
|
|
414
|
+
message = options.behavior[key].validate!('value' in outcome ? outcome.value : undefined, frontmatter);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
console.warn(`cairn: behavior.validate for field "${key}" threw; treating it as valid.`, err);
|
|
417
|
+
}
|
|
418
|
+
if (typeof message === 'string') issues.push({ path: [key], message });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (issues.length > 0) {
|
|
422
|
+
// Back-compat: derive the flat errors map from the located issues, keying each top-level field by
|
|
423
|
+
// the first message that mentions it, so a consumer reading `errors[fieldName]` still works.
|
|
424
|
+
const errors: Record<string, string> = {};
|
|
425
|
+
for (const issue of issues) {
|
|
426
|
+
const top = String(issue.path[0]);
|
|
427
|
+
if (!(top in errors)) errors[top] = issue.message;
|
|
428
|
+
}
|
|
429
|
+
return { ok: false, errors, issues };
|
|
430
|
+
}
|
|
431
|
+
const refined = options.refine?.(data, body);
|
|
432
|
+
if (refined && Object.keys(refined).length > 0) {
|
|
433
|
+
return { ok: false, errors: refined, issues: Object.entries(refined).map(([k, m]) => ({ path: [k], message: m })) };
|
|
434
|
+
}
|
|
435
|
+
return { ok: true, data };
|
|
436
|
+
};
|
|
437
|
+
const standard: StandardSchemaV1<StandardInput, Record<string, unknown>>['~standard'] = {
|
|
438
|
+
version: 1,
|
|
439
|
+
vendor: 'cairn',
|
|
440
|
+
validate: (value) => {
|
|
441
|
+
const { frontmatter = {}, body = '' } = (value ?? {}) as Partial<StandardInput>;
|
|
442
|
+
const result = validate(frontmatter ?? {}, body ?? '');
|
|
443
|
+
return result.ok
|
|
444
|
+
? { value: result.data }
|
|
445
|
+
: { issues: result.issues ?? Object.entries(result.errors).map(([key, message]) => ({ message, path: [key] })) };
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
return { fields: record, behavior: options.behavior ?? {}, validate, '~standard': standard };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Resolve each descriptor's `default` to a form-initial value, so a fresh entry opens prefilled. The
|
|
453
|
+
* `'today'` sentinel on a date field resolves through the passed `now` to its `YYYY-MM-DD` form; an
|
|
454
|
+
* empty-string or `false` default is omitted, so an untouched field commits no key (the
|
|
455
|
+
* minimal-frontmatter invariant). With no `now`, a `'today'` default is omitted rather than read off
|
|
456
|
+
* a real clock, since library code must stay deterministic and Workers-safe.
|
|
457
|
+
*/
|
|
458
|
+
export function initialValues(fieldset: Fieldset, now?: Date): Record<string, unknown> {
|
|
459
|
+
const values: Record<string, unknown> = {};
|
|
460
|
+
for (const [key, field] of Object.entries(fieldset.fields)) {
|
|
461
|
+
const value = field.default;
|
|
462
|
+
if (value === undefined || value === '' || value === false) continue;
|
|
463
|
+
if (field.type === 'date' && value === 'today') {
|
|
464
|
+
if (now) values[key] = now.toISOString().slice(0, 10);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
values[key] = value;
|
|
468
|
+
}
|
|
469
|
+
return values;
|
|
470
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// cairn-cms: the shared frontmatter-region helpers. A byte-preserving rewriter (media-rewrite's
|
|
2
|
+
// `repointMediaRef`/`fillAltForHash`, references' `rewriteFrontmatterReference`) splices a
|
|
3
|
+
// frontmatter value by source offset rather than round-tripping through gray-matter (which reformats
|
|
4
|
+
// YAML and is not byte stable). These helpers locate the `---` fenced block, split it into lines with
|
|
5
|
+
// absolute offsets, and find a top-level key's inclusive line range, so every such rewriter agrees on
|
|
6
|
+
// the boundary, the CRLF handling, and the colon-anchored key scan.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The split of fmBlock into its lines, each with its block-relative start and end offsets (the end
|
|
10
|
+
* is the index of the trailing newline, or the block length for the last line). Block offsets are
|
|
11
|
+
* already absolute since the frontmatter leads the document.
|
|
12
|
+
*/
|
|
13
|
+
export interface FmLine {
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Split a leading frontmatter block off the markdown. `fmBlock` is the `---` fenced block including
|
|
20
|
+
* both fences and the trailing newline (empty when there is none); `body` is everything after it.
|
|
21
|
+
* The block leads the document, so a frontmatter offset is already absolute and a body offset needs
|
|
22
|
+
* `fmBlock.length` added. Shared by every arm so they agree on the boundary.
|
|
23
|
+
*/
|
|
24
|
+
export function splitFrontmatter(markdown: string): { fmBlock: string; body: string } {
|
|
25
|
+
const m = markdown.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
|
26
|
+
const fmBlock = m ? m[0] : '';
|
|
27
|
+
return { fmBlock, body: markdown.slice(fmBlock.length) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split fmBlock into lines once, so the locator helpers walk a shared structure instead of
|
|
32
|
+
* re-scanning the block per call.
|
|
33
|
+
*/
|
|
34
|
+
export function fmLines(fmBlock: string): FmLine[] {
|
|
35
|
+
const lines: FmLine[] = [];
|
|
36
|
+
let pos = 0;
|
|
37
|
+
while (pos <= fmBlock.length) {
|
|
38
|
+
const nl = fmBlock.indexOf('\n', pos);
|
|
39
|
+
const end = nl === -1 ? fmBlock.length : nl;
|
|
40
|
+
lines.push({ start: pos, end });
|
|
41
|
+
if (nl === -1) break;
|
|
42
|
+
pos = nl + 1;
|
|
43
|
+
}
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The inclusive line-index range `[lo, hi]` of the block-style mapping a top-level key opens: the
|
|
49
|
+
* line `^<key>:` at indent 0 through the last line before the next top-level key (or the document
|
|
50
|
+
* end). A flow-style value (`key: { ... }` all on one line) yields a single-line range. Returns null
|
|
51
|
+
* when the key has no top-level line, which a malformed or non-canonical block can cause. Scoping the
|
|
52
|
+
* per-key search to this range is what lets two image fields that share one hash, or an image field
|
|
53
|
+
* whose hash also appears in a sibling text value, resolve to distinct, correct spans.
|
|
54
|
+
*/
|
|
55
|
+
export function frontmatterKeyRange(
|
|
56
|
+
lines: FmLine[],
|
|
57
|
+
fmBlock: string,
|
|
58
|
+
key: string,
|
|
59
|
+
): [number, number] | null {
|
|
60
|
+
const opener = new RegExp(`^${escapeForRegExp(key)}:`);
|
|
61
|
+
const topLevelKey = /^[^\s#][^:]*:/;
|
|
62
|
+
const isBoundary = (i: number) => {
|
|
63
|
+
const text = fmBlock.slice(lines[i].start, lines[i].end);
|
|
64
|
+
// A new top-level key or the closing `---` fence ends the current key's block.
|
|
65
|
+
return topLevelKey.test(text) || text === '---';
|
|
66
|
+
};
|
|
67
|
+
let lo = -1;
|
|
68
|
+
for (let i = 1; i < lines.length - 1; i += 1) {
|
|
69
|
+
// Skip the leading `---` fence (line 0) and the trailing empty line after the closing fence.
|
|
70
|
+
if (opener.test(fmBlock.slice(lines[i].start, lines[i].end))) {
|
|
71
|
+
lo = i;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (lo === -1) return null;
|
|
76
|
+
let hi = lo;
|
|
77
|
+
for (let i = lo + 1; i < lines.length - 1; i += 1) {
|
|
78
|
+
if (isBoundary(i)) break;
|
|
79
|
+
hi = i;
|
|
80
|
+
}
|
|
81
|
+
return [lo, hi];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Escape a literal string for safe interpolation into a RegExp source. A key name or an indent is
|
|
86
|
+
* matched literally, so its characters must not act as metacharacters.
|
|
87
|
+
*/
|
|
88
|
+
export function escapeForRegExp(literal: string): string {
|
|
89
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
90
|
+
}
|