// 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; var util = window.app.modules.util; var escapeHtml = util.escapeHtml; // ── 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) ───────────────────────────────── function saveContent(node, content, opts) { // Via the shared saveFile so local (FS-Access) saves escalate to // readwrite the same as the markdown editor — previously this path // skipped ensureWritable and failed on read-only-picked folders. return util.saveFile(node, content, 'application/x-yaml; charset=utf-8', opts); } var isZipMemberNode = util.isZipMemberNode; var isEditableZipMember = util.isEditableZipMember; function canSave(node) { // A .zddc.zip bundle member is saveable iff editable (elevated admin); // the server's ServeZipWrite is the real gate. Other zip members are // read-only. if (isZipMemberNode(node)) return isEditableZipMember(node); // Virtual .zddc placeholders are designed to be saved — a PUT // materializes the file from the synthetic body and the next // listing serves a real entry. Every other virtual node (per- // user home, canonical-folder virtuals) is just a tree // affordance, not a writable file. if (node.virtual && node.name !== '.zddc') return false; // Server-computed authority gate. The virtual .zddc entry // requires the admin verb 'a' (matches fileapi.go's // ActionAdmin gate at the .zddc URL); regular YAML files // require write 'w'. cap.has falls back to node.writable for // 'w' when verbs is absent (offline FS-API listings). if (node.url && window.app.state.source === 'server' && window.zddc.cap) { var needed = node.name === '.zddc' ? 'a' : 'w'; if (!window.zddc.cap.has(node, needed)) return false; } if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.url && window.app.state.source === 'server') return true; return false; } var hashContent = util.hashContent; // ── .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', tables: 'stringmap', views: 'viewmap', 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 '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; } } 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; var currentDirty = false; var currentNodeRef = null; // Server version token for the loaded file — sent as If-Match on save // and refreshed from each successful PUT's response ETag. var currentEtag = null; var currentLastModified = 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; currentDirty = false; currentNodeRef = null; currentEtag = null; currentLastModified = null; } function isDirty() { return currentDirty; } function currentNode() { return currentNodeRef; } async function render(node, container, ctx) { if (typeof window.CodeMirror === 'undefined') { container.innerHTML = '
' + 'CodeMirror isn\'t bundled in this build.
'; return; } dispose(); var text, loadedEtag = null, loadedLastModified = null; try { if (ctx.getContentWithVersion) { var loaded = await ctx.getContentWithVersion(node); text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf); loadedEtag = loaded.etag; loadedLastModified = loaded.lastModified; } else { 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 = isEditableZipMember(node) ? 'config bundle' : '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 writable = canSave(node); 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 }, // autofocus:false keeps the keyboard caret in the browse // tree pane so arrow-key nav can continue through yaml / // .zddc files without diverting into the editor. User // clicks (or tabs) into the editor when they want to type. autofocus: false, // Read-only uses readOnly:true (NOT "nocursor"): the editor // stays focusable so the user can click in, select text, and // copy — they just can't edit. "nocursor" removes the textarea // from focus, which also kills click-drag selection (the whole // reason a viewer would otherwise force admin mode just to copy // a .zddc snippet). autofocus:false keeps arrow-key tree nav // intact until the user deliberately clicks into the editor. readOnly: !writable, }); // 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; currentNodeRef = node; currentDirty = false; currentEtag = loadedEtag; currentLastModified = loadedLastModified; if (!writable) { saveBtn.disabled = true; saveBtn.title = 'Save not available — read-only source.'; // Read-only banner above the editor explains why. var roBanner = document.createElement('div'); roBanner.className = 'yaml-readonly-banner'; roBanner.innerHTML = '' + ' Read-only — you don\'t have write access to this file.'; editorHost.insertBefore(roBanner, editorHost.firstChild); } var initialHash = await hashContent(text); function markDirty(isDirty) { if (currentEditor !== editor) return; // editor replaced currentDirty = isDirty; saveBtn.disabled = !isDirty || !canSave(node); dirtyEl.textContent = isDirty ? '● modified' : ''; } editor.on('change', async function () { if (currentEditor !== editor) return; // switched away var h = await hashContent(editor.getValue()); if (currentEditor !== editor) return; // replaced during await markDirty(h !== initialHash); }); // Adopt the new server ETag + refresh the dirty baseline after a // successful write so save→edit→save doesn't false-conflict. async function markSaved(content, res) { if (currentEditor !== editor) return; if (res && res.etag) currentEtag = res.etag; initialHash = await hashContent(content); if (currentEditor !== editor) return; markDirty(false); statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); if (window.zddc && window.zddc.toast) { window.zddc.toast('Saved ' + node.name, 'success'); } } // 412 → file changed on the server since load. Open the shared // conflict dialog instead of clobbering. async function resolveConflict(content) { var conflict = window.app.modules.conflict; var prev = window.app.modules.preview; if (!conflict || !prev) return; await conflict.open({ filename: node.name, mineText: content, fetchTheirs: function () { return prev.getContentWithVersion(node).then(function (r) { return new TextDecoder('utf-8', { fatal: false }).decode(r.buf); }); }, onOverwrite: function () { return prev.getContentWithVersion(node).then(function (cur) { return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified }); }).then(function (res) { return markSaved(content, res); }); }, onReload: function () { markDirty(false); currentDirty = false; return prev.showFilePreview(node); }, onSaveCopy: function () { return util.saveCopy(node, content, 'application/x-yaml; charset=utf-8') .then(function (name) { if (window.zddc && window.zddc.toast) { window.zddc.toast('Saved your version as ' + name, 'success'); } }); } }); if (currentEditor === editor) statusEl.textContent = ''; } async function save() { if (saveBtn.disabled) return; // Re-check authority at click time, not via the mount-time // `writable` capture — the listing may have re-evaluated // (e.g. user toggled admin mode without a hard reload). if (!canSave(node)) return; var content = editor.getValue(); try { statusEl.textContent = 'Saving…'; var res = await saveContent(node, content, { etag: currentEtag, lastModified: currentLastModified }); await markSaved(content, res); } catch (e) { if (e && e.status === 412) { if (currentEditor !== editor) return; statusEl.textContent = 'Conflict — resolving…'; await resolveConflict(content); return; } 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, dispose: dispose, isDirty: isDirty, currentNode: currentNode }; })();