@coldsmirk/abacus-core 0.5.0 → 0.6.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/README.md CHANGED
@@ -9,6 +9,7 @@ Part of [abacus](https://github.com/coldsmirk/abacus). To edit expressions in an
9
9
  - **Evaluate** ZEN expressions against a data context, sync or async.
10
10
  - **Compile** a structured, UI-authored condition tree into a single ZEN boolean expression — and **lift** the canonical form back into a tree for round-trip editing — plus **select** a matching branch by priority.
11
11
  - **Type-analyze** an expression against a variable-context type tree — the inferred type of every span, autocomplete metadata, and positioned diagnostics that power an editor.
12
+ - **Project and infer JSON Schema** through a lossless field-tree subset, then turn that tree into an expression variable type.
12
13
  - **Localize** all editor-produced text through a typed, extensible message catalog.
13
14
  - **Strong typing** end to end, dual ESM / CJS, and a single lazily-loaded WebAssembly engine instance.
14
15
 
@@ -213,6 +214,49 @@ interface ExpressionCompletion {
213
214
 
214
215
  > Building an editor? You usually do not call these directly — `@coldsmirk/abacus-codemirror` and `@coldsmirk/abacus-react` wire them into CodeMirror for you.
215
216
 
217
+ ## JSON Schema field trees
218
+
219
+ The schema helpers provide the data model behind a visual JSON Schema builder without depending on a UI framework. The tree hosts an object-oriented draft 2020-12 subset: `type`, `properties`, `items`, `required`, and `description`, plus the optional root `$schema` declaration. Parsing refuses every unsupported construct with a structured `SchemaTreeIssue`; it never accepts a keyword and then silently removes it on serialization.
220
+
221
+ ```ts
222
+ import {
223
+ inferSchema,
224
+ parseSchemaTree,
225
+ schemaTreeToExpressionType,
226
+ serializeSchemaTree
227
+ } from "@coldsmirk/abacus-core";
228
+
229
+ const inferred = inferSchema({
230
+ amount: 120,
231
+ customer: { name: "Ada" },
232
+ tags: ["priority"]
233
+ });
234
+
235
+ const parsed = parseSchemaTree(JSON.stringify(inferred));
236
+
237
+ if (parsed.ok) {
238
+ serializeSchemaTree(parsed.tree); // pretty-printed schema JSON
239
+ schemaTreeToExpressionType(parsed.tree); // { Object: { amount: "Number", ... } }
240
+ } else {
241
+ parsed.issue; // a localizable SchemaTreeIssue
242
+ }
243
+ ```
244
+
245
+ ```ts
246
+ function inferSchema(sample: Json): Record<string, Json>;
247
+ function parseSchemaTree(text: string): SchemaTreeParseResult;
248
+ function serializeSchemaTree(tree: SchemaTree): string;
249
+ function schemaTreeToExpressionType(tree: SchemaTree): ExpressionType;
250
+ function newSchemaTreeField(overrides?: Partial<Omit<SchemaTreeField, "id">>): SchemaTreeField;
251
+ const SCHEMA_DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
252
+ ```
253
+
254
+ `inferSchema` is intentionally conservative: integer samples infer `number`, no property becomes `required` from one observation, `null` says nothing (`{}`), and heterogeneous arrays merge to the loosest schema the field-tree subset can express.
255
+
256
+ The parser preserves JSON Schema applicability, not merely its visible fields. A schema with `properties` or `items` but no `type` still permits instances of other types, so `SchemaTree.explicitType` and an object/array field's `explicitType` remember that omission and serialization does not add an object/array type. Hand-built trees that omit the optional metadata keep the original builder default and emit an explicit type.
257
+
258
+ Property names are arbitrary JSON strings. Empty, whitespace-only, and prototype-like names such as `__proto__` round-trip and enter the generated `ExpressionType`. A parsed blank name carries `preserveBlankName: true`; a blank row from `newSchemaTreeField()` has no marker and remains an unfinished UI draft that is omitted from serialization and expression types. Hosts editing a parsed tree should update nodes immutably (for example, with object spread) so these optional source-preservation fields survive.
259
+
216
260
  ## Compiling conditions
217
261
 
218
262
  A common need is to let users author conditions in a UI (field / operator / value rows, AND/OR groups, prioritized branches) and turn that structure into a ZEN expression. This package models that structure and compiles it — without depending on any editor.
@@ -254,7 +298,7 @@ interface ConditionBranchInput { // a branch guarded by groups co
254
298
  | `contains` / `not_contains` | `contains(subject, value)` / `not contains(subject, value)` |
255
299
  | `starts_with` / `ends_with` | `startsWith(subject, value)` / `endsWith(subject, value)` |
256
300
  | `in` / `not_in` | `subject in [..]` / `not (subject in [..])` (a scalar value is wrapped to a one-element array) |
257
- | `is_empty` / `is_not_empty` | a typed emptiness test (null / blank string / empty array) and its negation |
301
+ | `is_empty` / `is_not_empty` | a typed emptiness test (null / blank string / empty array / empty object) and its negation |
258
302
 
259
303
  ### Compiling and selecting
260
304
 
@@ -354,14 +398,15 @@ All tree consumers share `MAX_CONDITION_TREE_DEPTH`: the root is depth 0 and up
354
398
 
355
399
  The compiler is designed not to become an expression-injection sink:
356
400
 
357
- - A **subject** is emitted verbatim, so it must be a plain identifier path (`amount`, `user.age`, `items[0]`) whose segments are not ZEN reserved words (`and`, `or`, `not`, `in`, `true`, `false`, `null`). Every array index token must also be inside ZEN's unsigned 96-bit decimal domain (`0` through `2^96 - 1`); the next value is a parser error, so the compiler rejects it instead of poisoning a larger expression. Anything else (`len(secret)`, `a or b`, a bare `true`, …) makes the condition compile to `null` and be dropped.
358
- - A **value** is serialized to a ZEN literal via `toZenLiteral`, never interpolated as code. Strings are quoted _raw_ (ZEN honours no backslash escapes) by picking a delimiter the value does not contain; a value that cannot be represented faithfully makes the condition degrade to `null`. For numbers that means passing `isZenRepresentableNumber`: inside ZEN's 96-bit decimal domain (finite, magnitude below ~7.9e28, no finer than 1e-28) **and** held identically by the engine's own context conversion — the engine converts context JS numbers lossily (roughly: integers whose text fits u64 are exact; other f64s are trimmed to ~16 significant digits), so a value like `0.1 + 0.2` or `1.2345678901234567` would compile into a literal that silently compares **unequal to itself** in a context. Such values are rejected instead.
401
+ - A **subject** is emitted verbatim, so it must be a plain identifier path (`amount`, `user.age`, `items[0]`). ZEN keywords cannot start the path; in dotted member positions the parser accepts `and` / `or` / `not` / `in` / `null` as object keys (`metadata.in` is valid), while `true` / `false` remain literal tokens and are rejected there too. Every array index token must also be inside ZEN's unsigned 96-bit decimal domain (`0` through `2^96 - 1`); the next value is a parser error, so the compiler rejects it instead of poisoning a larger expression. Anything else (`len(secret)`, `a or b`, a bare `true`, …) makes the condition compile to `null` and be dropped.
402
+ - A **value** is serialized to a ZEN literal via `toZenLiteral`, never interpolated as code. Strings are quoted _raw_ (ZEN honours no backslash escapes) by picking a single-quote, double-quote, or backtick delimiter the value does not contain; only a string containing all three delimiters is unrepresentable. For numbers, faithful representation means passing `isZenRepresentableNumber`: inside ZEN's 96-bit decimal domain (finite, magnitude below ~7.9e28, no finer than 1e-28) **and** held identically by the engine's own context conversion — the engine converts context JS numbers lossily (roughly: integers whose text fits u64 are exact; other f64s are trimmed to ~16 significant digits), so a value like `0.1 + 0.2` or `1.2345678901234567` would compile into a literal that silently compares **unequal to itself** in a context. Such values are rejected instead.
359
403
 
360
404
  ```ts
361
405
  function toZenLiteral(value: unknown): string; // throws if the value has no faithful ZEN representation
362
406
 
363
407
  toZenLiteral("active"); // "'active'"
364
408
  toZenLiteral("it's"); // "\"it's\"" (switches delimiter)
409
+ toZenLiteral(`He said "it's"`); // "`He said \"it's\"`"
365
410
  toZenLiteral(1500); // "1500"
366
411
  toZenLiteral(["BJ", "SH"]); // "['BJ', 'SH']"
367
412
  toZenLiteral(null); // "null"
@@ -390,7 +435,19 @@ configureExpressionMessages({ locale: "zh-CN" }); // switc
390
435
  configureExpressionMessages({ messages: { typeCheckSource: "Validation" } }); // override individual strings
391
436
  ```
392
437
 
393
- The catalog is **process-global and last-write-wins** — there is one active catalog per runtime, so configure it once at startup. Built-in catalogs `enMessages` (the default) and `zhCNMessages` are exported. `getExpressionMessages()` returns the active one.
438
+ The catalog is **process-global and last-write-wins** — there is one active catalog per runtime, so configure it once at startup. Built-in catalogs `enMessages` (the default) and `zhCNMessages` are exported. `getExpressionMessages()` returns the active one. Each `messages` object replaces the previous override set over the current locale base; omitting it removes earlier overrides instead of accumulating them.
439
+
440
+ Framework integrations that keep already-rendered diagnostics can subscribe to catalog changes. The listener runs synchronously after the new catalog becomes active; the returned cleanup is idempotent:
441
+
442
+ ```ts
443
+ import { subscribeExpressionMessages } from "@coldsmirk/abacus-core";
444
+
445
+ const unsubscribe = subscribeExpressionMessages(messages => {
446
+ refreshDiagnostics(messages);
447
+ });
448
+
449
+ unsubscribe();
450
+ ```
394
451
 
395
452
  To add a language the library does not ship, register a full catalog under a key, then select it like any built-in — built-in and custom locales share one registry, so there is no privileged path:
396
453
 
@@ -426,9 +483,10 @@ class ExpressionNotReadyError extends ExpressionError { // a *Sync helper calle
426
483
  - **Templates** — `analyzeTemplate` / `analyzeTemplateSync`, `getTemplateDiagnostics` / `getTemplateDiagnosticsSync`, `parseTemplateHoles`, `templateHoleAt`
427
484
  - **Conditions** — `compileCondition`, `compileGroup`, `compileBranch`, `selectBranch`, `selectBranchWith`, `toZenLiteral`, `isZenRepresentableNumber`, `CONDITION_OPERATORS`, `conditionOperatorArity`
428
485
  - **Condition trees** — `compileConditionTree`, `liftConditionTree`, `emptyConditionGroup`, `newConditionNodeId`, `ensureConditionNodeIds`, `MAX_CONDITION_TREE_DEPTH`, `CONDITION_TREE_OPERATORS`
429
- - **Localization** — `configureExpressionMessages`, `registerExpressionLocale`, `getExpressionMessages`, `enMessages`, `zhCNMessages`
486
+ - **JSON Schema** — `inferSchema`, `parseSchemaTree`, `serializeSchemaTree`, `schemaTreeToExpressionType`, `newSchemaTreeField`, `SCHEMA_DIALECT_2020_12`
487
+ - **Localization** — `configureExpressionMessages`, `registerExpressionLocale`, `getExpressionMessages`, `subscribeExpressionMessages`, `enMessages`, `zhCNMessages`
430
488
  - **Errors** — `ExpressionError`, `ExpressionNotReadyError`
431
- - **Types** — `ExpressionType`, `ExpressionMode`, `TemplateHole`, `ExpressionContext`, `ExpressionEngine`, `LoadEngineOptions`, `ExpressionAnalysis`, `ExpressionTypeSpan`, `ExpressionCompletion`, `ExpressionDiagnostic`, `ExpressionMessages`, `ExpressionLocale`, `BuiltInExpressionLocale`, `ConfigureMessagesOptions`, `ConditionInput`, `FieldConditionInput`, `ExpressionConditionInput`, `ConditionGroupInput`, `ConditionBranchInput`, `ConditionOperator`, `ConditionOperatorArity`, `BranchSelection`, `ConditionTreeGroup`, `ConditionTreeNode`, `ConditionTreeRule`, `ConditionTreeOperator`, `ConditionTreeValue`, `ConditionScalar`
489
+ - **Types** — `ExpressionType`, `ExpressionMode`, `TemplateHole`, `ExpressionContext`, `ExpressionEngine`, `LoadEngineOptions`, `ExpressionAnalysis`, `ExpressionTypeSpan`, `ExpressionCompletion`, `ExpressionDiagnostic`, `ExpressionMessages`, `ExpressionMessagesListener`, `ExpressionLocale`, `BuiltInExpressionLocale`, `ConfigureMessagesOptions`, `ConditionInput`, `FieldConditionInput`, `ExpressionConditionInput`, `ConditionGroupInput`, `ConditionBranchInput`, `ConditionOperator`, `ConditionOperatorArity`, `BranchSelection`, `ConditionTreeGroup`, `ConditionTreeNode`, `ConditionTreeRule`, `ConditionTreeOperator`, `ConditionTreeValue`, `ConditionScalar`, `Json`, `SchemaTree`, `SchemaTreeField`, `SchemaTreeFieldType`, `SchemaTreeIssue`, `SchemaTreeParseResult`
432
490
 
433
491
  ## License
434
492
 
package/dist/index.cjs CHANGED
@@ -359,8 +359,9 @@ function isZenConsistentNumber(value) {
359
359
  return context !== null && literal.digits === context.digits && literal.exponent === context.exponent;
360
360
  }
361
361
  const SUBJECT_PATTERN = /^[A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*|\[\d+\])*$/i;
362
+ const SUBJECT_IDENTIFIER_PATTERN = /[A-Z_$][\w$]*/gi;
362
363
  const SUBJECT_INDEX_PATTERN = /\[(?<index>\d+)\]/g;
363
- const ZEN_RESERVED_WORDS = new Set([
364
+ const ZEN_ROOT_RESERVED_WORDS = new Set([
364
365
  "and",
365
366
  "or",
366
367
  "not",
@@ -369,8 +370,11 @@ const ZEN_RESERVED_WORDS = new Set([
369
370
  "false",
370
371
  "null"
371
372
  ]);
373
+ const ZEN_MEMBER_RESERVED_WORDS = new Set(["true", "false"]);
372
374
  function isIdentifierPath(subject) {
373
- if (!SUBJECT_PATTERN.test(subject) || (subject.match(/[A-Z_$][\w$]*/gi) ?? []).some((segment) => ZEN_RESERVED_WORDS.has(segment))) return false;
375
+ if (!SUBJECT_PATTERN.test(subject)) return false;
376
+ const [root, ...members] = subject.match(SUBJECT_IDENTIFIER_PATTERN) ?? [];
377
+ if (root === void 0 || ZEN_ROOT_RESERVED_WORDS.has(root) || members.some((member) => ZEN_MEMBER_RESERVED_WORDS.has(member))) return false;
374
378
  for (const match of subject.matchAll(SUBJECT_INDEX_PATTERN)) if (match.groups?.index === void 0 || !isZenUnsignedIntegerText(match.groups.index)) return false;
375
379
  return true;
376
380
  }
@@ -395,14 +399,17 @@ function toZenLiteral(value) {
395
399
  function encodeZenString(value) {
396
400
  const hasSingle = value.includes("'");
397
401
  const hasDouble = value.includes("\"");
398
- if (hasSingle && hasDouble) throw new ExpressionError("String contains both single and double quotes and has no ZEN literal representation");
399
- return hasSingle ? `"${value}"` : `'${value}'`;
402
+ const hasBacktick = value.includes("`");
403
+ if (!hasSingle) return `'${value}'`;
404
+ if (!hasDouble) return `"${value}"`;
405
+ if (!hasBacktick) return `\`${value}\``;
406
+ throw new ExpressionError("String contains every ZEN raw-string delimiter and has no literal representation");
400
407
  }
401
408
  function toArrayLiteral(value) {
402
409
  return isArray(value) ? toZenLiteral(value) : `[${toZenLiteral(value)}]`;
403
410
  }
404
411
  function zenIsEmpty(subject) {
405
- return `(${subject} == null or (type(${subject}) == 'string' and len(trim(${subject})) == 0) or (type(${subject}) == 'array' and len(${subject}) == 0))`;
412
+ return `(${subject} == null or (type(${subject}) == 'string' and len(trim(${subject})) == 0) or (type(${subject}) == 'array' and len(${subject}) == 0) or (type(${subject}) == 'object' and len(keys(${subject})) == 0))`;
406
413
  }
407
414
  function compileFieldCondition(subject, operator, value) {
408
415
  switch (operator) {
@@ -970,7 +977,7 @@ function tokenize(input) {
970
977
  index += 1;
971
978
  continue;
972
979
  }
973
- if (char === "'" || char === "\"") {
980
+ if (char === "'" || char === "\"" || char === "`") {
974
981
  const end = input.indexOf(char, index + 1);
975
982
  if (end === -1) return null;
976
983
  tokens.push({
@@ -1153,33 +1160,45 @@ const zhCNMessages = {
1153
1160
  expectedType: (expectedType, actualType) => `期望 \`${expectedType}\`,实际为 \`${actualType}\`。`
1154
1161
  };
1155
1162
  const localeRegistry = new Map([["en-US", enMessages], ["zh-CN", zhCNMessages]]);
1163
+ let activeBaseMessages = enMessages;
1156
1164
  let activeMessages = enMessages;
1165
+ const messageListeners = /* @__PURE__ */ new Set();
1157
1166
  function registerExpressionLocale(locale, messages) {
1158
1167
  localeRegistry.set(locale, messages);
1159
1168
  }
1160
1169
  function configureExpressionMessages({ locale, messages }) {
1161
- const base = locale === void 0 ? activeMessages : localeRegistry.get(locale) ?? activeMessages;
1162
- activeMessages = messages === void 0 ? base : {
1170
+ const base = locale === void 0 ? activeBaseMessages : localeRegistry.get(locale) ?? activeBaseMessages;
1171
+ const nextMessages = messages === void 0 ? base : {
1163
1172
  ...base,
1164
1173
  ...messages
1165
1174
  };
1175
+ activeBaseMessages = base;
1176
+ if (nextMessages === activeMessages) return;
1177
+ activeMessages = nextMessages;
1178
+ for (const listener of messageListeners) listener(activeMessages);
1166
1179
  }
1167
1180
  function getExpressionMessages() {
1168
1181
  return activeMessages;
1169
1182
  }
1183
+ function subscribeExpressionMessages(listener) {
1184
+ messageListeners.add(listener);
1185
+ return () => {
1186
+ messageListeners.delete(listener);
1187
+ };
1188
+ }
1170
1189
  function parseOffset(text) {
1171
1190
  const trimmed = text?.trim();
1172
1191
  return trimmed ? Number(trimmed) : NaN;
1173
1192
  }
1174
1193
  function extractPosition(message) {
1175
- const segments = message.split(" at ");
1176
- const last = segments.length <= 1 ? void 0 : segments.at(-1);
1177
- if (last === void 0) return null;
1178
- const [left, right] = last.replace("(", "").replace(")", "").split(", ");
1179
- const from = parseOffset(left);
1180
- if (Number.isNaN(from)) return null;
1181
- const to = parseOffset(right);
1182
- return [from, Number.isNaN(to) ? from : to];
1194
+ const positionPattern = / at (?:\(\s*(?<rangeFrom>\d+)\s*,\s*(?<rangeTo>\d+)\s*\)|(?<point>\d+))(?=\s*(?:;|$))/g;
1195
+ let position = null;
1196
+ for (const match of message.matchAll(positionPattern)) {
1197
+ const from = parseOffset(match.groups?.rangeFrom ?? match.groups?.point);
1198
+ const to = parseOffset(match.groups?.rangeTo);
1199
+ position = [from, Number.isNaN(to) ? from : to];
1200
+ }
1201
+ return position;
1183
1202
  }
1184
1203
  function normalizeDiagnostic(raw, source) {
1185
1204
  if (raw === null || raw === void 0) return null;
@@ -1299,11 +1318,301 @@ async function getTemplateDiagnostics(source) {
1299
1318
  await loadEngine();
1300
1319
  return getTemplateDiagnosticsSync(source);
1301
1320
  }
1321
+ function fieldType(field) {
1322
+ switch (field.type) {
1323
+ case "string": return "String";
1324
+ case "number": return "Number";
1325
+ case "integer": return "Number";
1326
+ case "boolean": return "Bool";
1327
+ case "object": return { Object: fieldRecord(field.children) };
1328
+ case "array": return { Array: field.items === null ? "Any" : fieldType(field.items) };
1329
+ case "any": return "Any";
1330
+ }
1331
+ }
1332
+ function fieldRecord(fields) {
1333
+ return Object.fromEntries(fields.filter((field) => field.name.trim() !== "" || field.preserveBlankName === true).map((field) => [field.name, fieldType(field)]));
1334
+ }
1335
+ function schemaTreeToExpressionType(tree) {
1336
+ return { Object: fieldRecord(tree.fields) };
1337
+ }
1338
+ function mergeSchemas(a, b) {
1339
+ if (a.type === "object" && b.type === "object") {
1340
+ const left = a.properties ?? {};
1341
+ const right = b.properties ?? {};
1342
+ const entries = [];
1343
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
1344
+ for (const key of keys) {
1345
+ const hasLeft = Object.hasOwn(left, key);
1346
+ const hasRight = Object.hasOwn(right, key);
1347
+ entries.push([key, hasLeft && hasRight ? mergeSchemas(left[key], right[key]) : hasLeft ? left[key] : right[key]]);
1348
+ }
1349
+ const properties = Object.fromEntries(entries);
1350
+ return Object.keys(properties).length > 0 ? {
1351
+ type: "object",
1352
+ properties
1353
+ } : { type: "object" };
1354
+ }
1355
+ if (a.type === "array" && b.type === "array") return a.items !== void 0 && b.items !== void 0 ? {
1356
+ type: "array",
1357
+ items: mergeSchemas(a.items, b.items)
1358
+ } : { type: "array" };
1359
+ return JSON.stringify(a) === JSON.stringify(b) ? a : {};
1360
+ }
1361
+ function inferSchema(sample) {
1362
+ if (sample === null) return {};
1363
+ if (Array.isArray(sample)) {
1364
+ if (sample.length === 0) return { type: "array" };
1365
+ return {
1366
+ type: "array",
1367
+ items: sample.map((element) => inferSchema(element)).reduce((left, right) => mergeSchemas(left, right))
1368
+ };
1369
+ }
1370
+ if (typeof sample === "string") return { type: "string" };
1371
+ if (typeof sample === "number") return { type: "number" };
1372
+ if (typeof sample === "boolean") return { type: "boolean" };
1373
+ const properties = Object.fromEntries(Object.entries(sample).map(([key, value]) => [key, inferSchema(value)]));
1374
+ return Object.keys(properties).length > 0 ? {
1375
+ type: "object",
1376
+ properties
1377
+ } : { type: "object" };
1378
+ }
1379
+ const SCHEMA_DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
1380
+ const fieldIdRealm = Math.random().toString(36).slice(2, 7);
1381
+ let fieldIdCounter = 0;
1382
+ function newSchemaTreeField(overrides = {}) {
1383
+ fieldIdCounter += 1;
1384
+ return {
1385
+ id: `sf-${fieldIdRealm}-${fieldIdCounter}`,
1386
+ name: "",
1387
+ type: "string",
1388
+ required: false,
1389
+ description: "",
1390
+ children: [],
1391
+ items: null,
1392
+ ...overrides
1393
+ };
1394
+ }
1395
+ const SCALAR_TYPES = new Set([
1396
+ "string",
1397
+ "number",
1398
+ "integer",
1399
+ "boolean"
1400
+ ]);
1401
+ const ROOT_KEYS = new Set([
1402
+ "$schema",
1403
+ "type",
1404
+ "properties",
1405
+ "required",
1406
+ "description"
1407
+ ]);
1408
+ const SUBSCHEMA_KEYS = new Set([
1409
+ "type",
1410
+ "properties",
1411
+ "required",
1412
+ "items",
1413
+ "description"
1414
+ ]);
1415
+ var Unsupported = class extends Error {
1416
+ issue;
1417
+ constructor(issue) {
1418
+ super(issue.code);
1419
+ this.issue = issue;
1420
+ }
1421
+ };
1422
+ function asPlainObject(value) {
1423
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
1424
+ }
1425
+ function checkKeys(schema, allowed) {
1426
+ for (const key of Object.keys(schema)) if (!allowed.has(key)) throw new Unsupported({
1427
+ code: "unsupported-keyword",
1428
+ keyword: key
1429
+ });
1430
+ }
1431
+ function parseObjectFields(schema) {
1432
+ const properties = schema.properties === void 0 ? null : asPlainObject(schema.properties);
1433
+ if (schema.properties !== void 0 && properties === null) throw new Unsupported({ code: "invalid-properties" });
1434
+ const fields = [];
1435
+ const entries = Object.entries(properties ?? {});
1436
+ for (const [name, subschema] of entries) fields.push({
1437
+ ...parseSubschema(subschema, name),
1438
+ name,
1439
+ ...name.trim() === "" && { preserveBlankName: true }
1440
+ });
1441
+ if (schema.required !== void 0) {
1442
+ if (!Array.isArray(schema.required)) throw new Unsupported({ code: "invalid-required" });
1443
+ for (const entry of schema.required) {
1444
+ if (typeof entry !== "string") throw new Unsupported({ code: "invalid-required" });
1445
+ const field = fields.find((candidate) => candidate.name === entry);
1446
+ if (field === void 0) throw new Unsupported({
1447
+ code: "unknown-required-field",
1448
+ field: entry
1449
+ });
1450
+ field.required = true;
1451
+ }
1452
+ }
1453
+ return fields;
1454
+ }
1455
+ function parseDescription(schema) {
1456
+ if (schema.description === void 0) return "";
1457
+ if (typeof schema.description !== "string") throw new Unsupported({ code: "invalid-description" });
1458
+ return schema.description;
1459
+ }
1460
+ function ensureAbsent(schema, key, holder) {
1461
+ if (schema[key] !== void 0) throw new Unsupported({
1462
+ code: "misplaced-keyword",
1463
+ keyword: key,
1464
+ holder
1465
+ });
1466
+ }
1467
+ function parseSubschema(value, name) {
1468
+ const schema = asPlainObject(value);
1469
+ if (schema === null) throw new Unsupported({
1470
+ code: "invalid-field-definition",
1471
+ field: name
1472
+ });
1473
+ checkKeys(schema, SUBSCHEMA_KEYS);
1474
+ const { type } = schema;
1475
+ if (type !== void 0 && typeof type !== "string") throw new Unsupported({
1476
+ code: "invalid-field-type",
1477
+ field: name
1478
+ });
1479
+ const description = parseDescription(schema);
1480
+ if (type === "object" || type === void 0 && (schema.properties !== void 0 || schema.required !== void 0)) {
1481
+ ensureAbsent(schema, "items", "array");
1482
+ return newSchemaTreeField({
1483
+ type: "object",
1484
+ description,
1485
+ children: parseObjectFields(schema),
1486
+ explicitType: type === "object"
1487
+ });
1488
+ }
1489
+ if (type === "array" || type === void 0 && schema.items !== void 0) {
1490
+ ensureAbsent(schema, "properties", "object");
1491
+ ensureAbsent(schema, "required", "object");
1492
+ return newSchemaTreeField({
1493
+ type: "array",
1494
+ description,
1495
+ items: schema.items === void 0 ? null : parseSubschema(schema.items, name),
1496
+ explicitType: type === "array"
1497
+ });
1498
+ }
1499
+ if (type === void 0) {
1500
+ ensureAbsent(schema, "items", "array");
1501
+ return newSchemaTreeField({
1502
+ type: "any",
1503
+ description
1504
+ });
1505
+ }
1506
+ if (!SCALAR_TYPES.has(type)) throw new Unsupported({
1507
+ code: "unsupported-field-type",
1508
+ field: name,
1509
+ type
1510
+ });
1511
+ ensureAbsent(schema, "properties", "object");
1512
+ ensureAbsent(schema, "required", "object");
1513
+ ensureAbsent(schema, "items", "array");
1514
+ return newSchemaTreeField({
1515
+ type,
1516
+ description
1517
+ });
1518
+ }
1519
+ function parseSchemaTree(text) {
1520
+ if (text.trim() === "") return {
1521
+ ok: true,
1522
+ tree: {
1523
+ fields: [],
1524
+ dialect: false,
1525
+ description: ""
1526
+ }
1527
+ };
1528
+ let parsed;
1529
+ try {
1530
+ parsed = JSON.parse(text);
1531
+ } catch {
1532
+ return {
1533
+ ok: false,
1534
+ issue: { code: "invalid-json" }
1535
+ };
1536
+ }
1537
+ try {
1538
+ const root = asPlainObject(parsed);
1539
+ if (root === null) throw new Unsupported({ code: "root-not-object" });
1540
+ checkKeys(root, ROOT_KEYS);
1541
+ const dialect = root.$schema !== void 0;
1542
+ if (dialect && root.$schema !== "https://json-schema.org/draft/2020-12/schema") throw new Unsupported({ code: "unsupported-dialect" });
1543
+ if (root.type !== void 0 && root.type !== "object") throw new Unsupported({ code: "root-not-object" });
1544
+ return {
1545
+ ok: true,
1546
+ tree: {
1547
+ fields: parseObjectFields(root),
1548
+ dialect,
1549
+ description: parseDescription(root),
1550
+ explicitType: root.type === "object"
1551
+ }
1552
+ };
1553
+ } catch (error) {
1554
+ if (error instanceof Unsupported) return {
1555
+ ok: false,
1556
+ issue: error.issue
1557
+ };
1558
+ throw error;
1559
+ }
1560
+ }
1561
+ function serializeObjectBody(fields) {
1562
+ const named = fields.filter((field) => field.name.trim() !== "" || field.preserveBlankName === true);
1563
+ const finalByName = /* @__PURE__ */ new Map();
1564
+ const finalFields = [];
1565
+ const body = {};
1566
+ for (const field of named) finalByName.set(field.name, field);
1567
+ finalByName.forEach((field) => {
1568
+ finalFields.push(field);
1569
+ });
1570
+ if (finalFields.length > 0) body.properties = Object.fromEntries(finalFields.map((field) => [field.name, serializeField(field)]));
1571
+ const required = finalFields.filter((field) => field.required).map((field) => field.name);
1572
+ if (required.length > 0) body.required = required;
1573
+ return body;
1574
+ }
1575
+ function serializeField(field) {
1576
+ const description = field.description === "" ? {} : { description: field.description };
1577
+ switch (field.type) {
1578
+ case "any": return { ...description };
1579
+ case "object": return {
1580
+ ...field.explicitType !== false && { type: "object" },
1581
+ ...description,
1582
+ ...serializeObjectBody(field.children)
1583
+ };
1584
+ case "array": {
1585
+ const type = field.explicitType === false ? {} : { type: "array" };
1586
+ return field.items === null || field.items.type === "any" && field.items.description === "" ? {
1587
+ ...type,
1588
+ ...description
1589
+ } : {
1590
+ ...type,
1591
+ ...description,
1592
+ items: serializeField(field.items)
1593
+ };
1594
+ }
1595
+ default: return {
1596
+ type: field.type,
1597
+ ...description
1598
+ };
1599
+ }
1600
+ }
1601
+ function serializeSchemaTree(tree) {
1602
+ const root = {
1603
+ ...tree.dialect && { $schema: "https://json-schema.org/draft/2020-12/schema" },
1604
+ ...tree.explicitType !== false && { type: "object" },
1605
+ ...tree.description !== "" && { description: tree.description },
1606
+ ...serializeObjectBody(tree.fields)
1607
+ };
1608
+ return `${JSON.stringify(root, null, 2)}\n`;
1609
+ }
1302
1610
  exports.CONDITION_OPERATORS = CONDITION_OPERATORS;
1303
1611
  exports.CONDITION_TREE_OPERATORS = CONDITION_TREE_OPERATORS;
1304
1612
  exports.ExpressionError = ExpressionError;
1305
1613
  exports.ExpressionNotReadyError = ExpressionNotReadyError;
1306
1614
  exports.MAX_CONDITION_TREE_DEPTH = MAX_CONDITION_TREE_DEPTH;
1615
+ exports.SCHEMA_DIALECT_2020_12 = SCHEMA_DIALECT_2020_12;
1307
1616
  exports.analyzeTemplate = analyzeTemplate;
1308
1617
  exports.analyzeTemplateSync = analyzeTemplateSync;
1309
1618
  exports.analyzeTypes = analyzeTypes;
@@ -1331,18 +1640,24 @@ exports.getEngineSync = getEngineSync;
1331
1640
  exports.getExpressionMessages = getExpressionMessages;
1332
1641
  exports.getTemplateDiagnostics = getTemplateDiagnostics;
1333
1642
  exports.getTemplateDiagnosticsSync = getTemplateDiagnosticsSync;
1643
+ exports.inferSchema = inferSchema;
1334
1644
  exports.isEngineReady = isEngineReady;
1335
1645
  exports.isZenRepresentableNumber = isZenRepresentableNumber;
1336
1646
  exports.liftConditionTree = liftConditionTree;
1337
1647
  exports.loadEngine = loadEngine;
1338
1648
  exports.newConditionNodeId = newConditionNodeId;
1649
+ exports.newSchemaTreeField = newSchemaTreeField;
1650
+ exports.parseSchemaTree = parseSchemaTree;
1339
1651
  exports.parseTemplateHoles = parseTemplateHoles;
1340
1652
  exports.registerExpressionLocale = registerExpressionLocale;
1341
1653
  exports.resetEngine = resetEngine;
1342
1654
  exports.satisfiesType = satisfiesType;
1343
1655
  exports.satisfiesTypeSync = satisfiesTypeSync;
1656
+ exports.schemaTreeToExpressionType = schemaTreeToExpressionType;
1344
1657
  exports.selectBranch = selectBranch;
1345
1658
  exports.selectBranchWith = selectBranchWith;
1659
+ exports.serializeSchemaTree = serializeSchemaTree;
1660
+ exports.subscribeExpressionMessages = subscribeExpressionMessages;
1346
1661
  exports.templateHoleAt = templateHoleAt;
1347
1662
  exports.toZenLiteral = toZenLiteral;
1348
1663
  exports.zhCNMessages = zhCNMessages;