@valbuild/core 0.126.0 → 0.127.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.
Files changed (27) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/dist/declarations/src/index.d.ts +5 -1
  3. package/dist/declarations/src/initSchema.d.ts +16 -0
  4. package/dist/declarations/src/locale.d.ts +55 -0
  5. package/dist/declarations/src/schema/array.d.ts +4 -0
  6. package/dist/declarations/src/schema/declaredKeys.d.ts +37 -0
  7. package/dist/declarations/src/schema/discriminatedUnion.d.ts +4 -0
  8. package/dist/declarations/src/schema/discriminatedUnionBranch.d.ts +20 -0
  9. package/dist/declarations/src/schema/enum.d.ts +9 -0
  10. package/dist/declarations/src/schema/index.d.ts +116 -3
  11. package/dist/declarations/src/schema/literal.d.ts +2 -0
  12. package/dist/declarations/src/schema/locale.d.ts +132 -0
  13. package/dist/declarations/src/schema/object.d.ts +7 -0
  14. package/dist/declarations/src/schema/record.d.ts +51 -1
  15. package/dist/declarations/src/schema/settings.d.ts +54 -1
  16. package/dist/declarations/src/schema/validation/ValidationFix.d.ts +1 -1
  17. package/dist/declarations/src/source/settings.d.ts +51 -0
  18. package/dist/{index-cd25c2dc.esm.js → index-5a6e6398.esm.js} +1035 -54
  19. package/dist/{index-b41b638f.cjs.dev.js → index-889ac441.cjs.dev.js} +1040 -53
  20. package/dist/{index-6db78f3c.cjs.prod.js → index-d44deccb.cjs.prod.js} +1040 -53
  21. package/dist/valbuild-core.cjs.dev.js +7 -1
  22. package/dist/valbuild-core.cjs.prod.js +7 -1
  23. package/dist/valbuild-core.esm.js +1 -1
  24. package/package.json +1 -1
  25. package/patch/dist/valbuild-core-patch.cjs.dev.js +1 -1
  26. package/patch/dist/valbuild-core-patch.cjs.prod.js +1 -1
  27. package/patch/dist/valbuild-core-patch.esm.js +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,189 @@
1
1
  # @valbuild/core
2
2
 
