UNPKG

vira

Version:

A simple and highly versatile design system using element-vir.

730 lines (729 loc) 22.6 kB
import { assertWrap, check } from '@augment-vir/assert'; import { omitObjectKeys, removeDuplicates, removePrefix } from '@augment-vir/common'; /** * The JSON types supported by `ViraJsonForm`. * * @category Internal */ export var ViraJsonType; (function (ViraJsonType) { ViraJsonType["String"] = "string"; ViraJsonType["Number"] = "number"; ViraJsonType["Integer"] = "integer"; ViraJsonType["Boolean"] = "boolean"; ViraJsonType["Null"] = "null"; ViraJsonType["Object"] = "object"; ViraJsonType["Array"] = "array"; })(ViraJsonType || (ViraJsonType = {})); /** * Human-friendly labels for each {@link ViraJsonType}. * * @category Internal */ export const viraJsonTypeLabels = { [ViraJsonType.String]: 'string', [ViraJsonType.Number]: 'number', [ViraJsonType.Integer]: 'integer', [ViraJsonType.Boolean]: 'boolean', [ViraJsonType.Null]: 'null', [ViraJsonType.Object]: 'object', [ViraJsonType.Array]: 'array', }; const allJsonTypes = [ ViraJsonType.String, ViraJsonType.Number, ViraJsonType.Integer, ViraJsonType.Boolean, ViraJsonType.Null, ViraJsonType.Object, ViraJsonType.Array, ]; /** * Classifies a JSON value's runtime type. Note that {@link ViraJsonType.Integer} is never returned; * it only appears in schemas. All numbers (whole or fractional) classify as * {@link ViraJsonType.Number}, since JSON has no separate integer type at runtime. * * @category Internal */ export function getJsonType(value) { if (value === null) { return ViraJsonType.Null; } else if (check.isArray(value)) { return ViraJsonType.Array; } else if (check.isObject(value)) { return ViraJsonType.Object; } else if (check.isBoolean(value)) { return ViraJsonType.Boolean; } else if (check.isNumber(value)) { return ViraJsonType.Number; } else { return ViraJsonType.String; } } /** * Normalizes a schema to a non-boolean object form. `true` becomes an unconstrained schema (`{}`), * `false` becomes `undefined` (nothing is allowed; treated as "no schema available"). * * @category Internal */ export function normalizeSchema(schema) { if (schema === undefined || schema === false) { return undefined; } else if (schema === true) { return {}; } else { return schema; } } function jsonTypeFromSchemaType(type) { if (allJsonTypes.includes(type)) { return type; } return undefined; } /** @category Internal */ export function createResolveContext(schema) { return { root: normalizeSchema(schema), seenRefs: new Set(), }; } function resolveRef(ref, context) { if (context.seenRefs.has(ref) || !context.root) { return { resolved: undefined, context, }; } const defsKey = ref.startsWith('#/$defs/') ? removePrefix({ value: ref, prefix: '#/$defs/', }) : ref.startsWith('#/definitions/') ? removePrefix({ value: ref, prefix: '#/definitions/', }) : undefined; if (!defsKey) { return { resolved: undefined, context, }; } const defsRecord = context.root.$defs ?? context.root.definitions; const resolved = normalizeSchema(defsRecord?.[defsKey]); return { resolved, context: { root: context.root, seenRefs: new Set([ ...context.seenRefs, ref, ]), }, }; } /** * Resolves a schema through `$ref` (via its root's `$defs`/`definitions`), returning the resolved * schema and an updated resolve context. Non-ref schemas are returned as-is. * * @category Internal */ export function resolveSchema(schema, context) { const normalized = normalizeSchema(schema); if (!normalized) { return { resolved: undefined, context, }; } const ref = normalized.$ref; if (check.isString(ref)) { return resolveRef(ref, context); } return { resolved: normalized, context, }; } /** * Flattens `anyOf` / `oneOf` branches into a single list of non-union schemas so that the editor * can treat them uniformly. * * @category Internal */ export function expandSchemaBranches(schema, context) { const { resolved } = resolveSchema(schema, context); if (!resolved) { return []; } const branches = [ ...(resolved.anyOf ?? []), ...(resolved.oneOf ?? []), ]; if (branches.length === 0) { return [resolved]; } return branches.flatMap((branch) => expandSchemaBranches(branch, { root: context.root, seenRefs: context.seenRefs, })); } /** * Computes the set of JSON types that a given schema allows. * * @category Internal */ export function getAllowedJsonTypes(schema, context) { const branches = expandSchemaBranches(schema, context); if (branches.length === 0) { return allJsonTypes; } const collected = []; for (const branch of branches) { const schemaType = branch.type; if (schemaType == undefined) { if ('const' in branch || 'enum' in branch) { const source = 'enum' in branch && check.isArray(branch.enum) ? branch.enum : [branch.const]; for (const entry of source) { collected.push(getJsonType(entry)); } } else { return allJsonTypes; } } else if (check.isArray(schemaType)) { for (const entry of schemaType) { const mapped = jsonTypeFromSchemaType(entry); if (mapped) { collected.push(mapped); } } } else { const mapped = jsonTypeFromSchemaType(schemaType); if (mapped) { collected.push(mapped); } } } return removeDuplicates(collected); } /** @category Internal */ export function createDefaultForJsonType(type) { const defaults = { [ViraJsonType.String]: '', [ViraJsonType.Number]: 0, [ViraJsonType.Integer]: 0, [ViraJsonType.Boolean]: false, [ViraJsonType.Null]: null, [ViraJsonType.Object]: {}, [ViraJsonType.Array]: [], }; return defaults[type]; } /** * Picks the first schema branch whose `type` contains (or is) the given JSON type. Used to narrow * the schema when traversing into a concrete value. * * @category Internal */ export function pickBranchForType(schema, valueType, context) { const branches = expandSchemaBranches(schema, context); for (const branch of branches) { const branchTypes = getAllowedJsonTypes(branch, context); if (branchTypes.length === 0 || branchTypes.includes(valueType)) { return branch; } } const numericFallback = valueType === ViraJsonType.Number ? ViraJsonType.Integer : valueType === ViraJsonType.Integer ? ViraJsonType.Number : undefined; if (numericFallback) { for (const branch of branches) { const branchTypes = getAllowedJsonTypes(branch, context); if (branchTypes.includes(numericFallback)) { return branch; } } } return branches[0]; } /** * Finds the sub-schema for a given object property key, considering `properties`, * `patternProperties`, and `additionalProperties`. * * @category Internal */ export function getPropertySchema(parentSchema, key, context) { const branch = pickBranchForType(parentSchema, ViraJsonType.Object, context); if (!branch) { return undefined; } const propertySchema = branch.properties?.[key]; if (propertySchema !== undefined) { return propertySchema; } const patternProperties = branch.patternProperties; if (patternProperties) { for (const [pattern, subSchema,] of Object.entries(patternProperties)) { try { if (new RegExp(pattern).test(key)) { return subSchema; } } catch { continue; } } } return branch.additionalProperties; } /** * Finds the sub-schema for an array item at a given index, considering tuple `items` arrays and * `additionalItems` fallbacks. * * @category Internal */ export function getItemSchema(parentSchema, index, context) { const branch = pickBranchForType(parentSchema, ViraJsonType.Array, context); if (!branch) { return undefined; } const items = branch.items; if (check.isArray(items)) { return items[index] ?? branch.additionalItems; } return items; } /** * Returns the schema used when appending a new item to an array. Falls back to `additionalItems` * when `items` is a tuple array and the new index is beyond the tuple's length. * * @category Internal */ export function getNewItemSchema(parentSchema, currentLength, context) { return getItemSchema(parentSchema, currentLength, context); } /** * Returns the schema used when adding an arbitrary new field to an object. When no schema is * provided at all the editor is unconstrained, so additions are allowed; once a schema is provided, * additions are only allowed when the schema explicitly opts in via `additionalProperties: true` or * an `additionalProperties` sub-schema. This is stricter than JSON Schema's `true` default — UI * authors must opt in. * * @category Internal */ export function getAdditionalPropertiesSchema(parentSchema, context) { if (parentSchema === undefined) { return { allowed: true, schema: undefined, }; } const branch = pickBranchForType(parentSchema, ViraJsonType.Object, context); if (!branch) { return { allowed: false, schema: undefined, }; } const additional = branch.additionalProperties; if (additional === undefined || additional === false) { return { allowed: false, schema: undefined, }; } return { allowed: true, schema: additional === true ? undefined : additional, }; } /** * Returns the `enum` values defined by any resolved branch of a schema. Aggregates across * `anyOf`/`oneOf` branches and includes `const` values so the editor can offer them as a select. * * @category Internal */ export function getSchemaEnumValues(schema, context) { const branches = expandSchemaBranches(schema, context); if (branches.length === 0) { return undefined; } const collected = []; for (const branch of branches) { if ('enum' in branch && check.isArray(branch.enum)) { for (const entry of branch.enum) { collected.push(entry); } } else if ('const' in branch) { collected.push(branch.const); } else { return undefined; } } return collected.length > 0 ? collected : undefined; } /** * Collects the string `enum`/`const` values declared by any branch of a schema, ignoring branches * that declare no enum/const. Unlike {@link getSchemaEnumValues}, a schema that mixes an enum branch * with a free-form branch still yields the enum values (rather than bailing out). Non-string enum * entries are excluded. * * @category Internal */ export function getStringEnumValues(schema, context) { const branches = expandSchemaBranches(schema, context); const values = branches.flatMap((branch) => { if ('enum' in branch && check.isArray(branch.enum)) { return branch.enum.filter(check.isString); } else if ('const' in branch && check.isString(branch.const)) { return [branch.const]; } else { return []; } }); return removeDuplicates(values); } /** * Returns whether the schema permits an arbitrary (non-enum, non-const) string in any of its * branches. Used alongside {@link getStringEnumValues} to detect fields that accept both a fixed set * of enum options and free-form text. * * @category Internal */ export function allowsFreeformString(schema, context) { return expandSchemaBranches(schema, context).some((branch) => { if ('enum' in branch || 'const' in branch) { return false; } return getAllowedJsonTypes(branch, context).includes(ViraJsonType.String); }); } /** * Returns the list of required property names declared by the object branch of a schema. * * @category Internal */ export function getRequiredProperties(schema, context) { const branch = pickBranchForType(schema, ViraJsonType.Object, context); return branch?.required ?? []; } /** * Returns the `properties` map declared by the object branch of a schema, or an empty record when * none is defined. * * @category Internal */ export function getDefinedProperties(schema, context) { const branch = pickBranchForType(schema, ViraJsonType.Object, context); return branch?.properties ?? {}; } /** * Reads the title of a schema's first resolved branch (for display purposes). * * @category Internal */ export function getSchemaTitle(schema, context) { const branches = expandSchemaBranches(schema, context); for (const branch of branches) { if (check.isString(branch.title)) { return branch.title; } } return undefined; } function formatPathLabel(path) { if (path.length === 0) { return 'root'; } return path .map((segment) => (typeof segment === 'number' ? `[${segment}]` : `.${segment}`)) .join('') .replace(/^\./, ''); } function isJsonValueMatchingType(value, type) { if (type === ViraJsonType.Integer) { return check.isNumber(value) && Number.isInteger(value); } return getJsonType(value) === type; } /** * Validates a JSON value against the (loose) JSON Schema subset supported by `ViraJsonForm`. * Returns a list of human-readable error messages; an empty array means the value is valid. Only * the constraints understood by the editor (`type`, `required`, `properties`, `additionalProperties * = false`, `items`, `enum`, `const`, `anyOf`, `oneOf`, `$ref`) are checked. * * @category Internal */ export function validateAgainstSchema(value, schema) { if (schema === undefined) { return []; } const context = createResolveContext(schema); const errors = []; validateRecursive({ value, schema, path: [], context, errors, }); return errors; } function validateRecursive({ value, schema, path, context, errors, }) { const branches = expandSchemaBranches(schema, context); if (branches.length === 0) { return; } const branchErrorSets = []; for (const branch of branches) { const branchErrors = []; validateBranch({ value, branch, path, context, errors: branchErrors, }); if (branchErrors.length === 0) { return; } branchErrorSets.push(branchErrors); } if (branches.length === 1) { errors.push(...assertWrap.isDefined(branchErrorSets[0])); } else { errors.push(`${formatPathLabel(path)} did not match any allowed schema branch (anyOf/oneOf).`); } } function validateBranch({ value, branch, path, context, errors, }) { const allowedTypes = getAllowedJsonTypes(branch, context); const concreteType = getJsonType(value); if (allowedTypes.length > 0) { const matchesAny = allowedTypes.some((type) => isJsonValueMatchingType(value, type)); if (!matchesAny) { const allowedLabel = allowedTypes.map((type) => viraJsonTypeLabels[type]).join(' | '); errors.push(`${formatPathLabel(path)} expected ${allowedLabel} but got ${concreteType === ViraJsonType.Number && Number.isInteger(value) ? 'integer' : viraJsonTypeLabels[concreteType]}.`); return; } } if ('const' in branch && !deepEqualsJson({ a: value, b: branch.const, })) { errors.push(`${formatPathLabel(path)} must equal const value.`); return; } if ('enum' in branch && check.isArray(branch.enum)) { const matched = branch.enum.some((entry) => deepEqualsJson({ a: value, b: entry, })); if (!matched) { errors.push(`${formatPathLabel(path)} must be one of the enum values.`); return; } } if (check.isObject(value)) { const required = branch.required ?? []; for (const key of required) { if (!(key in value)) { errors.push(`${formatPathLabel(path)} is missing required property "${key}".`); } } const additional = branch.additionalProperties; const definedKeys = new Set(Object.keys(branch.properties ?? {})); for (const [propKey, propValue,] of Object.entries(value)) { const propPath = [ ...path, propKey, ]; const propSchema = branch.properties?.[propKey]; if (propSchema !== undefined) { validateRecursive({ value: propValue, schema: propSchema, path: propPath, context, errors, }); } else if (additional === false) { errors.push(`${formatPathLabel(propPath)} is not allowed (additionalProperties is false).`); } else if (check.isObject(additional)) { validateRecursive({ value: propValue, schema: additional, path: propPath, context, errors, }); } else if (additional === undefined && definedKeys.size > 0) { continue; } } } else if (check.isArray(value)) { const items = branch.items; value.forEach((item, index) => { const itemPath = [ ...path, index, ]; if (check.isArray(items)) { const tupleSchema = items[index] ?? branch.additionalItems; if (tupleSchema !== undefined) { validateRecursive({ value: item, schema: tupleSchema, path: itemPath, context, errors, }); } } else if (items !== undefined) { validateRecursive({ value: item, schema: items, path: itemPath, context, errors, }); } }); } } function deepEqualsJson({ a, b }) { if (a === b) { return true; } else if (check.isArray(a) && check.isArray(b)) { return (a.length === b.length && a.every((entry, i) => deepEqualsJson({ a: entry, b: b[i] ?? null, }))); } else if (check.isObject(a) && check.isObject(b)) { const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) { return false; } return aKeys.every((key) => deepEqualsJson({ a: a[key] ?? null, b: b[key] ?? null, })); } else { return false; } } /** @category Internal */ export function pathToKey(path) { return JSON.stringify(path); } /** * Returns a new JSON value where the value at `path` has been replaced with `newValue`. * * @category Internal */ export function setValueAtPath({ root, path, newValue, }) { if (path.length === 0) { return newValue; } const head = assertWrap.isDefined(path[0]); const rest = path.slice(1); if (typeof head === 'number') { const array = check.isArray(root) ? root : []; const existing = array[head] ?? null; const replaced = setValueAtPath({ root: existing, path: rest, newValue, }); if (head >= array.length) { const extended = [...array]; while (extended.length < head) { extended.push(null); } extended.push(replaced); return extended; } return array.map((item, index) => (index === head ? replaced : item)); } else { const object = check.isObject(root) ? root : {}; const existing = object[head] ?? null; return { ...object, [head]: setValueAtPath({ root: existing, path: rest, newValue, }), }; } } /** * Returns a new JSON value where the value at `path` has been removed. If `path` targets an object * key, the key is deleted; if it targets an array index, the item is spliced out. * * @category Internal */ export function deleteValueAtPath(root, path) { if (path.length === 0) { return root; } const head = assertWrap.isDefined(path[0]); const rest = path.slice(1); if (rest.length === 0) { if (typeof head === 'number' && check.isArray(root)) { return root.filter((unusedItem, index) => index !== head); } else if (typeof head === 'string' && check.isObject(root)) { return omitObjectKeys(root, [head]); } else { return root; } } else if (typeof head === 'number' && check.isArray(root)) { return root.map((item, index) => (index === head ? deleteValueAtPath(item, rest) : item)); } else if (typeof head === 'string' && check.isObject(root)) { return { ...root, [head]: deleteValueAtPath(root[head] ?? null, rest), }; } else { return root; } }