diff --git a/browse/build.sh b/browse/build.sh index a4242d2..99aafa5 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -14,9 +14,21 @@ ensure_exists "$src_html" css_temp=$(mktemp) js_raw=$(mktemp) js_temp=$(mktemp) -cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; } +# Generated schema lives under dist/ (gitignored); concat_files resolves paths +# relative to $root_dir, so we pass the relative form. +schema_rel="dist/.zddc-schema.gen.js" +schema_js="$root_dir/$schema_rel" +cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$schema_js"; } trap cleanup EXIT +# Bake the .zddc JSON Schema into the bundle so the lint + completion + hover +# all share ONE grammar (no hand-kept key list to drift from the Go structs) +# AND work offline (file://), where /.api/zddc-schema is unreachable. This is +# the exact file the server serves at that endpoint. +schema_src="$root_dir/../zddc/internal/zddc/zddc.schema.json" +ensure_exists "$schema_src" +{ printf 'window.__ZDDC_SCHEMA__ = '; cat "$schema_src"; printf ';\n'; } > "$schema_js" + # CSS files: shared base first, then browse-specific. Toast UI's CSS # is bundled because the markdown plugin uses Toast UI inside the # preview pane (.md files render as a full editor). @@ -67,6 +79,7 @@ concat_files \ "../shared/icons.js" \ "../shared/zddc-source.js" \ "js/init.js" \ + "$schema_rel" \ "js/util.js" \ "js/yaml-complete.js" \ "js/conflict.js" \ diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index 7d5ebb5..c23faa2 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -25,23 +25,6 @@ var util = window.app.modules.util; var escapeHtml = util.escapeHtml; - // Cached .zddc JSON Schema (the machine grammar) — fetched once from - // /.api/zddc-schema and used to drive completion + hover on .zddc files. - // null = not loaded; {} on fetch failure. The promise dedupes fetches. - var zddcSchema = null; - var zddcSchemaPromise = null; - function loadZddcSchema() { - if (zddcSchema !== null) return; - if (!zddcSchemaPromise) { - zddcSchemaPromise = fetch('/.api/zddc-schema', { - headers: { 'Accept': 'application/schema+json, application/json' }, - credentials: 'same-origin' - }).then(function (r) { return r.ok ? r.json() : null; }) - .then(function (j) { zddcSchema = j || {}; }) - .catch(function () { zddcSchema = {}; }); - } - } - // ── Filename routing ──────────────────────────────────────────────────── // True for .zddc cascade files — `.zddc` (literal name, no ext) @@ -107,49 +90,10 @@ // any level surface as warnings — typos like `defaul_tool` are // common and the cascade silently ignores them. - var ALLOWED_TOOLS = { - archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1, - tables: 1, form: 1 - }; - - var TOP_KEYS = { - title: 'string', - acl: 'acl', - admins: 'string[]', - roles: 'rolemap', - available_tools: 'tools[]', - default_tool: 'tool', - dir_tool: 'tool', - auto_own: 'bool', - auto_own_fenced: 'bool', - virtual: 'bool', - drop_target: 'bool', - worm: 'string[]', - paths: 'pathmap', - display: 'stringmap', - tables: 'stringmap', - views: 'viewmap', - convert: 'convert', - created_by: 'string', - inherit: 'bool', - // Keys the Go decoder (zddc/internal/zddc/file.go) accepts that the - // lint was missing — flagged valid configs as "unknown key". - party_source: 'string', - history: 'bool', - history_globs: 'string[]', - records: 'object', - auto_own_roles: 'string[]', - received_path: 'string', - planned_response_date: 'string', - planned_review_date: 'string', - field_codes: 'object' - }; - - var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap', - allow: 'string[]', deny: 'string[]' }; - var ROLE_KEYS = { members: 'string[]', reset: 'bool' }; - var CONVERT_KEYS = { client: 'string', project: 'string', - contractor: 'string', project_number: 'string' }; + // The valid keys, types, enums and nesting are NOT hand-listed here any + // more — they come from the baked .zddc JSON Schema (window.__ZDDC_SCHEMA__, + // the same grammar the server serves at /.api/zddc-schema and that drives + // completion + hover). One source, no drift. See validateZddcSchema below. function typeOf(v) { if (v === null || v === undefined) return 'null'; @@ -157,168 +101,87 @@ return typeof v; // 'string' | 'number' | 'boolean' | 'object' } - // Collect schema issues for a parsed .zddc document. Each issue is - // { keyPath: string[], message: string, severity: 'error' | 'warning' }. - // keyPath is used by findLine() to locate the offending source line. + // The .zddc JSON Schema, baked into the bundle at build time + // (window.__ZDDC_SCHEMA__ — the same file the server serves at + // /.api/zddc-schema). Single source for lint, completion and hover; works + // offline. Synchronous, so the lint helper can use it directly. + function getZddcSchema() { + return (window.__ZDDC_SCHEMA__ && window.__ZDDC_SCHEMA__.properties) + ? window.__ZDDC_SCHEMA__ : {}; + } + + // Validate a parsed .zddc document against the JSON Schema, producing + // { keyPath, severity, message } issues (mapped to source lines by + // findLine). Covers the draft-2020-12 subset .zddc uses: type, enum, + // properties, additionalProperties (false | schema), patternProperties, + // items, pattern, and the recursive $ref:"#" (paths:). function validateZddc(doc) { + var schema = getZddcSchema(); var issues = []; + if (!schema || !schema.properties) return issues; // schema unavailable if (typeOf(doc) === 'null') return issues; if (typeOf(doc) !== 'object') { issues.push({ keyPath: [], severity: 'error', message: 'Root must be a map (got ' + typeOf(doc) + ').' }); return issues; } - walkObject(doc, TOP_KEYS, [], issues); - return issues; - } - - function walkObject(obj, schema, path, issues) { - for (var key in obj) { - if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; - var here = path.concat([key]); - var kind = schema[key]; - if (!kind) { - issues.push({ keyPath: here, severity: 'warning', - message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' }); - continue; + function deref(n) { return (n && n.$ref === '#') ? schema : n; } + function typeOk(t, want) { + if (Array.isArray(want)) { + for (var i = 0; i < want.length; i++) if (typeOk(t, want[i])) return true; + return false; } - checkValue(obj[key], kind, here, issues); + if (want === 'integer' || want === 'number') return t === 'number'; + return t === want; } - } - - function checkValue(val, kind, path, issues) { - var t = typeOf(val); - switch (kind) { - case 'string': - if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues); + function walk(value, sch, path) { + sch = deref(sch); + if (!sch) return; + var t = typeOf(value); + if (t === 'null') return; // empty value mid-edit — don't flag + if (sch.type && !typeOk(t, sch.type)) { + issues.push({ keyPath: path, severity: 'error', + message: 'Expected ' + (Array.isArray(sch.type) ? sch.type.join('/') : sch.type) + + ', got ' + t + '.' }); return; - case 'bool': - if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues); - return; - case 'string[]': - if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues); - return; - case 'tools[]': - if (t !== 'array' && t !== 'null') { - addTypeErr(path, kind, t, issues); return; - } - if (t === 'array') { - for (var i = 0; i < val.length; i++) { - if (typeOf(val[i]) !== 'string') { - issues.push({ keyPath: path, severity: 'error', - message: 'available_tools[' + i + '] must be a string.' }); - } else if (!ALLOWED_TOOLS[val[i]]) { - issues.push({ keyPath: path, severity: 'warning', - message: 'Unknown tool "' + val[i] - + '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' }); + } + if (sch.enum && sch.enum.map(String).indexOf(String(value)) === -1) { + issues.push({ keyPath: path, severity: 'warning', + message: 'Unknown value "' + value + '". Allowed: ' + sch.enum.join(', ') + '.' }); + } + if (sch.pattern && t === 'string' && !new RegExp(sch.pattern).test(value)) { + issues.push({ keyPath: path, severity: 'error', + message: 'Value "' + value + '" must match ' + sch.pattern + '.' }); + } + if (t === 'object') { + var props = sch.properties || {}; + for (var k in value) { + if (!Object.prototype.hasOwnProperty.call(value, k)) continue; + var kp = path.concat([k]); + if (props[k]) { walk(value[k], props[k], kp); continue; } + var ap = sch.additionalProperties; + if (ap && typeof ap === 'object') { walk(value[k], ap, kp); continue; } + if (sch.patternProperties) { + var matched = null; + for (var p in sch.patternProperties) { + if (Object.prototype.hasOwnProperty.call(sch.patternProperties, p) + && new RegExp(p).test(k)) { matched = sch.patternProperties[p]; break; } } + if (matched) { walk(value[k], matched, kp); continue; } + } + if (ap === false) { + issues.push({ keyPath: kp, severity: 'warning', + message: 'Unknown key "' + k + '" — not in the .zddc schema; it will be ignored.' }); } } - return; - case 'tool': - if (t === 'null') return; - if (t !== 'string') { addTypeErr(path, kind, t, issues); return; } - if (!ALLOWED_TOOLS[val]) { - issues.push({ keyPath: path, severity: 'warning', - message: 'Unknown tool "' + val + '". Known: ' - + Object.keys(ALLOWED_TOOLS).join(', ') + '.' }); + } else if (t === 'array' && sch.items) { + for (var i = 0; i < value.length; i++) { + walk(value[i], sch.items, path.concat([String(i)])); } - return; - case 'stringmap': - if (t === 'null') return; - if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } - for (var k in val) { - if (!Object.prototype.hasOwnProperty.call(val, k)) continue; - if (typeOf(val[k]) !== 'string') { - issues.push({ keyPath: path.concat([k]), severity: 'error', - message: 'Value must be a string (got ' - + typeOf(val[k]) + ').' }); - } - } - return; - case 'pathmap': - if (t === 'null') return; - if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } - for (var seg in val) { - if (!Object.prototype.hasOwnProperty.call(val, seg)) continue; - if (seg.indexOf('/') !== -1) { - issues.push({ keyPath: path.concat([seg]), severity: 'error', - message: 'Path keys must be a single segment — ' - + 'nest blocks instead of using "' + seg + '".' }); - } - var v = val[seg]; - if (typeOf(v) === 'null') continue; - if (typeOf(v) !== 'object') { - issues.push({ keyPath: path.concat([seg]), severity: 'error', - message: 'paths.' + seg + ' must be a map of cascade rules.' }); - continue; - } - walkObject(v, TOP_KEYS, path.concat([seg]), issues); - } - return; - case 'viewmap': - if (t === 'null') return; - if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } - for (var shape in val) { - if (!Object.prototype.hasOwnProperty.call(val, shape)) continue; - if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) { - issues.push({ keyPath: path.concat([shape]), severity: 'warning', - message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' }); - } - var vv = val[shape]; - if (typeOf(vv) !== 'object') { - issues.push({ keyPath: path.concat([shape]), severity: 'error', - message: 'views.' + shape + ' must be a map ({tool, config}).' }); - continue; - } - if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) { - issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning', - message: 'views.' + shape + '.tool should be a known tool (' - + Object.keys(ALLOWED_TOOLS).join(', ') + ').' }); - } - if (vv.config !== undefined && typeOf(vv.config) !== 'string') { - issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error', - message: 'views.' + shape + '.config must be a filename string.' }); - } - } - return; - case 'rolemap': - if (t === 'null') return; - if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } - for (var rn in val) { - if (!Object.prototype.hasOwnProperty.call(val, rn)) continue; - var rv = val[rn]; - if (typeOf(rv) === 'null') continue; - if (typeOf(rv) !== 'object') { - issues.push({ keyPath: path.concat([rn]), severity: 'error', - message: 'roles.' + rn + ' must be a map ({members, reset}).' }); - continue; - } - walkObject(rv, ROLE_KEYS, path.concat([rn]), issues); - } - return; - case 'acl': - if (t === 'null') return; - if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } - walkObject(val, ACL_KEYS, path, issues); - return; - case 'convert': - if (t === 'null') return; - if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } - walkObject(val, CONVERT_KEYS, path, issues); - return; - case 'object': - // Free-form map (records, field_codes) — the server accepts any - // nested shape, so we only check it's a mapping, not its keys. - if (t === 'null') return; - if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } - return; + } } - } - - function addTypeErr(path, expected, got, issues) { - issues.push({ keyPath: path, severity: 'error', - message: 'Expected ' + expected + ', got ' + got + '.' }); + walk(doc, schema, []); + return issues; } // Locate the source line for a key path. .zddc files are @@ -557,9 +420,7 @@ // Plain .yaml gets no schema (lint + highlighting only). var yc = window.app.modules.yamlComplete; if (yc && isZddcFile(node.name)) { - loadZddcSchema(); - yc.attach(editor, yc.schemaProvider(function () { return zddcSchema || {}; }), - { readOnly: !writable }); + yc.attach(editor, yc.schemaProvider(getZddcSchema), { readOnly: !writable }); } currentEditor = editor; currentNodeRef = node;