3
+ ## 0.127.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#608](https://github.com/valbuild/val/pull/608) [`7fa8699`](https://github.com/valbuild/val/commit/7fa869974bc895af992a7d5c18b76253636d7d65) Thanks [@freekh](https://github.com/freekh)! - A record whose schema declares its keys now holds every one of them.
8
+
9
+ Two key schemas enumerate their keys: `s.locale()`, whose set is the project's
10
+ `locales.available`, and a union of literals. For those, the keys are part of the
11
+ schema, so a missing one is a hole in the content rather than content nobody has
12
+ written yet — and validation now says so, naming what is missing.
13
+
14
+ ```typescript
15
+ s.record(s.locale(), s.object({ title: s.string() }));
16
+ // Missing key: 'nb-NO'. This record's keys are declared by its schema, so
17
+ // every one of them is an entry — an entry nobody has written yet is null,
18
+ // not absent.
19
+ ```
20
+
21
+ **An entry nobody has written yet is `null`.** Not an absent key: a null entry is
22
+ data you can count, filter and see in a diff, and it means half-translated
23
+ content stays _valid_ rather than blocking a publish. The value type of such a
24
+ record widens by `null` to match, so writing one in a `.val.ts` type-checks:
25
+
26
+ ```typescript
27
+ c.define(
28
+ "/content/jacket.val.ts",
29
+ s.record(s.locale(), s.object({ title: s.string() })),
30
+ {
31
+ "en-US": { title: "Winter jacket" },
32
+ "nb-NO": null, // nobody has translated this yet
33
+ },
34
+ );
35
+ ```
36
+
37
+ **This changes `s.record(s.union(...), item)`**, and closes a gap that was
38
+ already there: `s.record(s.union(s.literal("a"), s.literal("b")), item)` types as
39
+ `Record<"a" | "b", T>`, so TypeScript demanded both keys while the validator only
40
+ checked the ones present. It now checks them too, and — as above — accepts `null`
41
+ for an entry that has not been filled in. If you have such a record with keys
42
+ missing, validation will report them; adding the keys with `null` values is the
43
+ fix, and creating one from the Studio does it for you.
44
+
45
+ `emptyOf` creates these records with every key already in them rather than
46
+ empty, since an empty one is already missing keys. In the Studio use the
47
+ `useEmptyOf()` hook rather than importing `emptyOf` directly: a locale record's
48
+ keys are in the settings module, and the hook is what has read it.
49
+
50
+ - [#608](https://github.com/valbuild/val/pull/608) [`5b7fe05`](https://github.com/valbuild/val/commit/5b7fe05cec6365f9cd1de9ba31e65ed4a87edb63) Thanks [@freekh](https://github.com/freekh)! - `s.locale()`: one of the project's languages.
51
+
52
+ The languages themselves are declared in the settings module (`locales.available`);
53
+ this says that a value is one of them.
54
+
55
+ ```typescript
56
+ // a field: everything in this entry is in this language
57
+ s.record(s.string(), s.object({ locale: s.locale(), title: s.string() }));
58
+
59
+ // a key: one entry per language
60
+ s.record(s.locale(), s.object({ title: s.string() }));
61
+ ```
62
+
63
+ Every locale in content is checked against the project's list, the way `keyOf`
64
+ and `route` are checked against what they point at. An undeclared language names
65
+ the ones the project has; a project that has declared none is told to declare
66
+ them rather than told the value is wrong.
67
+
68
+ A locale is stored as the tag itself — the value in content is `nb-NO`, and a
69
+ record keyed by `s.locale()` has `nb-NO` as its key. Spelling one differently
70
+ where it is stored (`/no/…` as a URL segment) is a real need and is deliberately
71
+ not in this release: it changes what is accepted as well as what is shown, so it
72
+ is being designed on its own rather than folded in here.
73
+
74
+ A locale is **never stega encoded**: it ends up in `<html lang>`, in `hreflang`
75
+ and in `Intl` constructors, none of which survive invisible characters.
76
+
77
+ `assistant.translation` joins the settings module alongside `context` and `tone`
78
+ — a note per language, keyed by language, so only the target language's rules are
79
+ sent when translating into it.
80
+
81
+ - [#608](https://github.com/valbuild/val/pull/608) [`88262ac`](https://github.com/valbuild/val/commit/88262ac8db068650a664981ef73556457d87741a) Thanks [@freekh](https://github.com/freekh)! - Locale scopes: a subtree in one language, and one function that answers which.
82
+
83
+ A **locale scope** is content governed by a single language. Two things open one
84
+ today (a third, locale segments in routes, follows):
85
+
86
+ ```typescript
87
+ // a locale field: this object and everything below it is in one language
88
+ s.object({ locale: s.locale(), title: s.string(), body: s.richtext() });
89
+
90
+ // a locale-keyed record: each entry is in the language its key names
91
+ s.record(s.locale(), s.object({ title: s.string() }));
92
+ ```
93
+
94
+ **A scope may not contain another scope**, and an object may have only one
95
+ locale field. Both are reported as schema errors, naming what to move:
96
+
97
+ ```
98
+ An object can be in one language, so it can have one locale field.
99
+ Found 'locale', 'language'.
100
+
101
+ Everything here is already in one language, so 'byLanguage' cannot set
102
+ another. Move the locale-keyed record out of this object, or take the outer
103
+ one away.
104
+ ```
105
+
106
+ A scope three levels deep is reported once, by the scope immediately enclosing
107
+ it, rather than by every ancestor.
108
+
109
+ The rule is **validated rather than typed**. Expressing "no scope below this
110
+ one" as a type constraint means threading it through every schema class's type
111
+ parameter, and the errors a recursive constraint like that produces name the
112
+ whole tree — an unrelated typo in a `.val.ts` would print pages.
113
+
114
+ `localeAt(path, snapshot)` (from `@valbuild/shared/internal`) answers which
115
+ language governs a path, and is the one implementation of that question, so the
116
+ Studio, the server and the validation worker cannot disagree. It returns the
117
+ tag, which is what `<html lang>`, `Intl` and `locales.available` all want.
118
+
119
+ It answers `null` where no scope governs the path, where the project has
120
+ declared no languages, and where a locale field holds something that is not one
121
+ of them — validation is already reporting the last, and guessing would put a
122
+ language in `<html lang>` that nobody chose.
123
+
124
+ - [#608](https://github.com/valbuild/val/pull/608) [`29811c3`](https://github.com/valbuild/val/commit/29811c3f8c7e001a950e6f4833af6888e6a4efea) Thanks [@freekh](https://github.com/freekh)! - Settings: declare the languages a project publishes.
125
+
126
+ A new `locales` section in the settings module says which languages a project
127
+ has:
128
+
129
+ ```typescript
130
+ export default c.define("/settings.val.ts", s.settings(), {
131
+ locales: {
132
+ available: ["en-US", "fr-FR", "nb-NO"],
133
+ },
134
+ });
135
+ ```
136
+
137
+ The order is the project's own and is kept rather than sorted: it is the order
138
+ of the Studio's locale picker and of the rows in a locale-keyed record. There
139
+ is no default language — every locale-specific field asks which language it is
140
+ in, and a default is the answer that lets that question go unanswered.
141
+
142
+ Like every other settings section it is optional, so a project that is not
143
+ translated writes nothing and sees nothing: no locale controls appear anywhere
144
+ until `available` has something in it.
145
+
146
+ This is content rather than configuration, and deliberately: which languages a
147
+ site has is a decision the people who write it make, and under a build-time
148
+ constant it took a developer and a deploy. It is the same move `assistant`
149
+ already makes with `enabled`.
150
+
151
+ Tags are BCP 47 (`en-US`, `nb-NO`), checked through `Intl.getCanonicalLocales` —
152
+ the same implementation `<html lang>` and every `Intl` constructor use — and
153
+ they have to be in canonical form. `nb-no` parses, but nothing else in the stack
154
+ agrees it is the same string as `nb-NO`, and a locale is compared as a string
155
+ everywhere it is used. Validation names the spelling to use, and reports a
156
+ language declared twice on the repeat rather than on the list — that is the row
157
+ to delete, and a message on the list itself would not say which.
158
+
159
+ Edited under Settings → Locales in the Studio, which names each language in its
160
+ own language.
161
+
162
+ ### Patch Changes
163
+
164
+ - [#665](https://github.com/valbuild/val/pull/665) [`7072e07`](https://github.com/valbuild/val/commit/7072e07623c953a09ac14388ae22dada0b431ce3) Thanks [@freekh](https://github.com/freekh)! - `.nullable()` no longer drops the field's `.validate(...)` functions.
165
+
166
+ `.nullable()` returns a copy of the schema, and most of the schema classes built
167
+ that copy with an empty list of custom validators — so a validator declared
168
+ before the `.nullable()` was silently thrown away:
169
+
170
+ ```ts
171
+ s.number()
172
+ .validate((n) => (n > 100 ? "Too big" : false))
173
+ .nullable(); // the validator never ran
174
+ ```
175
+
176
+ `array`, `object`, `discriminatedUnion`, `enum`, `number`, `boolean`, `literal`,
177
+ `keyOf`, `date`, `dateTime`, `code`, `color` and `richtext` were affected;
178
+ `string`, `record`, `route`, `file` and `image` already carried them over, which
179
+ is why the bug was easy to miss. Validators now survive `.nullable()` on every
180
+ schema, in either order.
181
+
182
+ A validator on a nullable schema is called with `null` when the value is unset,
183
+ rather than being skipped — the same thing the schemas that never dropped them
184
+ have always done. If yours was written before the `.nullable()`, its argument is
185
+ typed as non-null even though `null` can reach it, so guard for it.
186
+
3
187
  ## 0.126.0
4
188
 
5
189
  ### Minor Changes
@@ -54,8 +54,12 @@ export { type SerializedImageSchema, ImageSchema } from "./schema/image.js";
54
54
  export { type SerializedFileSchema, FileSchema } from "./schema/file.js";
55
55
  export { type SerializedDateSchema, DateSchema } from "./schema/date.js";
56
56
  export { type SerializedSettingsSchema, SettingsSchema, } from "./schema/settings.js";
57
- export { type SettingsSource, type AssistantSettingsSource, type AssistantAvailability, ASSISTANT_SETTINGS_MAX_LENGTH, assistantAvailability, } from "./source/settings.js";
57
+ export { type SettingsSource, type AssistantSettingsSource, type LocalesSettingsSource, type AssistantAvailability, ASSISTANT_SETTINGS_MAX_LENGTH, assistantAvailability, } from "./source/settings.js";
58
58
  export { type ResolvedSettingsModule, SETTINGS_MODULE_CONVENTION, isRootModuleFilePath, resolveSettingsModule, } from "./settingsModule.js";
59
+ export { type Locale, localeTagError, localeOfValue } from "./locale.js";
60
+ export { type SerializedLocaleSchema, LocaleSchema } from "./schema/locale.js";
61
+ export { type DeclaredKeySet, declaredKeySetOf, missingDeclaredKeys, } from "./schema/declaredKeys.js";
62
+ export { discriminatedUnionBranchOf } from "./schema/discriminatedUnionBranch.js";
59
63
  export { type SerializedColorSchema, type ColorOptions, ColorSchema, } from "./schema/color.js";
60
64
  export { type SerializedCodeSchema, type CodeOptions, type CodeLanguage, CodeSchema, CODE_LANGUAGES, } from "./schema/code.js";
61
65
  export { type ColorFormat, type ParsedColor, COLOR_FORMATS, DEFAULT_COLOR_FORMAT, } from "./schema/colorFormat.js";
@@ -17,6 +17,7 @@ import { date } from "./schema/date.js";
17
17
  import { datetime } from "./schema/datetime.js";
18
18
  import { code } from "./schema/code.js";
19
19
  import { color } from "./schema/color.js";
20
+ import { locale } from "./schema/locale.js";
20
21
  import { route } from "./schema/route.js";
21
22
  import { router } from "./schema/router.js";
22
23
  import { images } from "./schema/images.js";
@@ -266,6 +267,20 @@ export type InitSchema = {
266
267
  * ```
267
268
  */
268
269
  readonly route: typeof route;
270
+ /**
271
+ * Define one of the project's languages.
272
+ *
273
+ * The languages themselves are declared in the settings module, under
274
+ * `locales.available` — this says only that a value is one of them, which is
275
+ * checked against that list.
276
+ *
277
+ * @example // a field: everything under this object is in this language
278
+ * const schema = s.object({ locale: s.locale(), title: s.string() });
279
+ *
280
+ * @example // a key: one entry per language
281
+ * const schema = s.record(s.locale(), s.object({ title: s.string() }));
282
+ */
283
+ readonly locale: typeof locale;
269
284
  /**
270
285
  * Create a page router.
271
286
  * Each key is the path of the page.
@@ -393,6 +408,7 @@ export declare function initSchema(): {
393
408
  datetime: (options?: Record<string, never>) => import("./index.js").DateTimeSchema<import("./index.js").RawString>;
394
409
  color: (options?: import("./index.js").ColorOptions) => import("./index.js").ColorSchema<import("./index.js").RawString>;
395
410
  code: (options?: import("./index.js").CodeOptions) => import("./index.js").CodeSchema<import("./index.js").RawString>;
411
+ locale: () => import("./index.js").LocaleSchema<string>;
396
412
  route: <T extends string>(options?: Record<string, never>) => import("./index.js").RouteSchema<T>;
397
413
  router: typeof router;
398
414
  images: <Accept extends `image/${string}`>(options: import("./schema/images.js").ImagesOptions<Accept>) => import("./index.js").RecordSchema<import("./index.js").ObjectSchema<{
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Language tags, and what makes one wrong.
3
+ *
4
+ * Val does not ship a list of locales. Which languages a project publishes is a
5
+ * property of that project, so it is declared in the settings module — see
6
+ * `LocalesSettingsSource` — and everything downstream reads it from there.
7
+ *
8
+ * What Val does own is the SPELLING. A tag is checked against BCP 47
9
+ * (RFC 5646) through `Intl.getCanonicalLocales`, which is the same
10
+ * implementation the browser, `<html lang>` and every `Intl` constructor use.
11
+ * Delegating rather than writing a matcher is the point: a tag this accepts is
12
+ * a tag those accept.
13
+ */
14
+ /**
15
+ * What is wrong with `tag` as a language tag, or `false` if nothing is.
16
+ *
17
+ * Canonical form, not merely parseable, and the difference is what makes this
18
+ * worth checking at all. `nb-no` parses; `Intl.DisplayNames` and a
19
+ * case-sensitive comparison against a stored key do not agree about it. Since a
20
+ * locale is compared as a string everywhere — a record key, a URL segment, a
21
+ * value in content — one spelling has to win, and the canonical one is the
22
+ * spelling every other tool already produces.
23
+ *
24
+ * The message names the canonical form where there is one, because "not
25
+ * canonical" is not an instruction and `nb-NO` is.
26
+ */
27
+ export declare function localeTagError(tag: string): string | false;
28
+ /**
29
+ * A language tag, as content holds it.
30
+ *
31
+ * A *flavoured* string rather than a union or a strict brand. Which languages a
32
+ * project has is declared in its settings module, where the project can change
33
+ * it, so there is no set for a type to enumerate — and a strict brand would make
34
+ * `"nb-NO"` unassignable, which would mean writing every locale in a `.val.ts`
35
+ * through a constructor. Content stays plain data.
36
+ *
37
+ * So this carries no compile-time guarantee, and is not meant to. It names the
38
+ * concept where a bare `string` would say nothing, and the guarantee is the
39
+ * `locale:check-locale` validation against `locales.available`.
40
+ */
41
+ declare const localeBrand: unique symbol;
42
+ export type Locale = string & {
43
+ readonly [localeBrand]?: "locale";
44
+ };
45
+ /**
46
+ * The locale a stored value means, or `null` if it means none of them.
47
+ *
48
+ * A lookup against the project's languages, and today that is all it is. It
49
+ * stays a named function because it is the one place the question is asked —
50
+ * the Studio, the validation worker and the server all come here rather than
51
+ * comparing strings themselves, so they cannot disagree about what a stored
52
+ * value is.
53
+ */
54
+ export declare function localeOfValue(value: string, available: readonly string[]): string | null;
55
+ export {};
@@ -85,6 +85,10 @@ export declare class ArraySchema<T extends Schema<SelectorSource>, Src extends S
85
85
  nullable(): ArraySchema<T, Src | null>;
86
86
  readonly(isReadonly?: boolean): ArraySchema<T, Src>;
87
87
  hidden(isHidden?: boolean): ArraySchema<T, Src>;
88
+ protected localeScopeChildren(): {
89
+ key: string;
90
+ schema: Schema<SelectorSource>;
91
+ }[];
88
92
  protected executeCustomValidateAt(path: SourcePath, src: Src): ValidationError[];
89
93
  protected executeSerialize(): SerializedArraySchema;
90
94
  protected executePreview(sourcePath: SourcePath | ModuleFilePath, src: Src, scope?: PreviewScope): ReifiedPreview;
@@ -0,0 +1,37 @@
1
+ import { SerializedSchema } from "./index.js";
2
+ /**
3
+ * The keys a record is REQUIRED to hold, where its key schema says what they are.
4
+ *
5
+ * Most records have an open key set — `s.record(s.string(), item)` holds
6
+ * whatever anyone adds — and this returns `null` for those. A record keyed by
7
+ * something that enumerates its values is different in kind: the keys are part
8
+ * of the schema, so a missing one is a hole in the content rather than content
9
+ * nobody has written yet.
10
+ *
11
+ * Two key schemas enumerate. A union of literals does it locally, so its keys
12
+ * are here. `s.locale()` does it in the settings module, which is another file
13
+ * and so cannot be read from a serialized schema — that case says only which
14
+ * kind it is, and is resolved against `locales.available` by
15
+ * `resolveSchemaSourceFixes`, the same crossing `keyOf` and `route` make.
16
+ */
17
+ export type DeclaredKeySet = {
18
+ kind: "literals";
19
+ keys: string[];
20
+ } | {
21
+ kind: "locale";
22
+ };
23
+ /**
24
+ * What `key` requires a record to hold, or `null` where it requires nothing.
25
+ *
26
+ * Works from the SERIALIZED schema so that the Studio, the validation worker
27
+ * and the server all answer this the same way — none of them has the schema
28
+ * instance, and `SchemaStore` deliberately holds serialized schemas only.
29
+ */
30
+ export declare function declaredKeySetOf(key: SerializedSchema | undefined): DeclaredKeySet | null;
31
+ /**
32
+ * The declared keys a record is missing, in the order the schema declares them.
33
+ *
34
+ * Declaration order rather than the source's: this is read as "what is left to
35
+ * write", and the answer should not reshuffle itself as entries are added.
36
+ */
37
+ export declare function missingDeclaredKeys(declared: readonly string[], present: readonly string[]): string[];
@@ -114,6 +114,10 @@ export declare class DiscriminatedUnionSchema<Key extends string, T extends Disc
114
114
  nullable(): DiscriminatedUnionSchema<Key, T, Src | null>;
115
115
  readonly(isReadonly?: boolean): DiscriminatedUnionSchema<Key, T, Src>;
116
116
  hidden(isHidden?: boolean): DiscriminatedUnionSchema<Key, T, Src>;
117
+ protected localeScopeChildren(): {
118
+ key: string;
119
+ schema: Schema<SelectorSource>;
120
+ }[];
117
121
  protected executeCustomValidateAt(path: SourcePath, src: Src): ValidationError[];
118
122
  /**
119
123
  * The variant schema a value takes. `null` when the value matches none of
@@ -0,0 +1,20 @@
1
+ import { SerializedSchema } from "./index.js";
2
+ /**
3
+ * The variant of a discriminated union that a value takes, by its tag.
4
+ *
5
+ * A discriminated union is a fork rather than a level: the variant IS the node,
6
+ * and everything a caller wants to know about that node — its fields, its
7
+ * `locale` field, the schema to draw it with — is on the variant and not on the
8
+ * union. Resolving it takes the source, so it cannot be done from the schema
9
+ * alone, which is why every caller that has only the union ends up needing this.
10
+ *
11
+ * `undefined` where the tag matches nothing, rather than a fallback to the first
12
+ * variant: a value whose tag names no variant is not a member of the union,
13
+ * validation is already saying so, and reading fields out of the wrong shape
14
+ * would answer confidently and wrongly.
15
+ *
16
+ * `s.enum()` never reaches here — it is a leaf whose values are strings, with no
17
+ * variants to resolve — so anything that is not a discriminated union answers
18
+ * `undefined` too.
19
+ */
20
+ export declare function discriminatedUnionBranchOf(schema: SerializedSchema | undefined, tag: unknown): SerializedSchema | undefined;
@@ -38,6 +38,15 @@ export declare class EnumSchema<Src extends string | null> extends Schema<Src> {
38
38
  private readonly description?;
39
39
  private readonly renderInput;
40
40
  private readonly previewInput;
41
+ /**
42
+ * Type-only marker: as a record key, this declares the key set.
43
+ *
44
+ * An enum names every value it may take, so a record keyed by one holds
45
+ * exactly those keys — the same claim `s.literal()` and `s.locale()` make.
46
+ * `DiscriminatedUnionSchema` carries no marker: it discriminates objects and
47
+ * cannot be a record key at all. See `LocaleSchema`.
48
+ */
49
+ readonly __declaresRecordKeys: true;
41
50
  constructor(values: readonly string[], opt?: boolean, customValidateFunctions?: CustomValidateFunction<Src>[], isReadonly?: boolean, isHidden?: boolean, description?: string | undefined, renderInput?: FieldRender | null, previewInput?: ItemPreviewInput<Src> | null);
42
51
  /**
43
52
  * Describe this field.
@@ -17,13 +17,14 @@ import { SerializedCodeSchema } from "./code.js";
17
17
  import { SerializedColorSchema } from "./color.js";
18
18
  import { SerializedDateSchema } from "./date.js";
19
19
  import { SerializedDateTimeSchema } from "./datetime.js";
20
+ import { SerializedLocaleSchema } from "./locale.js";
20
21
  import { SerializedRouteSchema } from "./route.js";
21
22
  import { SerializedSettingsSchema } from "./settings.js";
22
23
  import { ValidationError, ValidationErrors } from "./validation/ValidationError.js";
23
24
  import { FileSource } from "../source/media.js";
24
25
  import { GenericRichTextSourceNode, RichTextSource } from "../source/richtext.js";
25
26
  import { ReifiedPreview, PreviewScope, PreviewItem } from "../preview.js";
26
- export type SerializedSchema = SerializedStringSchema | SerializedLiteralSchema | SerializedBooleanSchema | SerializedNumberSchema | SerializedObjectSchema | SerializedArraySchema | SerializedDiscriminatedUnionSchema | SerializedEnumSchema | SerializedRichTextSchema | SerializedRecordSchema | SerializedKeyOfSchema | SerializedFileSchema | SerializedDateSchema | SerializedDateTimeSchema | SerializedColorSchema | SerializedCodeSchema | SerializedRouteSchema | SerializedSettingsSchema | SerializedImageSchema;
27
+ export type SerializedSchema = SerializedStringSchema | SerializedLiteralSchema | SerializedBooleanSchema | SerializedNumberSchema | SerializedObjectSchema | SerializedArraySchema | SerializedDiscriminatedUnionSchema | SerializedEnumSchema | SerializedRichTextSchema | SerializedRecordSchema | SerializedKeyOfSchema | SerializedFileSchema | SerializedDateSchema | SerializedDateTimeSchema | SerializedColorSchema | SerializedCodeSchema | SerializedRouteSchema | SerializedLocaleSchema | SerializedSettingsSchema | SerializedImageSchema;
27
28
  type Primitives = number | string | boolean | null | FileSource;
28
29
  export type AssertError = {
29
30
  message: string;
@@ -74,6 +75,88 @@ export declare abstract class Schema<Src extends SelectorSource> {
74
75
  * against the real instance. Structural errors publish first; these merge in.
75
76
  */
76
77
  protected abstract executeCustomValidateAt(path: SourcePath, src: Src): ValidationError[];
78
+ /**
79
+ * Whether this node is a locale — the field that MARKS a scope, `s.locale()`.
80
+ *
81
+ * Only `LocaleSchema` overrides this. A base implementation rather than an
82
+ * abstract one on purpose: every other schema class answers no, and a
83
+ * question every class had to answer would be twenty edits for one yes.
84
+ *
85
+ * Not the same question as {@link opensLocaleScope}: a locale field marks the
86
+ * scope, the object AROUND it is the scope. See `localeScope.ts`.
87
+ */
88
+ protected isLocaleField(): boolean;
89
+ /**
90
+ * Whether this node OPENS a locale scope, and what opens it.
91
+ *
92
+ * An object with a `s.locale()` field opens one; so does a record keyed by
93
+ * `s.locale()`. Everything below such a node is in that one language, which
94
+ * is why a scope may not contain another — see `localeScope.ts` for the rule
95
+ * and the reason it is validated rather than typed.
96
+ */
97
+ protected opensLocaleScope(): "field" | "key" | null;
98
+ /**
99
+ * The child schemas a locale scope reaches, by the path segment that reaches
100
+ * them.
101
+ *
102
+ * A structural walk that deliberately does NOT go through `executeSerialize`:
103
+ * serializing a subtree at every node to ask a question about its shape is
104
+ * quadratic, and this is asked during validation. `*` stands for the item of
105
+ * an array or record, which has no name of its own.
106
+ */
107
+ protected localeScopeChildren(): {
108
+ key: string;
109
+ schema: Schema<SelectorSource>;
110
+ }[];
111
+ /**
112
+ * Every locale scope opened strictly BELOW this node, by path.
113
+ *
114
+ * Stops descending at each one it finds, so a scope three deep is reported
115
+ * once, by the scope immediately enclosing it, rather than by every ancestor.
116
+ */
117
+ protected localeScopesBelow(prefix?: string[]): {
118
+ path: string[];
119
+ kind: "field" | "key";
120
+ }[];
121
+ /**
122
+ * The scope rule, as errors at this node — or `false` if it is not broken.
123
+ *
124
+ * Two things are wrong and both are wrong in the SCHEMA rather than in the
125
+ * content, so both are `schemaError`s: an object with two locale fields (the
126
+ * subtree below it would be in two languages at once), and a locale scope
127
+ * inside another (the inner one would silently override the outer for part
128
+ * of a subtree that is supposed to be one language throughout).
129
+ *
130
+ * Validated rather than typed on purpose. Expressing "no scope below this
131
+ * one" as a constraint means threading it through every schema class's type
132
+ * parameter, and the errors that fall out of a recursive constraint like
133
+ * that name the whole tree — an unrelated typo in a `.val.ts` would print
134
+ * pages. See the design notes on the locales PR.
135
+ */
136
+ protected localeScopeErrors(): ValidationError[];
137
+ /**
138
+ * The answer, computed once per schema.
139
+ *
140
+ * Memoised because the question is asked from `executeValidate`, which runs
141
+ * once per SOURCE instance: an array of five hundred blocks asks the item
142
+ * schema five hundred times, and each answer walks the schema below it
143
+ * looking for a nested scope. The answer cannot differ between two instances
144
+ * of the same schema — nothing here reads the source — so the walk is done
145
+ * once and the result handed back after that.
146
+ *
147
+ * The error is still reported at every instance's path. Val's validation
148
+ * errors are keyed by source path and that is where the Studio draws them, so
149
+ * a schema error on a row is a schema error on every row; reporting it on one
150
+ * arbitrary row would put it somewhere nobody was looking.
151
+ */
152
+ private localeScopeErrorsMemo?;
153
+ private computeLocaleScopeErrors;
154
+ /**
155
+ * This node's own `s.locale()` fields, where it is an object that has any.
156
+ *
157
+ * Empty everywhere else, so `localeScopeErrors` can live on the base class.
158
+ */
159
+ protected localeFieldNames(): string[];
77
160
  protected executeCustomValidateFunctions(src: Src, customValidateFunctions: CustomValidateFunction<Src>[], ctx: {
78
161
  path: SourcePath;
79
162
  }): ValidationError[];
@@ -99,8 +182,38 @@ export declare abstract class Schema<Src extends SelectorSource> {
99
182
  * The editor gets a way to clear the field, and the type of the source
100
183
  * widens to include `null` — so consuming code has to handle it.
101
184
  *
102
- * Declare `.validate(...)` AFTER `.nullable()`: on most schema types a
103
- * validator added before it is not carried over.
185
+ * `.validate(...)` may be declared before or after `.nullable()`: the
186
+ * validator is carried over either way. Declared before, its argument is
187
+ * typed as non-null even though `null` can reach it, so guard for it.
188
+ *
189
+ * Implementations MUST carry `customValidateFunctions` over to the new
190
+ * instance. `.nullable()` returns a copy, so dropping them there silently
191
+ * un-declares the user's `.validate(...)` whenever it was written before the
192
+ * `.nullable()` — which is the order most people write it in. Thirteen schema
193
+ * classes passed `[]` here until this was fixed; `nullableCustomValidate.test.ts`
194
+ * pins one instance of every factory on `s` against that, and does not compile
195
+ * until a newly added schema is listed in it.
196
+ *
197
+ * The validators keep running when the value IS null: a nullable schema's
198
+ * validator sees `Src | null` and decides for itself. That is what the
199
+ * classes that never dropped them (string, record, route, file, image) have
200
+ * always done.
201
+ *
202
+ * Carrying them over needs a cast, because `Src` sits in a PARAMETER position
203
+ * of {@link CustomValidateFunction} and so `CustomValidateFunction<Src>[]` is
204
+ * not assignable to `CustomValidateFunction<Src | null>[]`. Widening what the
205
+ * functions can be CALLED with is the intent here rather than something the
206
+ * cast gets away with: `null` is handed to them, and the paragraph above is
207
+ * the behaviour that buys.
208
+ *
209
+ * A validator's parameter type was never a runtime guarantee to begin with.
210
+ * `executeValidate` runs the custom validators BEFORE the structural checks
211
+ * (see {@link CustomValidateFunction}'s callers, e.g. `NumberSchema`, which
212
+ * calls them ahead of its `typeof src !== "number"`), so one can already be
213
+ * called with a value of the wrong type entirely — hand-written content, or
214
+ * a node the Studio's walker reached before its type was checked. That is why
215
+ * {@link executeCustomValidateFunctions} catches what a validator throws and
216
+ * reports it as a `schemaError` instead of letting it escape.
104
217
  *
105
218
  * @example
106
219
  * const schema = s.object({
@@ -25,6 +25,8 @@ export declare class LiteralSchema<Src extends string | null> extends Schema<Src
25
25
  private readonly description?;
26
26
  private readonly renderInput;
27
27
  private readonly previewInput;
28
+ /** Type-only marker: as a record key, this declares the key set. See `LocaleSchema`. */
29
+ readonly __declaresRecordKeys: true;
28
30
  constructor(value: string, opt?: boolean, customValidateFunctions?: CustomValidateFunction<Src>[], isReadonly?: boolean, isHidden?: boolean, description?: string | undefined, renderInput?: FieldRender | null, previewInput?: ItemPreviewInput<Src> | null);
29
31
  /**
30
32
  * Describe this field.