waybetter/packages/create-waybetter/tests/jsonSchema.mjs
CRT b972524888 create-waybetter: npm create waybetter scaffolds plugins (JS/TS), themes and whole apps
- plugin: *.plugin.js with the store header and start/stop on api.scope, storage, observe,
  onKey, the UI kit (toast with action, ui.h settings dialog) and api.theme/--wb-* vars;
  --ts makes a TypeScript project typed by the waybetter package (build keeps the header first)
- theme: JSON with "$schema" -> theme.schema.json, palette derived like themeFromColors,
  font roles (family, tweet), presets
- app: Tauri 2 + waybetter-host + runtime + shell models: SiteSpec, SiteAdapter, shell/tab-chrome/
  site-tab capabilities, tabs_sync, plugin store, a site-only command; presets for youtube.com,
  kick.com, twitch.tv with placeholder selectors; host crate via submodule, path or git
- tests generate into temp dirs: headers via parse_meta (new plugin_meta example), plugin code
  against createCorePluginApi's members, themes against the schema, app TS with tsc against the
  built package, app Rust with cargo metadata/check/test
- wired into bun run check (typecheck incl. checkJs, build:packages, test:create) and pack:check
2026-09-28 23:17:32 +07:00

50 lines
3.1 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, maxItems, oneOf, anyOf and local $ref. 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.maxItems != null && value.length > schema.maxItems) errors.push(`${path}: more than ${schema.maxItems} 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}`);
}
}
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`);
return errors;
}