// 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'; } // The CodeMirror editor is the general editor for editable TEXT files that // aren't markdown (markdown has its own editor). Syntax highlighting is // YAML-only — that's the one CM mode in the vendored bundle — so every // other type opens as a plaintext editor (still line numbers, find, // selection, save). svg/json-as-image etc. stay with their preview // renderers; this set is deliberately the "edit the source" types. var CODE_EXTS = { yaml: 1, yml: 1, txt: 1, text: 1, csv: 1, tsv: 1, tab: 1, json: 1, xml: 1, html: 1, htm: 1, css: 1, js: 1, mjs: 1, log: 1, ini: 1, conf: 1, cfg: 1, toml: 1, env: 1, sh: 1, bash: 1, properties: 1 }; function isCodeFile(node) { if (!node || node.isDir || node.isZip) return false; if (isYamlFile(node)) return true; return !!CODE_EXTS[(node.ext || '').toLowerCase()]; } // CodeMirror mode by extension — only yaml is vendored; others plaintext. function codeMode(node) { return isYamlFile(node) ? 'yaml' : null; } // ── 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. // 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'; if (Array.isArray(v)) return 'array'; return typeof v; // 'string' | 'number' | 'boolean' | 'object' } // 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; } 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; } // 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). ' + 'Click to view the full JSON Schema.'; // Clickable → opens the canonical machine grammar the lint mirrors. schemaTag.classList.add('yaml-shell__schema--link'); schemaTag.setAttribute('role', 'link'); schemaTag.setAttribute('tabindex', '0'); var openSchema = function () { window.open('/.api/zddc-schema', '_blank', 'noopener'); }; schemaTag.addEventListener('click', openSchema); schemaTag.addEventListener('keydown', function (ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); } }); } else if (isYamlFile(node)) { schemaTag.textContent = 'YAML'; } else { schemaTag.textContent = (node.ext || 'text').toUpperCase(); } 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 mode = codeMode(node); // Lint (js-yaml + the .zddc schema) only applies to YAML; other text // types are plaintext, so skip the lint gutter for them. var yamlMode = mode === 'yaml'; var editor = window.CodeMirror(editorHost, { value: text, mode: mode, lineNumbers: true, tabSize: 2, indentUnit: 2, indentWithTabs: false, lineWrapping: false, gutters: yamlMode ? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'] : ['CodeMirror-linenumbers'], lint: yamlMode ? { hasGutters: true } : false, // 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 (YAML only). if (yamlMode) editor.performLint(); // Schema completion + hover docs for .zddc files (the machine grammar // drives keys, enum/boolean values, and nested paths via $ref:"#"). // Plain .yaml gets no schema (lint + highlighting only). var yc = window.app.modules.yamlComplete; if (yc && isZddcFile(node.name)) { yc.attach(editor, yc.schemaProvider(getZddcSchema), { readOnly: !writable }); } 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) { return isCodeFile(node); } window.app.modules.yamledit = { handles: handles, render: render, dispose: dispose, isDirty: isDirty, currentNode: currentNode }; })();