// preview-yaml.js — YAML editor plugin for the browse preview pane. // // Routes any .yaml / .yml file, plus the .zddc cascade files // (`.zddc` and `*.zddc.yaml`), through a CodeMirror 5 editor with // syntax highlighting and live linting. js-yaml.loadAll feeds parse // errors into CM's lint gutter; for .zddc files an additional // schema-aware pass flags unknown keys, bad enum values, and wrong // types. // // Layout (single column): // ┌─────────────────────────────────────────────────────────────┐ // │ name | dirty | status | source | [Save] │ // ├─────────────────────────────────────────────────────────────┤ // │ CodeMirror editor (line numbers + lint gutter) │ // └─────────────────────────────────────────────────────────────┘ // // Save (Ctrl+S) writes back via PUT (server mode) or // FileSystemWritableFileStream (FS-API). Zip members and // virtual nodes are read-only — Save stays disabled. (function () { 'use strict'; if (!window.app || !window.app.modules) return; function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ── Filename routing ──────────────────────────────────────────────────── // True for .zddc cascade files — `.zddc` (literal name, no ext) // and `.zddc.yaml` (e.g. `defaults.zddc.yaml`). These // get the schema-aware lint layer. function isZddcFile(name) { if (!name) return false; if (name === '.zddc') return true; return /\.zddc\.ya?ml$/i.test(name); } function isYamlFile(node) { if (!node || !node.name) return false; if (isZddcFile(node.name)) return true; var ext = (node.ext || '').toLowerCase(); return ext === 'yaml' || ext === 'yml'; } // ── Save (mirrors preview-markdown.js) ───────────────────────────────── async function saveContent(node, content) { if (node.handle && typeof node.handle.createWritable === 'function') { var writable = await node.handle.createWritable(); await writable.write(content); await writable.close(); return; } if (node.url && window.app.state.source === 'server') { var resp = await fetch(node.url, { method: 'PUT', headers: { 'Content-Type': 'application/x-yaml; charset=utf-8' }, body: content, credentials: 'same-origin' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); return; } throw new Error('No write target for this file (read-only source).'); } function isZipMemberNode(node) { if (node.handle && node.handle.isZipEntry) return true; if (node.url && window.app.state.source === 'server' && /\.zip\//i.test(node.url)) return true; return false; } function canSave(node) { if (isZipMemberNode(node)) return false; if (node.virtual) return false; if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.url && window.app.state.source === 'server') return true; return false; } async function hashContent(text) { if (!window.crypto || !window.crypto.subtle) return null; var enc = new TextEncoder().encode(text); var buf = await window.crypto.subtle.digest('SHA-256', enc); var bytes = new Uint8Array(buf); var hex = ''; for (var i = 0; i < bytes.length; i++) { hex += bytes[i].toString(16).padStart(2, '0'); } return hex; } // ── .zddc schema ──────────────────────────────────────────────────────── // // Mirrors the Go-side decoder in zddc/internal/zddc/*. Allowed // tool names are the embedded set (always available) plus the // composable ones served when declared in apps:. Unknown keys at // 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', apps: 'appsmap', apps_pubkey: 'string', tables: 'stringmap', convert: 'convert', created_by: 'string', inherit: 'bool' }; 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' }; function typeOf(v) { if (v === null || v === undefined) return 'null'; if (Array.isArray(v)) return 'array'; 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. function validateZddc(doc) { var issues = []; 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; } 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 'appsmap': if (t === 'null') return; if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } for (var app in val) { if (!Object.prototype.hasOwnProperty.call(val, app)) continue; if (!ALLOWED_TOOLS[app]) { issues.push({ keyPath: path.concat([app]), severity: 'warning', message: 'Unknown tool "' + app + '" in apps:.' }); } if (typeOf(val[app]) !== 'string') { issues.push({ keyPath: path.concat([app]), severity: 'error', message: 'apps.' + app + ' must be a spec string ' + '(channel | v | URL | path).' }); } } 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; } } 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 // matching ":" whose indent is deeper than the // previously-matched line. Falls back to line 0 if no match. function findLine(source, keyPath) { if (!keyPath || keyPath.length === 0) return 0; var lines = source.split('\n'); var prevIndent = -1; var prevLine = 0; for (var i = 0; i < keyPath.length; i++) { var key = keyPath[i]; var found = -1; // Escape regex metachars in the key. var keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); var re = new RegExp('^(\\s*)"?' + keyRe + '"?\\s*:'); for (var j = prevLine; j < lines.length; j++) { var m = lines[j].match(re); if (m && m[1].length > prevIndent) { found = j; prevIndent = m[1].length; prevLine = j + 1; break; } } if (found === -1) return prevLine > 0 ? prevLine - 1 : 0; } return prevLine > 0 ? prevLine - 1 : 0; } // ── CodeMirror lint helper ────────────────────────────────────────────── function registerLinter(CM) { // The lint helper signature: function(text, options, editor) → annotations[] // Each annotation: { from, to, message, severity }. CM.registerHelper('lint', 'yaml', function (text, _opts, editor) { var out = []; if (!window.jsyaml) return out; var parsed; try { // loadAll handles multi-doc YAML; we only validate the // first doc against the schema (the .zddc cascade reads // only the first document). var docs = []; window.jsyaml.loadAll(text, function (d) { docs.push(d); }); parsed = docs[0]; } catch (e) { var mark = e.mark; var pos = mark ? CM.Pos(mark.line, mark.column) : CM.Pos(0, 0); out.push({ from: pos, to: pos, severity: 'error', message: e.message || String(e) }); return out; } // Schema layer — only for .zddc cascade files. var node = editor._zddcNode; if (node && isZddcFile(node.name)) { var issues = validateZddc(parsed); for (var i = 0; i < issues.length; i++) { var ln = findLine(text, issues[i].keyPath); out.push({ from: CM.Pos(ln, 0), to: CM.Pos(ln, (editor.getLine(ln) || '').length), severity: issues[i].severity, message: issues[i].message }); } } return out; }); } // ── Mount ─────────────────────────────────────────────────────────────── var currentEditor = null; function dispose() { // CM doesn't have an explicit destroy(); GC handles it once // the host element is removed. Clear our reference so a stale // editor doesn't keep handlers alive. currentEditor = null; } async function render(node, container, ctx) { if (typeof window.CodeMirror === 'undefined') { container.innerHTML = '
' + 'CodeMirror isn\'t bundled in this build.
'; return; } dispose(); var text; try { var buf = await ctx.getArrayBuffer(node); text = new TextDecoder('utf-8', { fatal: false }).decode(buf); } catch (e) { container.innerHTML = '
' + 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '
'; return; } container.innerHTML = ''; var shell = document.createElement('div'); shell.className = 'yaml-shell'; container.appendChild(shell); // Info header — same look as the markdown plugin's info-header // so the two editors feel like one family. var infohdr = document.createElement('div'); infohdr.className = 'md-shell__infohdr yaml-shell__infohdr'; var titleEl = document.createElement('span'); titleEl.className = 'md-shell__title'; titleEl.textContent = node.name; titleEl.title = node.name; var schemaTag = document.createElement('span'); schemaTag.className = 'md-shell__source yaml-shell__schema'; if (isZddcFile(node.name)) { schemaTag.textContent = '.zddc schema'; schemaTag.title = 'Linted against the .zddc cascade schema ' + '(unknown keys, bad enums, and wrong types are flagged).'; } else { schemaTag.textContent = 'YAML'; } var dirtyEl = document.createElement('span'); dirtyEl.className = 'md-shell__dirty'; var statusEl = document.createElement('span'); statusEl.className = 'md-shell__status'; var sourceEl = document.createElement('span'); sourceEl.className = 'md-shell__source'; if (isZipMemberNode(node)) sourceEl.textContent = 'read-only (zip)'; else if (node.handle) sourceEl.textContent = 'local'; else if (node.url) sourceEl.textContent = 'server'; var saveBtn = document.createElement('button'); saveBtn.className = 'btn btn-sm btn-primary md-shell__save'; saveBtn.type = 'button'; saveBtn.textContent = 'Save'; saveBtn.disabled = true; infohdr.appendChild(titleEl); infohdr.appendChild(schemaTag); infohdr.appendChild(dirtyEl); infohdr.appendChild(statusEl); infohdr.appendChild(sourceEl); infohdr.appendChild(saveBtn); shell.appendChild(infohdr); var editorHost = document.createElement('div'); editorHost.className = 'yaml-shell__editor'; shell.appendChild(editorHost); // Register the lint helper once per page lifetime. if (!window.CodeMirror.__zddcYamlLinterReady) { registerLinter(window.CodeMirror); window.CodeMirror.__zddcYamlLinterReady = true; } var editor = window.CodeMirror(editorHost, { value: text, mode: 'yaml', lineNumbers: true, tabSize: 2, indentUnit: 2, indentWithTabs: false, lineWrapping: false, gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'], lint: { hasGutters: true } }); // Stash the node on the editor so the lint helper can decide // whether to apply the .zddc schema layer. editor._zddcNode = node; // Force an initial lint pass now that _zddcNode is set. editor.performLint(); currentEditor = editor; var writable = canSave(node); if (!writable) { saveBtn.disabled = true; saveBtn.title = 'Save not available — read-only source.'; editor.setOption('readOnly', true); } var initialHash = await hashContent(text); function markDirty(isDirty) { saveBtn.disabled = !isDirty || !writable; dirtyEl.textContent = isDirty ? '● modified' : ''; } editor.on('change', async function () { var h = await hashContent(editor.getValue()); markDirty(h !== initialHash); }); async function save() { if (saveBtn.disabled) return; var content = editor.getValue(); try { statusEl.textContent = 'Saving…'; await saveContent(node, content); initialHash = await hashContent(content); markDirty(false); statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); if (window.zddc && window.zddc.toast) { window.zddc.toast('Saved ' + node.name, 'success'); } } catch (e) { statusEl.textContent = 'Save failed: ' + (e.message || e); if (window.zddc && window.zddc.toast) { window.zddc.toast('Save failed: ' + (e.message || e), 'error'); } } } saveBtn.addEventListener('click', save); editor.setOption('extraKeys', { 'Ctrl-S': save, 'Cmd-S': save }); // CM defers layout until its host has a size — refresh after // mount so the gutters and viewport sync to the grid cell. setTimeout(function () { try { editor.refresh(); } catch (_e) {} }, 0); } function handles(node) { if (!node || node.isDir || node.isZip) return false; return isYamlFile(node); } window.app.modules.yamledit = { handles: handles, render: render }; })();