waybetter/packages/create-waybetter/tests/jsonSchema.mjs
CRT 5d19396d89 Store: Open With (files the OS hands over) and the icons and builtin catalog kinds
Open With: store_commands keeps each existing file the OS hands over (launch
argument, a second launch forwarded by single-instance with its working
directory, a macOS file:// open-URL) under a random id (Store::receive_file:
name says what it is, regular file, size cap, 8 kept, 15 minutes) and queues
LinkEvent.opened {id, name, kind}. The shell-only install_stage_opened {id}
reads that one file once and stages it exactly like a dropped file; it takes
an id only (testkit asserts that), so the shell cannot name a path. The shell
shows the same install screen; nothing installs without it. The NSIS template
lists the app under "Open with" for the declared extensions (OpenWithProgids)
and never makes it the default program, unlike Tauri's own macro.

Catalog kinds: icons (an icon pack, *.icons.json, at most 120 KB; validate_icons,
staged and committed as text for the shell, the install screen draws every icon,
.icons.json is no longer read as a theme) and builtin (a feature the app
already has, such as an area plugin: no url, sha256, size or file; never staged;
the Store view lists it as Built in with the app's own switch via the areas
prop). Rust: Kind, CatalogItem.check, verify_item, install links refuse builtin.
JS: storeModel, installController (requestOpened, icons adapter), InstallDialog,
StoreView, StoreSection (remove an icon pack, area switches), words. CLI and
JSON Schema: kinds, optional url/sha256/size on builtin, icons size cap; the
test schema validator learns not and if/then/else. docs/STORE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:23:23 +07:00

59 lines
3.9 KiB
JavaScript

// A small JSON Schema (draft 2020-12) validator for the keywords theme.schema.json uses: type,
// required, properties, additionalProperties, enum, const, pattern, min/maxLength, minimum,
// maximum, items, minItems, maxItems, uniqueItems, allOf, oneOf, anyOf, not, if/then/else and local $ref (theme and
// store catalog schemas). Returns a list of error paths.
const typeOf = value => (value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value);
function matchesType(value, type) {
if (type === 'integer') return Number.isInteger(value);
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
return typeOf(value) === type;
}
export function validate(schema, value, root = schema, path = '$') {
if (schema.$ref) {
const target = schema.$ref.replace(/^#\//, '').split('/').reduce((node, key) => node?.[key], root);
if (!target) return [`${path}: unresolved ${schema.$ref}`];
return validate(target, value, root, path);
}
const errors = [];
if (schema.type && ![].concat(schema.type).some(type => matchesType(value, type))) return [`${path}: expected ${schema.type}, got ${typeOf(value)}`];
if ('const' in schema && JSON.stringify(schema.const) !== JSON.stringify(value)) errors.push(`${path}: expected ${JSON.stringify(schema.const)}`);
if (schema.enum && !schema.enum.some(item => JSON.stringify(item) === JSON.stringify(value))) errors.push(`${path}: not one of ${schema.enum.join(', ')}`);
if (typeof value === 'string') {
if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) errors.push(`${path}: does not match ${schema.pattern}`);
if (schema.minLength != null && [...value].length < schema.minLength) errors.push(`${path}: shorter than ${schema.minLength}`);
if (schema.maxLength != null && [...value].length > schema.maxLength) errors.push(`${path}: longer than ${schema.maxLength}`);
}
if (typeof value === 'number') {
if (schema.minimum != null && value < schema.minimum) errors.push(`${path}: below ${schema.minimum}`);
if (schema.maximum != null && value > schema.maximum) errors.push(`${path}: above ${schema.maximum}`);
}
if (Array.isArray(value)) {
if (schema.minItems != null && value.length < schema.minItems) errors.push(`${path}: fewer than ${schema.minItems} items`);
if (schema.maxItems != null && value.length > schema.maxItems) errors.push(`${path}: more than ${schema.maxItems} items`);
if (schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) errors.push(`${path}: repeated items`);
if (schema.items) value.forEach((item, index) => errors.push(...validate(schema.items, item, root, `${path}[${index}]`)));
}
if (typeOf(value) === 'object') {
for (const key of schema.required ?? []) if (!(key in value)) errors.push(`${path}: missing ${key}`);
for (const [key, item] of Object.entries(value)) {
const child = schema.properties?.[key];
if (child) errors.push(...validate(child, item, root, `${path}.${key}`));
else if (schema.additionalProperties === false) errors.push(`${path}: unknown property ${key}`);
}
}
for (const option of schema.allOf ?? []) errors.push(...validate(option, value, root, path));
if (schema.oneOf) {
const passing = schema.oneOf.filter(option => validate(option, value, root, path).length === 0).length;
if (passing !== 1) errors.push(`${path}: matches ${passing} of oneOf (needs exactly 1)`);
}
if (schema.anyOf && !schema.anyOf.some(option => validate(option, value, root, path).length === 0)) errors.push(`${path}: matches none of anyOf`);
if (schema.not && validate(schema.not, value, root, path).length === 0) errors.push(`${path}: matches a schema it must not match`);
if (schema.if) {
const branch = validate(schema.if, value, root, path).length === 0 ? schema.then : schema.else;
if (branch) errors.push(...validate(branch, value, root, path));
}
return errors;
}