feat(browse): unify the .zddc lint onto the JSON schema (baked, single source)
The .zddc lint used a hand-kept TOP_KEYS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS list
with bespoke type tags — a parallel grammar that drifted from the Go structs
(we'd already had to patch in party_source/history/etc.). Replace it with one
schema-driven validator over the same JSON Schema that now drives completion +
hover, so lint/completion/hover/form all share ONE grammar.
- Bake zddc.schema.json into the bundle at build time (window.__ZDDC_SCHEMA__),
the exact file the server serves at /.api/zddc-schema. Synchronous + works
offline (file://), where that endpoint is unreachable — drops the runtime
fetch entirely.
- validateZddc() now walks the parsed doc against the schema: type, enum,
pattern, properties, additionalProperties (false|schema), patternProperties,
items, and the recursive $ref:"#" (paths:). Same {keyPath,severity,message}
shape, so findLine + the CM lint helper are unchanged.
- Delete TOP_KEYS/ALLOWED_TOOLS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS + walkObject/
checkValue/addTypeErr. preview-yaml completion now reads getZddcSchema too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a13ce12a75
commit
d44e1b01bf
2 changed files with 84 additions and 210 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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,170 +101,89 @@
|
|||
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);
|
||||
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;
|
||||
}
|
||||
if (want === 'integer' || want === 'number') return t === 'number';
|
||||
return t === want;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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.' });
|
||||
}
|
||||
}
|
||||
} else if (t === 'array' && sch.items) {
|
||||
for (var i = 0; i < value.length; i++) {
|
||||
walk(value[i], sch.items, path.concat([String(i)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc, schema, []);
|
||||
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;
|
||||
}
|
||||
checkValue(obj[key], kind, here, issues);
|
||||
}
|
||||
}
|
||||
|
||||
function checkValue(val, kind, path, issues) {
|
||||
var t = typeOf(val);
|
||||
switch (kind) {
|
||||
case 'string':
|
||||
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||
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(', ') + '.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
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(', ') + '.' });
|
||||
}
|
||||
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 + '.' });
|
||||
}
|
||||
|
||||
// Locate the source line for a key path. .zddc files are
|
||||
// block-style YAML in practice (no flow style, no anchors), so a
|
||||
// simple indent-aware scan works: for each segment, find a line
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue