@jxsuite/schema 5.5.0 → 6.0.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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The type a JSON Schema says, as TypeScript writes it (`spec.md` §5.2, §23.3).
3
+ *
4
+ * A document's `$defs` holds its type definitions, and a `state` entry names one or carries a
5
+ * schema of its own (§5.3). Both readers of a document's types ask here, through `state-types.ts`,
6
+ * so the two say the same thing about the same schema.
7
+ *
8
+ * **A type is written as parts**, because two kinds of name in one are names the author wrote: a
9
+ * property of an object, and the definition a `$ref` ends in. The whole-document reader maps each
10
+ * to its token, which is what lets an editor rename one and find where it was declared, and the
11
+ * reader of one body joins them.
12
+ *
13
+ * **What a schema does not say is `any`, and never an error**: a keyword that is not read here, a
14
+ * `$ref` that leaves the document, a definition whose name is no identifier. A schema with no
15
+ * `type` constrains nothing, as JSON Schema has it, so `properties` alone does not make an object.
16
+ *
17
+ * @docs framework/concepts/type-checking
18
+ */
19
+ import { isBoolean, isJsonObject, isNumber, isString } from "../guards";
20
+ import type { JsonObject, JsonValue } from "../../types";
21
+
22
+ /** Whether a key can be written as a bare property name. */
23
+ export const IDENTIFIER = /^[A-Za-z_$][\w$]*$/u;
24
+
25
+ /** The namespace a document's definitions are written in. */
26
+ export const DEFINITIONS = "$defs";
27
+
28
+ /** A reference to one definition of the document it is written in. */
29
+ const LOCAL_DEFINITION = /^#\/\$defs\/([^/]+)$/u;
30
+
31
+ /** A property name as TypeScript writes it. */
32
+ export function propertyName(key: string): string {
33
+ return IDENTIFIER.test(key) ? key : JSON.stringify(key);
34
+ }
35
+
36
+ /** A JSON Pointer one step down. */
37
+ export function pointerInto(pointer: string, key: string | number): string {
38
+ return `${pointer}/${String(key).replaceAll("~", "~0").replaceAll("/", "~1")}`;
39
+ }
40
+
41
+ /** A name in a type that the document wrote. */
42
+ export interface WrittenName {
43
+ /**
44
+ * Which name it is: a `member` is the name of the property or definition at `pointer`, a `ref` is
45
+ * the definition that the `$ref` at `pointer` ends in, and a `text` is no name but a whole type,
46
+ * the TypeScript the string at `pointer` holds.
47
+ */
48
+ kind: "member" | "ref" | "text";
49
+ /** The name as the document holds it. */
50
+ name: string;
51
+ pointer: string;
52
+ /** The name as TypeScript writes it here. */
53
+ written: string;
54
+ }
55
+
56
+ /** One stretch of a type: text the author did not write, or a name they did. */
57
+ export type TypePart = string | WrittenName;
58
+
59
+ /** Where a schema is, and what its document gives it. */
60
+ export interface SchemaSite {
61
+ /** Whether the document has `$defs`, without which a `$ref` into it names nothing. */
62
+ defs: boolean;
63
+ /** The schema's JSON Pointer. */
64
+ pointer: string;
65
+ }
66
+
67
+ /** What joins a type's members at its outermost level, which decides where it needs brackets. */
68
+ type Joins = "&" | "|" | null;
69
+
70
+ /** A type being written. */
71
+ interface Typed {
72
+ joins: Joins;
73
+ parts: TypePart[];
74
+ }
75
+
76
+ /** A type as text. */
77
+ export function written(parts: readonly TypePart[]): string {
78
+ return parts.map((part) => (isString(part) ? part : part.written)).join("");
79
+ }
80
+
81
+ /** A type that is one word. */
82
+ function plain(text: string): Typed {
83
+ return { joins: null, parts: [text] };
84
+ }
85
+
86
+ /** A type as a member of another, in brackets where it would otherwise be read apart. */
87
+ function within(typed: Typed, around: "&" | "|" | "[]"): TypePart[] {
88
+ // `&` binds tighter than `|`, so only a union inside an intersection, or either in a list, needs them.
89
+ const apart =
90
+ typed.joins !== null && (around === "[]" || (around === "&" && typed.joins === "|"));
91
+ return apart ? ["(", ...typed.parts, ")"] : typed.parts;
92
+ }
93
+
94
+ /**
95
+ * Several types as one. A member that is only words is said once. One that holds a name the
96
+ * document wrote is said each time it is written, because each is a place a rename has to reach.
97
+ */
98
+ function joined(members: readonly Typed[], by: "&" | "|"): Typed {
99
+ const seen = new Set<string>();
100
+ const kept: Typed[] = [];
101
+ for (const member of members) {
102
+ const text = written(member.parts);
103
+ if (!member.parts.every(isString) || !seen.has(text)) {
104
+ kept.push(member);
105
+ seen.add(text);
106
+ }
107
+ }
108
+ const [only = plain("any"), ...rest] = kept;
109
+ const parts = [...within(only, by)];
110
+ for (const member of rest) {
111
+ parts.push(` ${by} `, ...within(member, by));
112
+ }
113
+ return rest.length > 0 ? { joins: by, parts } : only;
114
+ }
115
+
116
+ /** The type of a list of values that are each a type themselves, or null when one is not. */
117
+ function literals(values: JsonValue | undefined): Typed | null {
118
+ if (!Array.isArray(values) || values.length === 0) {
119
+ return null;
120
+ }
121
+ const members: Typed[] = [];
122
+ for (const value of values) {
123
+ if (!(isString(value) || isNumber(value) || isBoolean(value) || value === null)) {
124
+ return null;
125
+ }
126
+ members.push(plain(JSON.stringify(value)));
127
+ }
128
+ return joined(members, "|");
129
+ }
130
+
131
+ /** The definition a `$ref` names, when the schema is one. */
132
+ function referenced(schema: JsonObject, site: SchemaSite): Typed | null {
133
+ if (!isString(schema.$ref)) {
134
+ return null;
135
+ }
136
+ const name = LOCAL_DEFINITION.exec(schema.$ref)?.[1];
137
+ if (!site.defs || name === undefined || !IDENTIFIER.test(name)) {
138
+ return plain("any");
139
+ }
140
+ const { pointer } = site;
141
+ return {
142
+ joins: null,
143
+ parts: [`${DEFINITIONS}.`, { kind: "ref", name, pointer, written: name }],
144
+ };
145
+ }
146
+
147
+ /** The type `anyOf`, `oneOf` or `allOf` says, when the schema has it. */
148
+ function combined(
149
+ schema: JsonObject,
150
+ site: SchemaSite,
151
+ keyword: "allOf" | "anyOf" | "oneOf",
152
+ ): Typed | null {
153
+ const members = schema[keyword];
154
+ if (!Array.isArray(members) || members.length === 0) {
155
+ return null;
156
+ }
157
+ const at = pointerInto(site.pointer, keyword);
158
+ const typed = members.map((member, index) =>
159
+ typeOf(member, { ...site, pointer: pointerInto(at, index) }),
160
+ );
161
+ return joined(typed, keyword === "allOf" ? "&" : "|");
162
+ }
163
+
164
+ /** An object: each property it names, optional unless `required`, and whether it holds others. */
165
+ function objectType(schema: JsonObject, site: SchemaSite): Typed {
166
+ const properties = isJsonObject(schema.properties) ? schema.properties : {};
167
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
168
+ const others = schema.additionalProperties;
169
+ const at = pointerInto(site.pointer, "properties");
170
+ const parts: TypePart[] = ["{"];
171
+ for (const [key, member] of Object.entries(properties)) {
172
+ const pointer = pointerInto(at, key);
173
+ parts.push(
174
+ " ",
175
+ { kind: "member", name: key, pointer, written: propertyName(key) },
176
+ required.has(key) ? ": " : "?: ",
177
+ ...typeOf(member, { ...site, pointer }).parts,
178
+ ";",
179
+ );
180
+ }
181
+ const named = parts.length > 1;
182
+ if (!named) {
183
+ // An object that names no property is a map, of what `additionalProperties` says when it does.
184
+ const held = typeOf(others, {
185
+ ...site,
186
+ pointer: pointerInto(site.pointer, "additionalProperties"),
187
+ });
188
+ parts.push(" [key: string]: ", ...held.parts, ";");
189
+ } else if (others === true || isJsonObject(others)) {
190
+ // Beside named properties an index must admit every one of them, and only `any` always does.
191
+ parts.push(" [key: string]: any;");
192
+ }
193
+ return { joins: null, parts: [...parts, " }"] };
194
+ }
195
+
196
+ /** A list of what `items` says. */
197
+ function listType(schema: JsonObject, site: SchemaSite): Typed {
198
+ if (!isJsonObject(schema.items)) {
199
+ return plain("any[]");
200
+ }
201
+ const item = typeOf(schema.items, { ...site, pointer: pointerInto(site.pointer, "items") });
202
+ return { joins: null, parts: [...within(item, "[]"), "[]"] };
203
+ }
204
+
205
+ /** The type one `type` keyword names. */
206
+ function keywordType(keyword: JsonValue | undefined, schema: JsonObject, site: SchemaSite): Typed {
207
+ switch (keyword) {
208
+ case "array": {
209
+ return listType(schema, site);
210
+ }
211
+ case "boolean": {
212
+ return plain("boolean");
213
+ }
214
+ case "integer":
215
+ case "number": {
216
+ return plain("number");
217
+ }
218
+ case "null": {
219
+ return plain("null");
220
+ }
221
+ case "object": {
222
+ return objectType(schema, site);
223
+ }
224
+ case "string": {
225
+ return plain("string");
226
+ }
227
+ default: {
228
+ return plain("any");
229
+ }
230
+ }
231
+ }
232
+
233
+ /** The type a schema says. */
234
+ function typeOf(schema: JsonValue | undefined, site: SchemaSite): Typed {
235
+ if (!isJsonObject(schema)) {
236
+ return plain("any");
237
+ }
238
+ const { type } = schema;
239
+ const narrowed =
240
+ referenced(schema, site) ??
241
+ literals("const" in schema ? [schema.const ?? null] : schema.enum) ??
242
+ combined(schema, site, "anyOf") ??
243
+ combined(schema, site, "oneOf") ??
244
+ combined(schema, site, "allOf");
245
+ if (narrowed) {
246
+ return narrowed;
247
+ }
248
+ return Array.isArray(type)
249
+ ? joined(
250
+ type.map((keyword) => keywordType(keyword, schema, site)),
251
+ "|",
252
+ )
253
+ : keywordType(type, schema, site);
254
+ }
255
+
256
+ /**
257
+ * The type a JSON Schema says.
258
+ *
259
+ * @param schema The schema.
260
+ * @param site Where it is, and whether its document has `$defs`.
261
+ * @returns The type, in parts.
262
+ */
263
+ export function schemaParts(schema: JsonValue | undefined, site: SchemaSite): TypePart[] {
264
+ return typeOf(schema, site).parts;
265
+ }
266
+
267
+ /** The definitions a document holds, or null when it has no `$defs`. */
268
+ export function definitionsOf(doc: JsonValue | undefined): JsonObject | null {
269
+ return isJsonObject(doc) && isJsonObject(doc.$defs) ? doc.$defs : null;
270
+ }
271
+
272
+ /**
273
+ * The namespace that holds a document's definitions, each a type under its own name.
274
+ *
275
+ * A namespace, because a definition is then named in every message and every hover as the author
276
+ * named it (`$defs.TodoItem`), which an interface's member is not. One whose name is not an
277
+ * identifier cannot be declared in one and is left out, and a `$ref` to it is `any`.
278
+ *
279
+ * @param defs The document's `$defs`.
280
+ * @param exported Whether the namespace is exported, as it is from a document's module.
281
+ * @returns The declaration, in parts, with no line break after it.
282
+ */
283
+ export function definitionParts(defs: JsonObject, exported: boolean): TypePart[] {
284
+ const parts: TypePart[] = [`${exported ? "export " : ""}namespace ${DEFINITIONS} {\n`];
285
+ const site: SchemaSite = { defs: true, pointer: `/${DEFINITIONS}` };
286
+ for (const [name, schema] of Object.entries(defs)) {
287
+ if (IDENTIFIER.test(name)) {
288
+ const pointer = pointerInto(site.pointer, name);
289
+ parts.push(
290
+ " export type ",
291
+ { kind: "member", name, pointer, written: name },
292
+ " = ",
293
+ ...schemaParts(schema, { ...site, pointer }),
294
+ ";\n",
295
+ );
296
+ }
297
+ }
298
+ return [...parts, "}"];
299
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * A JSON Schema, read as far as a completion needs (`spec.md` §22.6).
3
+ *
4
+ * Nothing here validates. A completion asks a schema three things: what members an object may have,
5
+ * what the schema of one of them is, and what values a schema names. Each is answered over every
6
+ * schema the one asked about is made of, because a document's schema says most of what it says
7
+ * through `$ref`, `allOf` and `oneOf`.
8
+ *
9
+ * A schema travels with the document it was read from, since that is what its `$ref`s point into. A
10
+ * project's composed schema keeps the core under `#/$defs/v1`, so nothing here knows a definition
11
+ * by its name.
12
+ */
13
+ import { isJsonObject, isJsonPrimitive, isString } from "../guards.ts";
14
+ import type { JsonObject, JsonPrimitive, JsonValue } from "../../types.ts";
15
+
16
+ /** A schema, and the document its `$ref`s point into. */
17
+ export interface RootedSchema {
18
+ root: JsonObject;
19
+ schema: JsonObject;
20
+ }
21
+
22
+ /** The keywords whose every member says something of the same value. */
23
+ const COMBINERS = ["allOf", "anyOf", "oneOf"];
24
+
25
+ /** The keywords whose one schema does. */
26
+ const BRANCHES = ["then", "else"];
27
+
28
+ /** What a `$ref` into its own document points at, or null. */
29
+ function resolve(root: JsonObject, ref: string): JsonObject | null {
30
+ if (!ref.startsWith("#")) {
31
+ return null;
32
+ }
33
+ let at: JsonValue | undefined = root;
34
+ for (const part of ref.split("/").slice(1)) {
35
+ const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
36
+ if (Array.isArray(at)) {
37
+ at = at[Number(key)];
38
+ } else if (isJsonObject(at)) {
39
+ at = at[key];
40
+ } else {
41
+ return null;
42
+ }
43
+ }
44
+ return isJsonObject(at) ? at : null;
45
+ }
46
+
47
+ /**
48
+ * Every schema that says something of the value this one describes: itself, what it refers to, and
49
+ * the members of its `allOf`, `anyOf`, `oneOf`, `then` and `else`, each read the same way.
50
+ *
51
+ * @param rooted The schema.
52
+ * @returns Each once, the schema itself first. A schema that refers to itself ends there.
53
+ */
54
+ export function leavesOf(rooted: RootedSchema): JsonObject[] {
55
+ const seen = new Set<JsonObject>();
56
+ const visit = (one: JsonValue | undefined): void => {
57
+ if (!isJsonObject(one) || seen.has(one)) {
58
+ return;
59
+ }
60
+ seen.add(one);
61
+ if (isString(one.$ref)) {
62
+ visit(resolve(rooted.root, one.$ref));
63
+ }
64
+ for (const key of COMBINERS) {
65
+ const members = one[key];
66
+ if (Array.isArray(members)) {
67
+ for (const member of members) {
68
+ visit(member);
69
+ }
70
+ }
71
+ }
72
+ for (const key of BRANCHES) {
73
+ visit(one[key]);
74
+ }
75
+ };
76
+ visit(rooted.schema);
77
+ return [...seen];
78
+ }
79
+
80
+ /** Several schemas of one value as one schema, or null where there is none. */
81
+ function joined(root: JsonObject, schemas: JsonObject[]): RootedSchema | null {
82
+ const [first] = schemas;
83
+ if (!first) {
84
+ return null;
85
+ }
86
+ return { root, schema: schemas.length === 1 ? first : { anyOf: schemas } };
87
+ }
88
+
89
+ /**
90
+ * The members an object of this schema is declared to have.
91
+ *
92
+ * @param rooted The schema.
93
+ * @returns Each name once with its schema, in the order the schema declares them.
94
+ */
95
+ export function namesOf(rooted: RootedSchema): Map<string, RootedSchema> {
96
+ const names = new Map<string, RootedSchema>();
97
+ for (const leaf of leavesOf(rooted)) {
98
+ if (isJsonObject(leaf.properties)) {
99
+ for (const [name, schema] of Object.entries(leaf.properties)) {
100
+ if (!names.has(name) && isJsonObject(schema)) {
101
+ names.set(name, { root: rooted.root, schema });
102
+ }
103
+ }
104
+ }
105
+ }
106
+ return names;
107
+ }
108
+
109
+ /** The schemas a leaf gives a member it does not declare by name. */
110
+ function undeclared(leaf: JsonObject, key: string): JsonObject[] {
111
+ const found: JsonObject[] = [];
112
+ if (isJsonObject(leaf.patternProperties)) {
113
+ for (const [pattern, schema] of Object.entries(leaf.patternProperties)) {
114
+ if (isJsonObject(schema) && matches(pattern, key)) {
115
+ found.push(schema);
116
+ }
117
+ }
118
+ }
119
+ if (isJsonObject(leaf.additionalProperties)) {
120
+ found.push(leaf.additionalProperties);
121
+ }
122
+ return found;
123
+ }
124
+
125
+ /** Whether a `patternProperties` pattern matches a name. One that is no pattern matches nothing. */
126
+ function matches(pattern: string, key: string): boolean {
127
+ try {
128
+ return new RegExp(pattern, "u").test(key);
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The schema of one member of an object of this schema.
136
+ *
137
+ * @param rooted The object's schema.
138
+ * @param key The member's name.
139
+ * @returns Its entry in `properties` wherever one is declared, and otherwise every
140
+ * `patternProperties` entry the name matches together with `additionalProperties`. Null where the
141
+ * schema says nothing of it.
142
+ */
143
+ export function memberOf(rooted: RootedSchema, key: string): RootedSchema | null {
144
+ const leaves = leavesOf(rooted);
145
+ const declared = leaves.flatMap((leaf) => {
146
+ const schema = isJsonObject(leaf.properties) ? leaf.properties[key] : undefined;
147
+ return isJsonObject(schema) ? [schema] : [];
148
+ });
149
+ return joined(
150
+ rooted.root,
151
+ declared.length > 0 ? declared : leaves.flatMap((leaf) => undeclared(leaf, key)),
152
+ );
153
+ }
154
+
155
+ /**
156
+ * The schema of an item of a list of this schema.
157
+ *
158
+ * @param rooted The list's schema.
159
+ * @returns Its `items`, or null where the schema is of no list.
160
+ */
161
+ export function itemsOf(rooted: RootedSchema): RootedSchema | null {
162
+ return joined(
163
+ rooted.root,
164
+ leavesOf(rooted).flatMap((leaf) => (isJsonObject(leaf.items) ? [leaf.items] : [])),
165
+ );
166
+ }
167
+
168
+ /** Whether a schema's `type` is, or includes, the one named. */
169
+ function hasType(leaf: JsonObject, type: string): boolean {
170
+ return Array.isArray(leaf.type) ? leaf.type.includes(type) : leaf.type === type;
171
+ }
172
+
173
+ /**
174
+ * The values this schema names.
175
+ *
176
+ * @param rooted The schema.
177
+ * @returns Its `const`, its `enum`, both booleans where it is any boolean, and its `examples`, each
178
+ * once. Only what one token can hold: an object or a list among them is left out.
179
+ */
180
+ export function valuesOf(rooted: RootedSchema): JsonPrimitive[] {
181
+ const values = new Set<JsonPrimitive>();
182
+ const add = (value: JsonValue | undefined): void => {
183
+ if (isJsonPrimitive(value)) {
184
+ values.add(value);
185
+ }
186
+ };
187
+ const leaves = leavesOf(rooted);
188
+ for (const leaf of leaves) {
189
+ add(leaf.const);
190
+ for (const value of Array.isArray(leaf.enum) ? leaf.enum : []) {
191
+ add(value);
192
+ }
193
+ // A boolean held to one value by `const` is not both.
194
+ if (hasType(leaf, "boolean") && leaf.const === undefined && leaf.enum === undefined) {
195
+ values.add(true);
196
+ values.add(false);
197
+ }
198
+ }
199
+ // After everything the schema requires, since an example is only a suggestion.
200
+ for (const leaf of leaves) {
201
+ for (const value of Array.isArray(leaf.examples) ? leaf.examples : []) {
202
+ add(value);
203
+ }
204
+ }
205
+ return [...values];
206
+ }
207
+
208
+ /**
209
+ * What this schema says it is for.
210
+ *
211
+ * @param rooted The schema.
212
+ * @returns The first `description` among the schemas it is made of, or null.
213
+ */
214
+ export function descriptionOf(rooted: RootedSchema): string | null {
215
+ for (const leaf of leavesOf(rooted)) {
216
+ if (isString(leaf.description)) {
217
+ return leaf.description;
218
+ }
219
+ }
220
+ return null;
221
+ }
@@ -204,6 +204,73 @@ function writeFunction(key: string, fn: JsonObject, indent: string, where: strin
204
204
  return [parts.join(" ")];
205
205
  }
206
206
 
207
+ /**
208
+ * Whether a Function is an export of the document's own script (`spec.md` §23.2) with the
209
+ * `(export)` spelling. One with a body or parameters is a `(fn)` row, as any Function is.
210
+ */
211
+ function isExportRow(value: JsonValue | undefined): value is JsonObject {
212
+ if (
213
+ !isObject(value) ||
214
+ value.$prototype !== "Function" ||
215
+ value.$src !== "#client" ||
216
+ !isString(value.$export) ||
217
+ Object.hasOwn(value, "body") ||
218
+ Object.hasOwn(value, "parameters")
219
+ ) {
220
+ return false;
221
+ }
222
+ return Object.values(value).every((entry) => scalar(entry, "", "") !== null);
223
+ }
224
+
225
+ /** An `(export)` node. The export is the node's name unless it is written. */
226
+ function writeExport(key: string, fn: JsonObject, indent: string, where: string): string[] {
227
+ const { $export, $prototype: _prototype, $src: _src, ...rest } = fn;
228
+ const parts = [`${indent}(export)${writeName(key, where)}`];
229
+ if ($export !== key) {
230
+ parts.push(`$export=${scalar($export, indent, down(where, "$export"))}`);
231
+ }
232
+ for (const [name, value] of Object.entries(rest)) {
233
+ parts.push(`${writeName(name, where)}=${scalar(value, indent, down(where, name))}`);
234
+ }
235
+ return [parts.join(" ")];
236
+ }
237
+
238
+ /** What a `(server)` row may hold, and nothing else: anything more is a plain block. */
239
+ const SERVER_ROW_KEYS = new Set(["$export", "$src", "arguments", "timing"]);
240
+
241
+ /**
242
+ * Whether an entry is a function of the document's own server script with the `(server)` spelling:
243
+ * exported under the entry's own name, with arguments that each fit in a value.
244
+ */
245
+ function isServerRow(key: string, value: JsonValue | undefined): value is JsonObject {
246
+ if (
247
+ !isObject(value) ||
248
+ value.timing !== "server" ||
249
+ value.$src !== "#server" ||
250
+ value.$export !== key ||
251
+ !Object.keys(value).every((entry) => SERVER_ROW_KEYS.has(entry))
252
+ ) {
253
+ return false;
254
+ }
255
+ const { arguments: args } = value;
256
+ if (args === undefined) {
257
+ return true;
258
+ }
259
+ const values = isObject(args) ? Object.values(args) : [];
260
+ return values.length > 0 && values.every((entry) => scalar(entry, "", "") !== null);
261
+ }
262
+
263
+ /** A `(server)` node, with its arguments as names. */
264
+ function writeServer(key: string, entry: JsonObject, indent: string, where: string): string[] {
265
+ const parts = [`${indent}(server)${writeName(key, where)}`];
266
+ const at = down(where, "arguments");
267
+ const args = isObject(entry.arguments) ? entry.arguments : {};
268
+ for (const [name, value] of Object.entries(args)) {
269
+ parts.push(`${writeName(name, at)}=${scalar(value, indent, down(at, name))}`);
270
+ }
271
+ return [parts.join(" ")];
272
+ }
273
+
207
274
  /** A JSON block. */
208
275
  export function writeValue(key: string, value: JsonValue, indent: string, where: string): string[] {
209
276
  const name = writeName(key, where);
@@ -212,6 +279,12 @@ export function writeValue(key: string, value: JsonValue, indent: string, where:
212
279
  if (single !== null) {
213
280
  return [`${head} ${single}`];
214
281
  }
282
+ if (isExportRow(value)) {
283
+ return writeExport(key, value, indent, where);
284
+ }
285
+ if (isServerRow(key, value)) {
286
+ return writeServer(key, value, indent, where);
287
+ }
215
288
  if (isFunctionRow(value)) {
216
289
  return writeFunction(key, value, indent, where);
217
290
  }
@@ -358,11 +431,40 @@ function isPlainAttribute(key: string, spelled: string | null): spelled is strin
358
431
  );
359
432
  }
360
433
 
361
- /** An element. */
362
- function writeElement(element: JsonObject, indent: string, where: string): string[] {
434
+ /**
435
+ * A document's `$script` as the `script` nodes of its root element (`spec.md` §23.2), or null when
436
+ * it holds anything a `script` node cannot say and so has to be a plain `$script` block.
437
+ */
438
+ function writeScripts(scripts: JsonValue, indent: string): string[] | null {
439
+ const sides = isObject(scripts) ? Object.entries(scripts) : [];
440
+ const lines: string[] = [];
441
+ for (const [side, text] of sides) {
442
+ if ((side !== "client" && side !== "server") || !isString(text)) {
443
+ return null;
444
+ }
445
+ const timing = side === "server" ? "timing=server " : "";
446
+ lines.push(`${indent}script ${timing}${writeString(text, indent, `/$script/${side}`)}`);
447
+ }
448
+ return lines.length > 0 ? lines : null;
449
+ }
450
+
451
+ /**
452
+ * An element.
453
+ *
454
+ * @param element The element.
455
+ * @param indent Where it sits.
456
+ * @param where Its JSON Pointer.
457
+ * @param scripts The document's `script` nodes, which only its root element holds.
458
+ */
459
+ function writeElement(
460
+ element: JsonObject,
461
+ indent: string,
462
+ where: string,
463
+ scripts: string[] = [],
464
+ ): string[] {
363
465
  const parts = [writeHead(element, indent, where)];
364
466
  const inner = indent + INDENT;
365
- const blocks: string[] = [];
467
+ const blocks: string[] = [...scripts];
366
468
  const leftoverAttributes: JsonObject = {};
367
469
  let textPositional: string | null = null;
368
470
  for (const [key, value] of Object.entries(element)) {
@@ -519,8 +621,14 @@ function writeCanonical(document: JxDocument): string {
519
621
  const source = document;
520
622
  const lines = ["/- kdl-version 2"];
521
623
  const root: JsonObject = {};
624
+ const hasRoot = Object.keys(source).some((key) => !key.startsWith("$"));
625
+ let scripts: string[] = [];
522
626
  for (const [key, value] of Object.entries(source)) {
523
- if (key === "$head" && Array.isArray(value)) {
627
+ // A script is written inside the root element, so a document with none keeps the plain block.
628
+ const written = key === "$script" && hasRoot ? writeScripts(value, INDENT) : null;
629
+ if (written) {
630
+ scripts = written;
631
+ } else if (key === "$head" && Array.isArray(value)) {
524
632
  lines.push(...writeHeadBlock(value, INDENT));
525
633
  } else if (key === "$head") {
526
634
  // Not a list, so not rows. The annotation is what stops it being read as them.
@@ -532,7 +640,7 @@ function writeCanonical(document: JxDocument): string {
532
640
  }
533
641
  }
534
642
  if (Object.keys(root).length > 0) {
535
- lines.push("", ...writeElement(root, "", ""));
643
+ lines.push("", ...writeElement(root, "", "", scripts));
536
644
  }
537
645
  return `${lines.join("\n")}\n`;
538
646
  }