Compare commits

..

No commits in common. "0d052a20c301de460d6750f777ccdfc76f2f3642" and "242d25d55acb2611ff531eb799023f30a675850e" have entirely different histories.

22 changed files with 1302 additions and 2385 deletions

View file

@ -14,21 +14,9 @@ ensure_exists "$src_html"
css_temp=$(mktemp) css_temp=$(mktemp)
js_raw=$(mktemp) js_raw=$(mktemp)
js_temp=$(mktemp) js_temp=$(mktemp)
# Generated schema lives under dist/ (gitignored); concat_files resolves paths cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
# 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 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 # CSS files: shared base first, then browse-specific. Toast UI's CSS
# is bundled because the markdown plugin uses Toast UI inside the # is bundled because the markdown plugin uses Toast UI inside the
# preview pane (.md files render as a full editor). # preview pane (.md files render as a full editor).
@ -39,7 +27,6 @@ concat_files \
"../shared/logo.css" \ "../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \ "../shared/vendor/toastui-editor.min.css" \
"../shared/vendor/codemirror-yaml.min.css" \ "../shared/vendor/codemirror-yaml.min.css" \
"../shared/vendor/codemirror-show-hint.min.css" \
"../shared/context-menu.css" \ "../shared/context-menu.css" \
"../shared/elevation.css" \ "../shared/elevation.css" \
"../shared/profile-menu.css" \ "../shared/profile-menu.css" \
@ -47,7 +34,6 @@ concat_files \
"css/tree.css" \ "css/tree.css" \
"css/preview-yaml.css" \ "css/preview-yaml.css" \
"css/history.css" \ "css/history.css" \
"css/manage-access.css" \
> "$css_temp" > "$css_temp"
# JS files: shared canonical helpers, then browse modules. # JS files: shared canonical helpers, then browse modules.
@ -62,7 +48,6 @@ concat_files \
"../shared/vendor/utif.min.js" \ "../shared/vendor/utif.min.js" \
"../shared/vendor/js-yaml.min.js" \ "../shared/vendor/js-yaml.min.js" \
"../shared/vendor/codemirror-yaml.min.js" \ "../shared/vendor/codemirror-yaml.min.js" \
"../shared/vendor/codemirror-show-hint.min.js" \
"../shared/vendor/toastui-editor-all.min.js" \ "../shared/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
@ -80,10 +65,7 @@ concat_files \
"../shared/icons.js" \ "../shared/icons.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"js/init.js" \ "js/init.js" \
"$schema_rel" \
"js/util.js" \ "js/util.js" \
"js/yaml-complete.js" \
"js/manage-access.js" \
"js/conflict.js" \ "js/conflict.js" \
"js/menu-model.js" \ "js/menu-model.js" \
"js/loader.js" \ "js/loader.js" \
@ -91,6 +73,7 @@ concat_files \
"js/preview.js" \ "js/preview.js" \
"js/preview-markdown.js" \ "js/preview-markdown.js" \
"js/preview-yaml.js" \ "js/preview-yaml.js" \
"js/preview-zddc-form.js" \
"js/hovercard.js" \ "js/hovercard.js" \
"js/grid.js" \ "js/grid.js" \
"js/upload.js" \ "js/upload.js" \

View file

@ -1,90 +0,0 @@
/* manage-access.js — guided "who can do what here" dialog. */
.ma-overlay {
position: fixed;
inset: 0;
z-index: 9800;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
}
.ma-box {
background: var(--bg-elevated, var(--bg, #fff));
color: var(--text, #222);
border: 1px solid var(--border, #ccc);
border-radius: 8px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32);
padding: 1.1rem 1.25rem;
width: min(34rem, 94vw);
max-height: 90vh;
overflow: auto;
}
.ma-title { margin: 0 0 0.2rem; font-size: 1.15rem; }
.ma-sub {
margin: 0 0 0.8rem;
font-size: 0.82rem;
color: var(--text-muted, #777);
word-break: break-all;
}
.ma-list { display: flex; flex-direction: column; gap: 0.4rem; }
/* who fills the row and shrinks (min-width:0); level + delete size to content
so nothing overflows the dialog regardless of email/principal length. */
.ma-row {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content max-content;
gap: 0.5rem;
align-items: center;
}
.ma-who,
.ma-level {
box-sizing: border-box;
padding: 0.4rem 0.5rem;
font: inherit;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
background: var(--bg, #fff);
color: var(--text, #222);
}
.ma-who { width: 100%; min-width: 0; }
.ma-level { width: 8.5rem; cursor: pointer; }
.ma-legend {
margin: 0.5rem 0 0;
font-size: 0.74rem;
color: var(--text-muted, #888);
}
.ma-del {
border: none;
background: transparent;
color: var(--text-muted, #999);
cursor: pointer;
font-size: 1rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
.ma-del:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.06)); color: var(--danger, #c14242); }
.ma-add {
margin: 0.6rem 0 0;
border: 1px dashed var(--border, #bbb);
background: transparent;
color: var(--primary, #2868c8);
cursor: pointer;
padding: 0.35rem 0.6rem;
border-radius: 4px;
font: inherit;
}
.ma-add:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.04)); }
.ma-inherit {
display: flex;
align-items: center;
gap: 0.3rem;
margin: 0.9rem 0 0;
font-size: 0.88rem;
}
.ma-err { color: var(--danger, #c14242); font-size: 0.82rem; margin: 0.5rem 0 0; min-height: 0; }
.ma-err:empty { display: none; }
.ma-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}

View file

@ -40,24 +40,6 @@
outline: none; outline: none;
} }
/* Hover-doc tooltip (yaml-complete.js) appended to document.body, so it's
styled globally. Carries a key's schema description on hover. */
.cm-doc-tip {
position: fixed;
z-index: 9700;
max-width: 360px;
padding: 6px 9px;
font-size: 0.75rem;
line-height: 1.4;
background: var(--bg-elevated, var(--bg, #fff));
color: var(--text, #222);
border: 1px solid var(--border, #ccc);
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
pointer-events: none;
white-space: normal;
}
/* CodeMirror has to fill the grid cell. The vendored CSS sets /* CodeMirror has to fill the grid cell. The vendored CSS sets
`height: 300px` by default we override to 100% so it grows with `height: 300px` by default we override to 100% so it grows with
the preview pane. */ the preview pane. */

View file

@ -249,7 +249,6 @@ body {
content's natural size (which clips the content's natural size (which clips the
YAML editor's bottom when there are many YAML editor's bottom when there are many
lines, even with the editor's own scroll) */ lines, even with the editor's own scroll) */
min-width: 0;
overflow: auto; overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -257,16 +256,10 @@ body {
} }
/* The body's children fill the available space. Plugins inject /* The body's children fill the available space. Plugins inject
different content here img, iframe, pre, custom markdown editor. different content here img, iframe, pre, custom markdown editor. */
min-width:0 is load-bearing: a flex item defaults to min-width:auto
(its min-content width), so the markdown editor's wide internal
min-content would push the whole pane past the viewport's right edge
instead of shrinking. With min-width:0 the editor shrinks and its own
(and the grid's minmax(0)) scrolling takes over. */
.preview-pane__body > * { .preview-pane__body > * {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
min-width: 0;
} }
.preview-empty { .preview-empty {
@ -903,55 +896,39 @@ body {
/* ── Front matter editor ────────────────────────────────────────────────── */ /* ── Front matter editor ────────────────────────────────────────────────── */
.md-fm__body { .md-fm__body {
/* Body cell owns the CodeMirror editor; sized by the sidebar's grid row. */ /* Body cell owns the textarea; sized by the sidebar's grid row. */
padding: 0; padding: 0;
display: block; display: block;
overflow: hidden; overflow: hidden;
} }
/* Recognised-keys caption under the header (tooltip carries the full list). */ .md-fm__textarea {
.md-fm__hint { width: 100%;
padding: 2px 0.6rem 4px;
font-size: 0.72rem;
color: var(--text-muted);
cursor: help;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* CodeMirror YAML front-matter editor fills the body cell + scrolls
internally, matching the .zddc previewer's editor styling. */
.md-fm__editor,
.md-fm__editor .CodeMirror {
height: 100%; height: 100%;
} box-sizing: border-box;
.md-fm__editor .CodeMirror { margin: 0;
padding: 0.4rem 0.6rem;
border: 0;
background: transparent;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace); font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.45; line-height: 1.45;
background: transparent; resize: none;
color: var(--text); outline: none;
white-space: pre;
overflow: auto;
tab-size: 2;
} }
.md-fm__editor .CodeMirror-gutters { .md-fm__textarea::placeholder {
background: var(--bg-secondary); color: var(--text-muted);
border-right: 1px solid var(--border); font-style: italic;
} }
/* Schema-completion dropdown (show-hint add-on) theme it to the app .md-fm__textarea:focus {
palette so it reads in dark mode; show-hint.css ships light-only. */ background: var(--surface-2, rgba(0, 0, 0, 0.025));
.CodeMirror-hints {
z-index: 9600;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.78rem;
background: var(--bg-elevated, var(--bg, #fff));
border: 1px solid var(--border, #ccc);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
} }
.CodeMirror-hint { .md-fm__textarea[readonly] {
color: var(--text, #222); color: var(--text-muted);
padding: 2px 8px; cursor: not-allowed;
}
li.CodeMirror-hint-active {
background: var(--primary, #2868c8);
color: #fff;
} }
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced /* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced

View file

@ -1,243 +0,0 @@
// manage-access.js — guided "who can do what here" dialog. A task-first
// front door for a folder's .zddc acl: the user picks people + friendly access
// levels; we read the on-disk .zddc, merge ONLY the access bits (preserving
// every other key), and PUT it. No YAML, no schema knowledge required. The raw
// editor stays as the "Advanced" escape hatch.
//
// Friendly level → verbs (r read, w overwrite, c create, d delete, a admin):
// View → r Contribute → rc
// Edit → rwc Manage → admins: membership (not a verb string)
// "Custom" preserves a hand-written verb string we don't recognise.
(function (app) {
'use strict';
if (!app || !app.modules) return;
var util = app.modules.util;
var LEVELS = [
{ id: 'view', label: 'View', hint: 'read only', verbs: 'r' },
{ id: 'contribute', label: 'Contribute', hint: 'read + add new files', verbs: 'rc' },
{ id: 'edit', label: 'Edit', hint: 'read, overwrite, add', verbs: 'rwc' },
{ id: 'manage', label: 'Manage', hint: 'full config + (elevated) bypass', verbs: null }
];
function verbsOfLevel(id) {
for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i].verbs;
return null;
}
function levelOfVerbs(verbs) {
verbs = String(verbs || '');
if (verbs.indexOf('a') !== -1) return 'manage';
if (verbs.indexOf('w') !== -1) return 'edit';
if (verbs.indexOf('c') !== -1) return 'contribute';
if (verbs.indexOf('r') !== -1) return 'view';
return 'custom'; // empty (explicit deny) or non-standard
}
function dirUrl(dir) {
var u = dir || '/';
if (u.charAt(0) !== '/') u = '/' + u;
if (u.charAt(u.length - 1) !== '/') u += '/';
return u;
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
async function open(dir) {
if (!app.state || app.state.source !== 'server') {
toast('Access management needs the server.', 'error');
return;
}
var base = dirUrl(dir);
var zddcUrl = base + '.zddc';
var data = {}, etag = null;
try {
var r = await fetch(zddcUrl, { credentials: 'same-origin' });
if (r.ok) {
etag = r.headers.get('ETag');
var txt = await r.text();
try { data = (window.jsyaml && window.jsyaml.load(txt)) || {}; } catch (_e) { data = {}; }
} else if (r.status !== 404) {
throw new Error('HTTP ' + r.status);
}
} catch (e) {
toast('Could not read access rules: ' + (e.message || e), 'error');
return;
}
if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
// Build the principal → level model from admins (Manage) + acl.permissions.
var acl = (data.acl && typeof data.acl === 'object') ? data.acl : {};
var perms = (acl.permissions && typeof acl.permissions === 'object') ? acl.permissions : {};
var admins = Array.isArray(data.admins) ? data.admins : [];
var rows = [];
var seen = {};
admins.forEach(function (p) {
if (typeof p === 'string' && !seen[p]) { seen[p] = 1; rows.push({ principal: p, level: 'manage', custom: '' }); }
});
Object.keys(perms).forEach(function (p) {
if (seen[p]) return;
seen[p] = 1;
var lvl = levelOfVerbs(perms[p]);
rows.push({ principal: p, level: lvl, custom: lvl === 'custom' ? String(perms[p] || '') : '' });
});
var inherit = acl.inherit !== false;
renderModal(base, zddcUrl, data, etag, rows, inherit);
}
function toast(msg, kind) { if (window.zddc && window.zddc.toast) window.zddc.toast(msg, kind || 'info'); }
function renderModal(base, zddcUrl, data, etag, rows, inherit) {
var overlay = el('div', 'ma-overlay');
var box = el('div', 'ma-box');
overlay.appendChild(box);
box.appendChild(el('h2', 'ma-title', 'Manage access'));
var sub = el('p', 'ma-sub', 'Who can do what in ' + base + ' — changes here only.');
box.appendChild(sub);
var list = el('div', 'ma-list');
box.appendChild(list);
function addRow(model) {
var row = el('div', 'ma-row');
var who = el('input', 'ma-who');
who.type = 'text';
who.value = model.principal || '';
who.placeholder = 'email or *@domain or role name';
who.addEventListener('input', function () { model.principal = who.value.trim(); });
var sel = el('select', 'ma-level');
LEVELS.forEach(function (lv) {
var o = el('option', null, lv.label);
o.value = lv.id;
o.title = lv.hint;
sel.appendChild(o);
});
if (model.level === 'custom') {
var o2 = el('option', null, 'Custom');
o2.value = 'custom';
o2.title = 'verbs: ' + model.custom;
sel.appendChild(o2);
}
sel.value = model.level;
sel.addEventListener('change', function () { model.level = sel.value; });
var del = el('button', 'ma-del', '✕');
del.type = 'button';
del.title = 'Remove';
del.addEventListener('click', function () { row.remove(); model._removed = true; });
row.appendChild(who);
row.appendChild(sel);
row.appendChild(del);
list.appendChild(row);
return model;
}
rows.forEach(addRow);
var addBtn = el('button', 'ma-add', '+ Add person or group');
addBtn.type = 'button';
addBtn.addEventListener('click', function () {
var m = { principal: '', level: 'view', custom: '' };
rows.push(m);
addRow(m);
});
box.appendChild(addBtn);
var legend = el('p', 'ma-legend',
'View = read · Contribute = add new files · Edit = overwrite + add · Manage = admin');
box.appendChild(legend);
// Inherit / make-private.
var inhWrap = el('label', 'ma-inherit');
var inhBox = el('input');
inhBox.type = 'checkbox';
inhBox.checked = inherit;
inhWrap.appendChild(inhBox);
inhWrap.appendChild(el('span', null, ' Inherit access from parent folders'));
box.appendChild(inhWrap);
var err = el('p', 'ma-err');
box.appendChild(err);
var actions = el('div', 'ma-actions');
var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel');
cancel.type = 'button';
var save = el('button', 'btn btn-sm btn-primary', 'Save');
save.type = 'button';
actions.appendChild(cancel);
actions.appendChild(save);
box.appendChild(actions);
function close() {
document.removeEventListener('keydown', onKey, true);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
document.addEventListener('keydown', onKey, true);
overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) close(); });
cancel.addEventListener('click', close);
save.addEventListener('click', function () {
err.textContent = '';
// Rebuild perms + admins from the live rows (skip removed/blank).
var perms = {}, admins = [], bad = false;
rows.forEach(function (m) {
if (m._removed) return;
var p = (m.principal || '').trim();
if (!p) return;
if (m.level === 'manage') {
if (admins.indexOf(p) === -1) admins.push(p);
} else if (m.level === 'custom') {
perms[p] = m.custom; // preserve the hand-written string
} else {
perms[p] = verbsOfLevel(m.level);
}
});
// Merge into the existing doc, preserving every unmanaged key.
var out = {};
Object.keys(data).forEach(function (k) { out[k] = data[k]; });
var acl = (out.acl && typeof out.acl === 'object') ? Object.assign({}, out.acl) : {};
if (Object.keys(perms).length) acl.permissions = perms; else delete acl.permissions;
if (!inhBox.checked) acl.inherit = false; else delete acl.inherit;
if (Object.keys(acl).length) out.acl = acl; else delete out.acl;
if (admins.length) out.admins = admins; else delete out.admins;
var content;
try { content = window.jsyaml.dump(out); }
catch (e2) { err.textContent = 'Could not serialize: ' + (e2.message || e2); return; }
save.disabled = true;
save.textContent = 'Saving…';
var node = { url: zddcUrl, name: '.zddc', ext: '' };
util.saveFile(node, content, 'application/yaml; charset=utf-8', etag ? { etag: etag } : {})
.then(function () {
toast('Access updated for ' + base, 'success');
var ev = app.modules.events;
if (ev && ev.refreshListing) { try { ev.refreshListing(); } catch (_e) { /* ignore */ } }
close();
})
.catch(function (e3) {
save.disabled = false;
save.textContent = 'Save';
if (e3 && e3.status === 412) {
err.textContent = 'These rules changed on the server since you opened this. Close and reopen to get the latest, then redo your change.';
} else {
err.textContent = 'Save failed: ' + (e3 && e3.message ? e3.message : e3);
}
});
});
document.body.appendChild(overlay);
var first = box.querySelector('.ma-who');
if (first) first.focus();
}
app.modules.manageAccess = { open: open };
})(window.app);

View file

@ -352,30 +352,13 @@
// ── admin / sub-admin tier ── // ── admin / sub-admin tier ──
{ {
// Guided "who can do what here" dialog — the front door for access. // HIDDEN unless the user can actually edit access rules here
// HIDDEN unless the user can administer here (admin verb 'a', or // (admin verb 'a', or subtree/site admin) — not shown greyed.
// subtree/site admin).
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'], id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
label: 'Manage access…', label: 'Edit access rules…',
appliesTo: function (ctx) { appliesTo: function (ctx) {
if (!isServer()) return false; // server-only tier if (!isServer()) return false; // server-only tier
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node); var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
return typeOk && manageAccessGate(ctx).enabled
&& !!(window.app.modules.manageAccess);
},
action: function (ctx) {
var m = window.app.modules.manageAccess;
if (m) m.open(ctx.dir);
}
},
{
// The raw-YAML escape hatch — same authority gate, demoted to
// "advanced" since the guided dialog covers the common case.
id: 'edit-zddc-raw', group: 'admin', surfaces: ['row', 'pane'],
label: 'Edit raw policy (.zddc)…',
appliesTo: function (ctx) {
if (!isServer()) return false;
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
return typeOk && manageAccessGate(ctx).enabled; return typeOk && manageAccessGate(ctx).enabled;
}, },
action: function (ctx) { openZddcEditor(ctx.dir); } action: function (ctx) { openZddcEditor(ctx.dir); }

View file

@ -82,38 +82,30 @@
// empty / unavailable. The promise dedupes concurrent fetches. // empty / unavailable. The promise dedupes concurrent fetches.
var fmPlaceholder = null; var fmPlaceholder = null;
var fmPlaceholderPromise = null; var fmPlaceholderPromise = null;
// Recognised fields ([{name, hint, values}]) from the same /.api/frontmatter
// fetch — drives schema completion (keys + enum values). null = not loaded.
var fmFields = null;
// applyFrontMatterHint populates a greyed caption (+ tooltip) with the // applyFrontMatterPlaceholder sets the textarea placeholder to the server's
// server's recognised front-matter fields, in server mode only. Async + // recognised-field hint, in server mode only. Async + best-effort: a failed
// best-effort: a failed fetch leaves the caption hidden, never an error. // fetch leaves the pane blank (no placeholder), never an error.
// (Replaces the old textarea placeholder — CodeMirror 5 has no built-in function applyFrontMatterPlaceholder(textarea) {
// placeholder without an unvendored add-on. Arbitrary keys stay free.)
function applyFrontMatterHint(el) {
var st = window.app && window.app.state; var st = window.app && window.app.state;
if (!st || st.source !== 'server') return; if (!st || st.source !== 'server') return;
function paint() { if (fmPlaceholder !== null) {
if (!el.isConnected) return; // user switched files before resolve textarea.placeholder = fmPlaceholder;
if (!fmPlaceholder) { el.style.display = 'none'; return; } return;
el.textContent = 'ⓘ Recognised front-matter keys (hover) — any other key is allowed';
el.title = fmPlaceholder;
el.style.display = '';
} }
if (fmPlaceholder !== null) { paint(); return; }
if (!fmPlaceholderPromise) { if (!fmPlaceholderPromise) {
fmPlaceholderPromise = fetch('/.api/frontmatter', { fmPlaceholderPromise = fetch('/.api/frontmatter', {
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
credentials: 'same-origin' credentials: 'same-origin'
}).then(function (r) { return r.ok ? r.json() : null; }) }).then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { .then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
fmPlaceholder = (j && j.placeholder) || ''; .catch(function () { fmPlaceholder = ''; });
fmFields = (j && j.fields) || [];
})
.catch(function () { fmPlaceholder = ''; fmFields = []; });
} }
fmPlaceholderPromise.then(paint); fmPlaceholderPromise.then(function () {
// Only apply if this textarea is still in the DOM (user may have
// switched files before the fetch resolved).
if (textarea.isConnected) textarea.placeholder = fmPlaceholder;
});
} }
// Lightweight YAML front-matter parser. Same envelope as mdedit's: // Lightweight YAML front-matter parser. Same envelope as mdedit's:
@ -435,19 +427,22 @@
fmHeader.textContent = 'YAML front matter'; fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div'); var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body'; fmBody.className = 'md-side__body md-fm__body';
// CodeMirror YAML editor host — mounted with the front-matter value var fmTextarea = document.createElement('textarea');
// once it's computed (sync-on-open) below. Same editor family as the fmTextarea.className = 'md-fm__textarea';
// .zddc previewer: syntax highlighting, line numbers, lint gutter. fmTextarea.spellcheck = false;
var fmEditorHost = document.createElement('div'); fmTextarea.autocapitalize = 'off';
fmEditorHost.className = 'md-fm__editor'; fmTextarea.autocomplete = 'off';
fmBody.appendChild(fmEditorHost); // Placeholder: in server mode, hint the recognised front-matter keys
// Recognised-keys hint (server mode): a greyed caption under the header // (doctype, numbering, …) as greyed text so authors can discover them.
// whose tooltip carries the full "key: # hint" template from // It's placeholder-only — inserts nothing, vanishes on the first
// /.api/frontmatter. Replaces the old textarea placeholder. // keystroke — so arbitrary keys stay free and a file with no front
var fmHint = document.createElement('div'); // matter still renders as a genuinely empty pane. The text is fetched
fmHint.className = 'md-fm__hint'; // from the server (/.api/frontmatter), the single source of truth, so
fmHint.style.display = 'none'; // it never drifts from what the converter honours. file:// mode shows
applyFrontMatterHint(fmHint); // no placeholder (conversion is server-only).
fmTextarea.placeholder = '';
applyFrontMatterPlaceholder(fmTextarea);
fmBody.appendChild(fmTextarea);
// Rename cue: shown when the author edits an identity field // Rename cue: shown when the author edits an identity field
// (tracking_number / revision / status / title) away from the // (tracking_number / revision / status / title) away from the
// filename. The filename owns identity, so the cue offers an explicit // filename. The filename owns identity, so the cue offers an explicit
@ -455,16 +450,12 @@
// discarding the value. Populated by renderIdentityCue(). // discarding the value. Populated by renderIdentityCue().
var fmWarn = document.createElement('div'); var fmWarn = document.createElement('div');
fmWarn.className = 'md-fm__warn'; fmWarn.className = 'md-fm__warn';
// Visibility is controlled via style.display (toggled in fmWarn.hidden = true;
// renderIdentityCue), NOT the `hidden` attribute: an inline
// display:flex outranks [hidden]{display:none}, which would leave an
// empty box on screen whenever the cue has nothing to say.
fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid ' fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid '
+ '#fcd34d;border-radius:4px;padding:6px 8px;margin:0 0 4px;font-size:' + '#fcd34d;border-radius:4px;padding:6px 8px;margin:0 0 4px;font-size:'
+ '0.78rem;line-height:1.5;flex-wrap:wrap;align-items:center;gap:6px;' + '0.78rem;line-height:1.5;display:flex;flex-wrap:wrap;align-items:'
+ 'display:none;'; + 'center;gap:6px;';
fmSection.appendChild(fmHeader); fmSection.appendChild(fmHeader);
fmSection.appendChild(fmHint);
fmSection.appendChild(fmWarn); fmSection.appendChild(fmWarn);
fmSection.appendChild(fmBody); fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection); sidebar.appendChild(fmSection);
@ -609,59 +600,21 @@
// even if we tweak whitespace in the YAML lines. // even if we tweak whitespace in the YAML lines.
var initialParsed = parseFrontMatter(text); var initialParsed = parseFrontMatter(text);
var bodyText = initialParsed.body; var bodyText = initialParsed.body;
// On open, RECONCILE existing front-matter identity keys with the // On open, mirror the filename-derived identity into the front matter
// filename (the single source of truth) — but never ADD them. A blank // (the filename is the single source of truth; this keeps the values
// or new file opens blank (we don't inject a title etc.); a file whose // baked in for the converter). No-op for non-ZDDC filenames. The dirty
// author already wrote a now-stale title/revision/… gets corrected.
// The converter derives identity from the filename regardless, so
// there's nothing to "bake in" for an empty front matter. The dirty
// baseline stays the ON-DISK state, so a correction opens the buffer // baseline stays the ON-DISK state, so a correction opens the buffer
// dirty and a save persists it. // dirty and a save persists it.
var onDiskFM = stringifyFrontMatter(initialParsed.data); var onDiskFM = stringifyFrontMatter(initialParsed.data);
var fid = filenameIdentity(node.name); var fid = filenameIdentity(node.name);
if (fid) { if (fid) {
for (var ik in fid) { for (var ik in fid) {
if (Object.prototype.hasOwnProperty.call(fid, ik) if (Object.prototype.hasOwnProperty.call(fid, ik)) initialParsed.data[ik] = fid[ik];
&& Object.prototype.hasOwnProperty.call(initialParsed.data, ik)) {
initialParsed.data[ik] = fid[ik];
} }
} }
} fmTextarea.value = stringifyFrontMatter(initialParsed.data);
var syncedFM = stringifyFrontMatter(initialParsed.data);
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText)); var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
var writableMode = canSave(node); var writableMode = canSave(node);
// Front-matter YAML editor — CodeMirror, the same editor family as the
// .zddc previewer (syntax highlighting, line numbers, shared js-yaml
// lint gutter). Replaces the old <textarea>.
var fmCM = window.CodeMirror(fmEditorHost, {
value: syncedFM,
mode: 'yaml',
lineNumbers: true,
tabSize: 2,
indentUnit: 2,
indentWithTabs: false,
lineWrapping: true,
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
lint: { hasGutters: true },
autofocus: false,
readOnly: !writableMode
});
// The yaml lint helper (registered by the .zddc previewer) checks this
// to decide the schema layer; a .md node → plain js-yaml parse lint.
fmCM._zddcNode = node;
// Schema completion + hover docs via the shared helper. The front
// matter is flat; the identity keys are excluded from suggestions
// (filename-driven — see renderIdentityCue).
var yc = window.app.modules.yamlComplete;
if (yc) {
yc.attach(fmCM, yc.flatProvider(function () { return fmFields; }, {
exclude: IDENTITY_FIELDS.map(function (f) { return f.fm; })
}), { readOnly: !writableMode });
}
// CodeMirror mis-measures when mounted before its pane is laid out;
// refresh on the next frame so the gutters + scroll size correctly.
requestAnimationFrame(function () { fmCM.refresh(); });
// autofocus:false keeps the keyboard caret in the tree pane — // autofocus:false keeps the keyboard caret in the tree pane —
// arrow-key nav can continue through markdown files without // arrow-key nav can continue through markdown files without
// diverting into the editor. The user clicks into the editor // diverting into the editor. The user clicks into the editor
@ -710,7 +663,7 @@
node: node, node: node,
hash: initialHash, hash: initialHash,
tocEl: tocBody, tocEl: tocBody,
fmEl: fmCM, fmEl: fmTextarea,
ac: ac, ac: ac,
// Server version token captured at load — sent as If-Match on // Server version token captured at load — sent as If-Match on
// save and refreshed from each successful PUT's response ETag. // save and refreshed from each successful PUT's response ETag.
@ -722,7 +675,7 @@
if (!writableMode) { if (!writableMode) {
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.'; saveBtn.title = 'Save not available — read-only source.';
// fmCM was created with readOnly:!writableMode — nothing more here. fmTextarea.readOnly = true;
} }
renderToc(tocBody, bodyText, editor); renderToc(tocBody, bodyText, editor);
@ -832,7 +785,7 @@
var onChange = debounce(async function () { var onChange = debounce(async function () {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
var body = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmCM.getValue(), body)); var h = await hashContent(assembleContent(fmTextarea.value, body));
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
markDirty(h !== instance.hash); markDirty(h !== instance.hash);
renderToc(tocBody, body, editor); renderToc(tocBody, body, editor);
@ -869,8 +822,8 @@
function renderIdentityCue() { function renderIdentityCue() {
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild); while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
var fid = filenameIdentity(node.name); var fid = filenameIdentity(node.name);
if (!fid || !canSave(node)) { fmWarn.style.display = 'none'; return; } if (!fid || !canSave(node)) { fmWarn.hidden = true; return; }
var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {}; var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
var edits = []; var edits = [];
IDENTITY_FIELDS.forEach(function (f) { IDENTITY_FIELDS.forEach(function (f) {
if (!(f.fm in data)) return; if (!(f.fm in data)) return;
@ -878,7 +831,7 @@
var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim(); var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim();
if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”'); if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”');
}); });
if (!edits.length) { fmWarn.style.display = 'none'; return; } if (!edits.length) { fmWarn.hidden = true; return; }
var msg = document.createElement('span'); var msg = document.createElement('span');
msg.textContent = '✎ Identity comes from the filename. You changed ' msg.textContent = '✎ Identity comes from the filename. You changed '
+ edits.join(', ') + '. '; + edits.join(', ') + '. ';
@ -893,36 +846,7 @@
btn.addEventListener('click', function () { renameToMatch(newName); }); btn.addEventListener('click', function () { renameToMatch(newName); });
fmWarn.appendChild(btn); fmWarn.appendChild(btn);
} }
// Cancel: discard the identity edits, restoring the filename values. fmWarn.hidden = false;
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn btn-sm md-fm__revert';
cancelBtn.textContent = 'Cancel';
cancelBtn.title = 'Discard these identity edits and restore the filename values.';
cancelBtn.addEventListener('click', function () { revertIdentityEdits(); });
fmWarn.appendChild(cancelBtn);
fmWarn.style.display = 'flex';
}
// Revert the identity fields in the front matter to the filename-
// derived values (undo a manual identity edit), leaving the rest of the
// front matter + body untouched. Recomputes dirty + the cue after.
async function revertIdentityEdits() {
var fid = filenameIdentity(node.name);
if (!fid) return;
var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
for (var k in fid) {
if (Object.prototype.hasOwnProperty.call(fid, k)
&& Object.prototype.hasOwnProperty.call(data, k)) {
data[k] = fid[k];
}
}
fmCM.setValue(stringifyFrontMatter(data));
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
renderIdentityCue();
} }
// Rename action: persist the current buffer (so body edits aren't // Rename action: persist the current buffer (so body edits aren't
@ -933,26 +857,11 @@
async function renameToMatch(newName) { async function renameToMatch(newName) {
var up = window.app.modules.upload; var up = window.app.modules.upload;
if (!up || !up.renameNode || !newName) return; if (!up || !up.renameNode || !newName) return;
// 1. Persist the current buffer first so body edits survive the // 1. Save first so body/FM edits survive the rename. A failed save
// rename. Force the write (no If-Match) — the user deliberately // (conflict, ACL) leaves the buffer dirty — abort the rename.
// initiated this rename, so we commit their version rather than if (instance.dirty) {
// interrupting with the conflict-resolution modal (which save() await save();
// raises on a 412). The identity edit that triggered the rename if (currentInstance !== instance || instance.dirty) return;
// is consumed by the new filename, so there's nothing to merge.
if (instance.dirty && canSave(node)) {
try {
var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
statusEl.textContent = 'Saving…';
var res = await saveContent(node, content, { force: true });
await markSaved(content, res);
if (currentInstance !== instance) return;
} catch (e) {
statusEl.textContent = '';
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
}
return;
}
} }
// 2. Rename on disk. // 2. Rename on disk.
try { try {
@ -993,21 +902,17 @@
var onFmChange = debounce(async function () { var onFmChange = debounce(async function () {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
var body = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmCM.getValue(), body)); var h = await hashContent(assembleContent(fmTextarea.value, body));
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
markDirty(h !== instance.hash); markDirty(h !== instance.hash);
renderIdentityCue(); renderIdentityCue();
}, 250); }, 250);
fmCM.on('change', onFmChange); fmTextarea.addEventListener('input', onFmChange);
renderIdentityCue(); // initial state on load (clean after sync-on-open) renderIdentityCue(); // initial state on load (clean after sync-on-open)
// If sync-on-open corrected the front matter, open the buffer dirty so // If sync-on-open corrected the front matter, open the buffer dirty so
// a save bakes the filename-derived identity in — and say so, since the // a save bakes the filename-derived identity in.
// change is otherwise silent (the values just match the filename now). if (writableMode && fmTextarea.value !== onDiskFM) markDirty(true);
if (writableMode && fmCM.getValue() !== onDiskFM) {
markDirty(true);
statusEl.textContent = 'Front matter synced to filename — review and save';
}
// ── Save ─────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────
// Mark a successful write: adopt the new server ETag (so the next // Mark a successful write: adopt the new server ETag (so the next
@ -1071,7 +976,7 @@
async function save() { async function save() {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
if (!instance.dirty || !canSave(node)) return; if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmCM.getValue(), editor.getMarkdown()); var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try { try {
statusEl.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
var res = await saveContent(node, content, { var res = await saveContent(node, content, {

View file

@ -43,26 +43,6 @@
return ext === 'yaml' || ext === 'yml'; 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) ───────────────────────────────── // ── Save (mirrors preview-markdown.js) ─────────────────────────────────
function saveContent(node, content, opts) { function saveContent(node, content, opts) {
@ -110,10 +90,49 @@
// any level surface as warnings — typos like `defaul_tool` are // any level surface as warnings — typos like `defaul_tool` are
// common and the cascade silently ignores them. // common and the cascade silently ignores them.
// The valid keys, types, enums and nesting are NOT hand-listed here any var ALLOWED_TOOLS = {
// more — they come from the baked .zddc JSON Schema (window.__ZDDC_SCHEMA__, archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
// the same grammar the server serves at /.api/zddc-schema and that drives tables: 1, form: 1
// completion + hover). One source, no drift. See validateZddcSchema below. };
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' };
function typeOf(v) { function typeOf(v) {
if (v === null || v === undefined) return 'null'; if (v === null || v === undefined) return 'null';
@ -121,87 +140,168 @@
return typeof v; // 'string' | 'number' | 'boolean' | 'object' return typeof v; // 'string' | 'number' | 'boolean' | 'object'
} }
// The .zddc JSON Schema, baked into the bundle at build time // Collect schema issues for a parsed .zddc document. Each issue is
// (window.__ZDDC_SCHEMA__ — the same file the server serves at // { keyPath: string[], message: string, severity: 'error' | 'warning' }.
// /.api/zddc-schema). Single source for lint, completion and hover; works // keyPath is used by findLine() to locate the offending source line.
// 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) { function validateZddc(doc) {
var schema = getZddcSchema();
var issues = []; var issues = [];
if (!schema || !schema.properties) return issues; // schema unavailable
if (typeOf(doc) === 'null') return issues; if (typeOf(doc) === 'null') return issues;
if (typeOf(doc) !== 'object') { if (typeOf(doc) !== 'object') {
issues.push({ keyPath: [], severity: 'error', issues.push({ keyPath: [], severity: 'error',
message: 'Root must be a map (got ' + typeOf(doc) + ').' }); message: 'Root must be a map (got ' + typeOf(doc) + ').' });
return issues; return issues;
} }
function deref(n) { return (n && n.$ref === '#') ? schema : n; } walkObject(doc, TOP_KEYS, [], issues);
function typeOk(t, want) { return issues;
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 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 walk(value, sch, path) { checkValue(obj[key], kind, here, issues);
sch = deref(sch); }
if (!sch) return; }
var t = typeOf(value);
if (t === 'null') return; // empty value mid-edit — don't flag function checkValue(val, kind, path, issues) {
if (sch.type && !typeOk(t, sch.type)) { 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', issues.push({ keyPath: path, severity: 'error',
message: 'Expected ' + (Array.isArray(sch.type) ? sch.type.join('/') : sch.type) message: 'available_tools[' + i + '] must be a string.' });
+ ', got ' + t + '.' }); } 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; 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)) {
function addTypeErr(path, expected, got, issues) {
issues.push({ keyPath: path, severity: 'error', issues.push({ keyPath: path, severity: 'error',
message: 'Value "' + value + '" must match ' + sch.pattern + '.' }); message: 'Expected ' + expected + ', got ' + got + '.' });
}
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 // Locate the source line for a key path. .zddc files are
@ -365,10 +465,8 @@
schemaTag.addEventListener('keydown', function (ev) { schemaTag.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); } if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
}); });
} else if (isYamlFile(node)) {
schemaTag.textContent = 'YAML';
} else { } else {
schemaTag.textContent = (node.ext || 'text').toUpperCase(); schemaTag.textContent = 'YAML';
} }
var dirtyEl = document.createElement('span'); var dirtyEl = document.createElement('span');
@ -408,22 +506,16 @@
} }
var writable = canSave(node); 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, { var editor = window.CodeMirror(editorHost, {
value: text, value: text,
mode: mode, mode: 'yaml',
lineNumbers: true, lineNumbers: true,
tabSize: 2, tabSize: 2,
indentUnit: 2, indentUnit: 2,
indentWithTabs: false, indentWithTabs: false,
lineWrapping: false, lineWrapping: false,
gutters: yamlMode gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'] lint: { hasGutters: true },
: ['CodeMirror-linenumbers'],
lint: yamlMode ? { hasGutters: true } : false,
// autofocus:false keeps the keyboard caret in the browse // autofocus:false keeps the keyboard caret in the browse
// tree pane so arrow-key nav can continue through yaml / // tree pane so arrow-key nav can continue through yaml /
// .zddc files without diverting into the editor. User // .zddc files without diverting into the editor. User
@ -441,15 +533,8 @@
// Stash the node on the editor so the lint helper can decide // Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer. // whether to apply the .zddc schema layer.
editor._zddcNode = node; editor._zddcNode = node;
// Force an initial lint pass now that _zddcNode is set (YAML only). // Force an initial lint pass now that _zddcNode is set.
if (yamlMode) editor.performLint(); 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; currentEditor = editor;
currentNodeRef = node; currentNodeRef = node;
currentDirty = false; currentDirty = false;
@ -571,7 +656,8 @@
} }
function handles(node) { function handles(node) {
return isCodeFile(node); if (!node || node.isDir || node.isZip) return false;
return isYamlFile(node);
} }
window.app.modules.yamledit = { window.app.modules.yamledit = {

View file

@ -0,0 +1,325 @@
// preview-zddc-form.js — schema-driven FORM view for .zddc files.
//
// The user shouldn't have to understand YAML cascades to configure a project.
// This renders the .zddc as a form: the OPTION fields (the blanks an operator
// fills — title, admins, role members) are editable widgets; the STRUCTURE
// (paths, WORM, tools, behaviours — what a ZDDC project IS) is shown read-only
// for context. The split is driven by the server's .zddc JSON Schema
// (/.api/zddc-schema, x-zddc-tier: structure|option). Saving merges the edited
// option values back into the file (preserving all structure keys) and PUTs the
// YAML — which works for an on-disk .zddc and for a .zddc.zip bundle member
// (the server's ServeZipWrite). An "Edit raw YAML" escape hands off to the
// CodeMirror editor for anything the form doesn't cover (field_codes, display,
// convert, advanced acl).
//
// This is the primary .zddc editor; the raw-YAML plugin (preview-yaml.js) is
// the power-user fallback.
(function (app) {
'use strict';
var util = app.modules.util || window.app.modules.util;
var escapeHtml = util.escapeHtml;
var saveFile = util.saveFile;
var isEditableZipMember = util.isEditableZipMember;
var current = null; // { node, dirty, etag, lastModified }
// Cached .zddc schema (property → {tier, description}).
var schemaProps = null;
function loadSchema() {
if (schemaProps) return Promise.resolve(schemaProps);
return fetch('/.api/zddc-schema', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { schemaProps = (j && j.properties) || {}; return schemaProps; })
.catch(function () { schemaProps = {}; return schemaProps; });
}
function handles(node) {
return !!node && (node.name === '.zddc' || /\.zddc$/i.test(node.name || ''));
}
function canSave(node) {
if (isEditableZipMember(node)) return true;
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
// A .zddc edit is an ActionAdmin write — needs the 'a' verb.
return window.zddc.cap.has(node, 'a');
}
return false;
}
function isDirty() { return !!(current && current.dirty); }
function currentNode() { return current ? current.node : null; }
function dispose() { current = null; }
function desc(name) {
return (schemaProps && schemaProps[name] && schemaProps[name].description) || '';
}
// ── small DOM helpers ───────────────────────────────────────────────────
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
// A growable list of single-string rows (used for admins + role members).
function listEditor(values, placeholder, onChange, readOnly) {
var wrap = el('div', 'zf-list');
function addRow(val) {
var row = el('div', 'zf-list__row');
row.style.cssText = 'display:flex;gap:.4rem;margin:.2rem 0;';
var input = el('input');
input.type = 'text';
input.value = val || '';
input.placeholder = placeholder || '';
input.style.cssText = 'flex:1;padding:.3rem;font-family:var(--code,monospace);';
input.disabled = !!readOnly;
input.addEventListener('input', onChange);
row.appendChild(input);
if (!readOnly) {
var del = el('button', null, '');
del.type = 'button';
del.title = 'Remove';
del.addEventListener('click', function () { row.remove(); onChange(); });
row.appendChild(del);
}
wrap.appendChild(row);
}
(values || []).forEach(addRow);
if (!readOnly) {
var add = el('button', 'zf-add', '+ add');
add.type = 'button';
add.style.cssText = 'margin-top:.2rem;';
add.addEventListener('click', function () { addRow(''); onChange(); });
wrap.appendChild(add);
}
wrap._values = function () {
return Array.prototype.slice.call(wrap.querySelectorAll('.zf-list__row input'))
.map(function (i) { return i.value.trim(); })
.filter(function (v) { return v; });
};
return wrap;
}
async function render(node, container, ctx) {
dispose();
var text, etag = null, lastModified = null;
try {
if (ctx.getContentWithVersion) {
var loaded = await ctx.getContentWithVersion(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
etag = loaded.etag;
lastModified = loaded.lastModified;
} else {
text = new TextDecoder('utf-8', { fatal: false }).decode(await ctx.getArrayBuffer(node));
}
} catch (e) {
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
+ 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '</div>';
return;
}
var data = {};
try { data = (window.jsyaml && window.jsyaml.load(text)) || {}; } catch (_) { data = {}; }
if (typeof data !== 'object' || Array.isArray(data)) data = {};
await loadSchema();
var editable = canSave(node);
current = { node: node, dirty: false, etag: etag, lastModified: lastModified };
container.innerHTML = '';
var shell = el('div', 'yaml-shell zddc-form');
shell.style.cssText = 'padding:.75rem 1rem;overflow:auto;height:100%;box-sizing:border-box;';
container.appendChild(shell);
// Header.
var hdr = el('div', 'md-shell__infohdr');
hdr.appendChild(el('span', 'md-shell__title', node.name));
var srcTag = el('span', 'md-shell__source', isEditableZipMember(node) ? 'config bundle' : (editable ? '.zddc form' : 'read-only'));
hdr.appendChild(srcTag);
var dirtyEl = el('span', 'md-shell__dirty');
hdr.appendChild(dirtyEl);
var statusEl = el('span', 'md-shell__status');
hdr.appendChild(statusEl);
var rawBtn = el('button', 'btn btn-sm btn-secondary', 'Edit raw YAML');
rawBtn.type = 'button';
rawBtn.title = 'Switch to the raw YAML editor (covers every key).';
rawBtn.addEventListener('click', function () {
var ym = window.app.modules.yamledit;
if (ym && ym.render) { dispose(); ym.render(node, container, ctx); }
});
hdr.appendChild(rawBtn);
var saveBtn = el('button', 'btn btn-sm btn-primary', 'Save');
saveBtn.type = 'button';
saveBtn.disabled = true;
hdr.appendChild(saveBtn);
shell.appendChild(hdr);
function markDirty() {
if (!current) return;
current.dirty = true;
dirtyEl.textContent = '● modified';
if (editable) saveBtn.disabled = false;
}
var help = el('p', 'help');
help.style.cssText = 'color:var(--color-text-muted,#666);font-size:.85rem;margin:.3rem 0 .5rem;';
help.textContent = editable
? 'Project options. Structural keys are read-only — use Edit raw YAML.'
: 'Read-only — you need admin authority over this path to edit it.';
shell.appendChild(help);
// ── OPTION fields ───────────────────────────────────────────────────
function section(title, hint, tight) {
var s = el('section', 'zf-section');
s.style.cssText = 'margin:0 0 1rem;';
var h = el('h3', null, title);
// `tight` drops the heading's top margin for the FIRST section so
// it doesn't stack with the intro's bottom margin (the gap above
// Title was reading as excessive). Later sections keep the gap.
h.style.cssText = 'font-size:1em;margin:' + (tight ? '0' : '.6rem') + ' 0 .2rem;';
s.appendChild(h);
if (hint) {
var p = el('p', 'help', hint);
p.style.cssText = 'color:var(--color-text-muted,#888);font-size:.8rem;margin:0 0 .3rem;';
s.appendChild(p);
}
shell.appendChild(s);
return s;
}
// title
var titleSec = section('Title', desc('title'), true);
var titleInput = el('input');
titleInput.type = 'text';
titleInput.value = (typeof data.title === 'string') ? data.title : '';
titleInput.disabled = !editable;
titleInput.style.cssText = 'width:100%;max-width:32rem;padding:.35rem;';
titleInput.addEventListener('input', markDirty);
titleSec.appendChild(titleInput);
// admins
var adminsSec = section('Admins', desc('admins'));
var adminsList = listEditor(Array.isArray(data.admins) ? data.admins : [], 'email or *@domain', markDirty, !editable);
adminsSec.appendChild(adminsList);
// roles (map name → {members:[]})
var rolesSec = section('Roles', desc('roles') || 'Who belongs to each project role.');
var rolesHost = el('div', 'zf-roles');
rolesSec.appendChild(rolesHost);
var roleEditors = []; // {name, membersEl, getName}
function addRole(name, members) {
var box = el('div', 'zf-role');
box.style.cssText = 'border:1px solid rgba(0,0,0,0.1);border-radius:4px;padding:.4rem .6rem;margin:.3rem 0;';
var nameRow = el('div');
nameRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.2rem;';
var nameInput = el('input');
nameInput.type = 'text';
nameInput.value = name || '';
nameInput.placeholder = 'role name (e.g. document_controller)';
nameInput.style.cssText = 'font-family:var(--code,monospace);font-weight:600;flex:1;padding:.25rem;';
nameInput.disabled = !editable;
nameInput.addEventListener('input', markDirty);
nameRow.appendChild(el('span', null, '👥'));
nameRow.appendChild(nameInput);
box.appendChild(nameRow);
var membersList = listEditor(members || [], 'member email or *@domain', markDirty, !editable);
box.appendChild(membersList);
rolesHost.appendChild(box);
roleEditors.push({ getName: function () { return nameInput.value.trim(); }, members: membersList });
}
var roles = (data.roles && typeof data.roles === 'object') ? data.roles : {};
Object.keys(roles).forEach(function (rn) {
var m = (roles[rn] && Array.isArray(roles[rn].members)) ? roles[rn].members : [];
addRole(rn, m);
});
if (editable) {
var addRoleBtn = el('button', 'zf-add', '+ add role');
addRoleBtn.type = 'button';
addRoleBtn.addEventListener('click', function () { addRole('', []); markDirty(); });
rolesSec.appendChild(addRoleBtn);
}
// ── STRUCTURE (read-only) ───────────────────────────────────────────
var structKeys = Object.keys(data).filter(function (k) {
return schemaProps[k] && schemaProps[k].tier === 'structure';
});
// Also surface option keys this form doesn't render yet, as read-only.
var rawHandled = { title: 1, admins: 1, roles: 1 };
var otherKeys = Object.keys(data).filter(function (k) {
return !rawHandled[k] && !(schemaProps[k] && schemaProps[k].tier === 'structure');
});
if (structKeys.length || otherKeys.length) {
var det = el('details', 'zf-structure');
det.style.cssText = 'margin-top:.5rem;';
var sum = el('summary', null, 'Structure & advanced (read-only — edit via raw YAML)');
sum.style.cssText = 'cursor:pointer;color:var(--color-text-muted,#666);font-size:.85rem;';
det.appendChild(sum);
var subset = {};
structKeys.concat(otherKeys).forEach(function (k) { subset[k] = data[k]; });
var pre = el('pre');
pre.style.cssText = 'background:var(--code-bg,#f6f8fa);padding:.5rem;border-radius:4px;overflow:auto;font-size:.8rem;';
try { pre.textContent = window.jsyaml ? window.jsyaml.dump(subset) : JSON.stringify(subset, null, 2); }
catch (_) { pre.textContent = JSON.stringify(subset, null, 2); }
det.appendChild(pre);
shell.appendChild(det);
}
// ── Save ────────────────────────────────────────────────────────────
function buildContent() {
var out = {};
// Preserve everything not managed by the form (structure + unrendered options).
Object.keys(data).forEach(function (k) { if (!rawHandled[k]) out[k] = data[k]; });
var t = titleInput.value.trim();
if (t) out.title = t;
var admins = adminsList._values();
if (admins.length) out.admins = admins;
var rolesOut = {};
roleEditors.forEach(function (re) {
var n = re.getName();
if (!n) return;
var mem = re.members._values();
rolesOut[n] = mem.length ? { members: mem } : { members: [] };
});
if (Object.keys(rolesOut).length) out.roles = rolesOut;
return window.jsyaml.dump(out);
}
saveBtn.addEventListener('click', async function () {
if (!current || !editable) return;
saveBtn.disabled = true;
statusEl.textContent = 'Saving…';
var content;
try { content = buildContent(); }
catch (e) { statusEl.textContent = 'Serialize failed: ' + (e.message || e); return; }
try {
var res = await saveFile(node, content, 'application/yaml; charset=utf-8',
{ etag: current.etag, lastModified: current.lastModified });
if (!current) return;
current.etag = (res && res.etag) || current.etag;
current.dirty = false;
dirtyEl.textContent = '';
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) window.zddc.toast('Saved ' + node.name, 'success');
} catch (e) {
if (e && e.status === 412 && window.app.modules.conflict) {
window.app.modules.conflict.open({
name: node.name, theirsText: '', minePut: function () { return saveFile(node, content, 'application/yaml; charset=utf-8', {}); }
});
statusEl.textContent = 'Conflict — changed on server.';
} else {
statusEl.textContent = 'Save failed: ' + (e && e.message ? e.message : e);
}
saveBtn.disabled = false;
}
});
}
app.modules.zddcform = {
handles: handles,
render: render,
isDirty: isDirty,
currentNode: currentNode,
dispose: dispose
};
})(window.app);

View file

@ -100,7 +100,7 @@
function editorModules() { function editorModules() {
var m = window.app.modules; var m = window.app.modules;
return [m.markdown, m.yamledit].filter(Boolean); return [m.markdown, m.yamledit, m.zddcform].filter(Boolean);
} }
function disposeEditors() { function disposeEditors() {
@ -132,9 +132,6 @@
disposeEditors(); disposeEditors();
var container = document.getElementById('previewBody'); var container = document.getElementById('previewBody');
if (container) container.innerHTML = ''; if (container) container.innerHTML = '';
toggleTargetNode = null;
var tb = document.getElementById('previewViewToggle');
if (tb) tb.classList.add('hidden');
} }
// Warn before a full page unload (reload / close / external nav) drops // Warn before a full page unload (reload / close / external nav) drops
@ -144,41 +141,6 @@
if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; } if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; }
}); });
// ── Rendered ⇄ Source toggle ─────────────────────────────────────────────
// Some types we can RENDER, not just edit (.html). Those show rendered by
// default (sandboxed — no scripts, no same-origin) with a toggle to the
// CodeMirror source view. Markdown has its own rendered/source toggle, so
// it's not here. Extend RENDERABLE to add more (svg already previews as an
// image; csv could render as a table later).
var RENDERABLE = { html: 1, htm: 1 };
function isRenderable(ext) { return !!RENDERABLE[(ext || '').toLowerCase()]; }
function nodeKey(node) { return (node && (node.url || node.name)) || ''; }
// Per-node mode; 'rendered' is the default. Only the node the user last
// toggled is remembered, so switching files resets to rendered.
var viewToggle = { key: null, mode: 'rendered' };
var toggleTargetNode = null;
function effectiveMode(node) {
return (viewToggle.key && viewToggle.key === nodeKey(node)) ? viewToggle.mode : 'rendered';
}
function ensureViewToggleBtn() {
var btn = document.getElementById('previewViewToggle');
if (btn) return btn;
var popout = document.getElementById('previewPopout');
if (!popout || !popout.parentNode) return null;
btn = document.createElement('button');
btn.id = 'previewViewToggle';
btn.type = 'button';
btn.className = 'btn btn-sm btn-secondary hidden';
popout.parentNode.insertBefore(btn, popout);
btn.addEventListener('click', function () {
if (!toggleTargetNode) return;
var next = effectiveMode(toggleTargetNode) === 'rendered' ? 'source' : 'rendered';
viewToggle = { key: nodeKey(toggleTargetNode), mode: next };
renderInline(toggleTargetNode, { toggle: true });
});
return btn;
}
// ── Inline rendering ──────────────────────────────────────────────────── // ── Inline rendering ────────────────────────────────────────────────────
// Bumped on every renderInline entry; a render that loses the race // Bumped on every renderInline entry; a render that loses the race
@ -207,10 +169,9 @@
var dm = dirtyEditor(); var dm = dirtyEditor();
if (dm) { if (dm) {
var cur = dm.currentNode ? dm.currentNode() : null; var cur = dm.currentNode ? dm.currentNode() : null;
if (samePreviewNode(cur, node) && !opts.toggle) { if (samePreviewNode(cur, node)) {
// Re-selecting the file we're already editing — don't reload // Re-selecting the file we're already editing — don't reload
// and clobber the in-progress edits. (A deliberate view toggle // and clobber the in-progress edits.
// falls through to the discard prompt below.)
return; return;
} }
if (opts.auto) { if (opts.auto) {
@ -238,32 +199,6 @@
var ext = (node.ext || '').toLowerCase(); var ext = (node.ext || '').toLowerCase();
// Rendered ⇄ Source toggle button — shown only for renderable types.
var toggleBtn = ensureViewToggleBtn();
if (toggleBtn) {
if (isRenderable(ext)) {
toggleTargetNode = node;
toggleBtn.classList.remove('hidden');
toggleBtn.textContent = effectiveMode(node) === 'rendered' ? '⟨⟩ Source' : '◱ Preview';
} else {
toggleBtn.classList.add('hidden');
}
}
// Renderable types (.html) — show rendered by default, sandboxed for
// safety (no scripts, no same-origin). The toggle flips to source.
if (isRenderable(ext) && effectiveMode(node) === 'rendered') {
try {
var rinfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = '<iframe class="preview-iframe" sandbox src="'
+ escapeHtml(rinfo.url) + '"></iframe>';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Markdown plugin (if loaded) takes over for .md / .markdown. // Markdown plugin (if loaded) takes over for .md / .markdown.
if ((ext === 'md' || ext === 'markdown') && if ((ext === 'md' || ext === 'markdown') &&
window.app.modules.markdown && window.app.modules.markdown &&
@ -276,27 +211,39 @@
return; return;
} }
// CodeMirror editor: the general editor for editable text files that // .zddc form view: a schema-driven form (option fields editable,
// aren't markdown — yaml/.zddc (schema lint + completion + hover) plus // structure read-only) is the PRIMARY editor for .zddc files. It hands
// txt/csv/tsv/json/xml/html/css/js/… as a plaintext code editor. // off to the raw YAML editor on demand. Other YAML files skip it.
// Guided dialogs (Manage access, …) are the front door for the common var zddcForm = window.app.modules.zddcform;
// .zddc tasks; this is the full/raw edit surface. if (zddcForm && zddcForm.handles(node)) {
try {
await zddcForm.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) {
renderError(container, '.zddc form render failed: ' + (e.message || e));
}
return;
}
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
// CodeMirror 5 editor with js-yaml linting; .zddc files also
// get a schema-aware lint pass.
var yamlMod = window.app.modules.yamledit; var yamlMod = window.app.modules.yamledit;
if (yamlMod && yamlMod.handles(node)) { if (yamlMod && yamlMod.handles(node)) {
try { try {
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) { } catch (e) {
renderError(container, 'Editor failed: ' + (e.message || e)); renderError(container, 'YAML render failed: ' + (e.message || e));
} }
return; return;
} }
// PDF → iframe (HTML now routes to the editor above). // PDF / HTML → iframe.
if (ext === 'pdf') { if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
try { try {
var info = await getBlobUrl(node); var info = await getBlobUrl(node);
if (seq !== renderSeq) return; if (seq !== renderSeq) return;
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"></iframe>'; var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
} catch (e) { } catch (e) {
renderError(container, e.message || String(e)); renderError(container, e.message || String(e));
} }
@ -485,25 +432,6 @@
} }
async function renderInPopup(node) { async function renderInPopup(node) {
// Editor-type files (markdown, yaml/.zddc, code text) can't be hosted
// in the lightweight popup window — they need the bundled editor. Pop
// them out as the FULL browse app deep-linked to the file, which loads
// the real editor in a new window. Server mode only; HTML keeps its
// rendered popup. Falls through to the lightweight popup otherwise.
var pext = (node.ext || '').toLowerCase();
var ym = window.app.modules.yamledit;
var isEditorType = pext === 'md' || pext === 'markdown'
|| (ym && ym.handles && ym.handles(node) && pext !== 'html' && pext !== 'htm');
if (isEditorType && window.app.state.source === 'server' && node.url) {
var slash = node.url.lastIndexOf('/');
var pdir = slash >= 0 ? node.url.slice(0, slash + 1) : '/';
var pbase = slash >= 0 ? node.url.slice(slash + 1) : node.url;
var pp = new URLSearchParams();
try { pp.set('file', decodeURIComponent(pbase)); } catch (_e) { pp.set('file', pbase); }
if (window.app.state.showHidden) pp.set('hidden', '1');
window.open(pdir + '?' + pp.toString(), '_blank', 'noopener');
return;
}
var info; var info;
try { try {
info = await getBlobUrl(node); info = await getBlobUrl(node);

View file

@ -1,275 +0,0 @@
// yaml-complete.js — deterministic, schema-driven completion + hover docs for
// the browse YAML editors (markdown front matter + .zddc). NO heuristics, no
// AI: every candidate and doc string comes from a PROVIDER backed by the
// converter's field list or the .zddc JSON Schema.
//
// A provider answers three questions about a position, identified by its key
// PATH (the array of parent keys):
// keysAt(path) → [{name, hint, values}] valid child keys here
// valuesFor(path, key) → [string] | null enum/boolean values
// describe(path, key) → string | null doc text (for hover)
// The CodeMirror plumbing (indent→path, sibling scan, show-hint, hover) is
// shared; only the provider differs between the flat front matter and the
// nested .zddc schema. Requires CodeMirror 5 + the show-hint add-on.
(function () {
'use strict';
if (!window.app) window.app = {};
if (!window.app.modules) window.app.modules = {};
function indentOf(line) { var m = line.match(/^(\s*)/); return m ? m[1].length : 0; }
function isBlankOrComment(line) { return /^\s*$/.test(line) || /^\s*#/.test(line); }
function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
// Parent key-path for a line, derived from YAML indentation: walk upward
// collecting each "key:" line at a strictly smaller indent.
function pathAt(cm, lineNo) {
var path = [];
var target = indentOf(cm.getLine(lineNo));
for (var ln = lineNo - 1; ln >= 0 && target > 0; ln--) {
var line = cm.getLine(ln);
if (isBlankOrComment(line)) continue;
var ind = indentOf(line);
if (ind < target) {
var m = line.match(/^\s*([\w.\-]+)\s*:/);
if (m) { path.unshift(m[1]); target = ind; }
}
}
return path;
}
// Sibling keys already present at the same indent within this block, so we
// don't re-suggest a key the author already wrote.
function presentSiblings(cm, lineNo, indent) {
var present = {};
[-1, 1].forEach(function (dir) {
for (var ln = lineNo + dir; ln >= 0 && ln < cm.lineCount(); ln += dir) {
var line = cm.getLine(ln);
if (isBlankOrComment(line)) continue;
var ind = indentOf(line);
if (ind < indent) break; // left the block
if (ind === indent) {
var m = line.match(/^\s*([\w.\-]+)\s*:/);
if (m) present[m[1]] = true;
}
}
});
return present;
}
function keyItem(k, hinter) {
var item = {
text: k.name + ': ',
displayText: k.name + (k.hint ? ' — ' + truncate(k.hint, 64) : '')
};
// An enum key inserts "key: " then immediately opens its value menu.
if (k.values && k.values.length) {
item.hint = function (cmi, data, comp) {
cmi.replaceRange(comp.text, data.from, data.to);
setTimeout(function () { cmi.showHint({ hint: hinter, completeSingle: false }); }, 0);
};
}
return item;
}
function makeHinter(provider) {
function hinter(cm) {
var CM = window.CodeMirror;
if (!CM) return null;
var cur = cm.getCursor();
var before = cm.getLine(cur.line).slice(0, cur.ch);
var colon = before.indexOf(':');
var path = pathAt(cm, cur.line);
if (colon === -1) {
// KEY context.
var m = before.match(/^(\s*)([\w.\-]*)$/);
if (!m) return null;
var indent = m[1], typed = m[2];
var keys = provider.keysAt(path) || [];
if (!keys.length) return null;
var present = presentSiblings(cm, cur.line, indent.length);
var list = [];
keys.forEach(function (k) {
if (present[k.name]) return;
if (typed && k.name.indexOf(typed) !== 0) return;
list.push(keyItem(k, hinter));
});
if (!list.length) return null;
return { list: list, from: CM.Pos(cur.line, indent.length), to: cur };
}
// VALUE context.
var key = before.slice(0, colon).trim();
var values = provider.valuesFor(path, key) || [];
if (!values.length) return null;
var rest = before.slice(colon + 1);
var valTyped = rest.replace(/^\s*/, '');
var valStart = colon + 1 + (rest.length - valTyped.length);
var vlist = [];
values.forEach(function (v) {
if (valTyped && v.indexOf(valTyped) !== 0) return;
vlist.push({ text: v, displayText: v });
});
if (!vlist.length) return null;
return { list: vlist, from: CM.Pos(cur.line, valStart), to: cur };
}
return hinter;
}
// Lightweight hover docs: hover a "key:" → its schema description. No
// add-on — a debounced mousemove over the editor + a fixed-position tip.
function attachHover(cm, provider) {
var tip = null, timer = null;
function hide() { if (tip && tip.parentNode) tip.parentNode.removeChild(tip); tip = null; }
function show(text, x, y) {
hide();
tip = document.createElement('div');
tip.className = 'cm-doc-tip';
tip.textContent = text;
document.body.appendChild(tip);
tip.style.left = x + 'px';
tip.style.top = (y + 16) + 'px';
}
var wrap = cm.getWrapperElement();
wrap.addEventListener('mousemove', function (e) {
if (timer) clearTimeout(timer);
var ex = e.clientX, ey = e.clientY;
timer = setTimeout(function () {
if (!wrap.isConnected) { hide(); return; }
try {
var pos = cm.coordsChar({ left: ex, top: ey }, 'window');
var line = cm.getLine(pos.line) || '';
var m = line.match(/^\s*([\w.\-]+)\s*:/);
if (!m) { hide(); return; }
var keyStart = line.indexOf(m[1]);
if (pos.ch < keyStart || pos.ch > keyStart + m[1].length) { hide(); return; }
var doc = provider.describe(pathAt(cm, pos.line), m[1]);
if (doc) show(doc, ex, ey); else hide();
} catch (_e) { hide(); }
}, 350);
});
wrap.addEventListener('mouseleave', function () { if (timer) clearTimeout(timer); hide(); });
cm.on('cursorActivity', hide);
cm.on('changes', hide);
}
// Wire completion (Ctrl-Space + auto-trigger as you type) and hover docs
// onto a CodeMirror instance. opts.readOnly skips the typing trigger;
// opts.hover:false skips hover.
function attach(cm, provider, opts) {
opts = opts || {};
var hinter = makeHinter(provider);
var keys = Object.assign({}, cm.getOption('extraKeys') || {}, {
'Ctrl-Space': function (c) { c.showHint({ hint: hinter, completeSingle: false }); }
});
cm.setOption('extraKeys', keys);
if (!opts.readOnly) {
cm.on('inputRead', function (c, change) {
if (!change.text || change.text.length !== 1) return; // skip paste/delete
if (!/[\w.\-]/.test(change.text[0])) return;
c.showHint({ hint: hinter, completeSingle: false });
});
}
if (opts.hover !== false) attachHover(cm, provider);
return hinter;
}
// ── Providers ───────────────────────────────────────────────────────────
// Flat: a fixed field list [{name, hint, values}] at the root, nothing
// nested (front matter). opts.exclude = names never suggested.
function flatProvider(getFields, opts) {
opts = opts || {};
var exclude = {};
(opts.exclude || []).forEach(function (n) { exclude[n] = true; });
function fields() { return getFields() || []; }
function find(name) {
var fs = fields();
for (var i = 0; i < fs.length; i++) if (fs[i].name === name) return fs[i];
return null;
}
return {
keysAt: function (path) {
if (path.length) return [];
return fields().filter(function (f) { return !exclude[f.name]; })
.map(function (f) { return { name: f.name, hint: f.hint, values: f.values }; });
},
valuesFor: function (path, key) {
if (path.length) return null;
var f = find(key); return f ? f.values : null;
},
describe: function (path, key) {
if (path.length) return null;
var f = find(key); return f ? f.hint : null;
}
};
}
// Schema: a JSON Schema (draft-2020-12 subset). Resolves nested key-paths
// through properties / additionalProperties / patternProperties and the
// recursive $ref:"#" .zddc uses for paths:. Keys = object property names;
// values = enum / boolean.
function schemaProvider(getSchema) {
function root() { return getSchema(); }
function deref(node) { return (node && node.$ref === '#') ? root() : node; }
function stepInto(node, seg) {
node = deref(node);
if (!node || node.type !== 'object') return null;
if (node.properties && node.properties[seg]) return node.properties[seg];
if (node.additionalProperties && typeof node.additionalProperties === 'object') {
return node.additionalProperties;
}
if (node.patternProperties) {
for (var p in node.patternProperties) {
if (Object.prototype.hasOwnProperty.call(node.patternProperties, p)) {
return node.patternProperties[p];
}
}
}
return null;
}
function containerAt(path) {
var node = deref(root());
for (var i = 0; i < path.length; i++) {
node = stepInto(node, path[i]);
if (!node) return null;
node = deref(node);
}
return node;
}
function valuesOf(node) {
node = deref(node);
if (!node) return null;
if (Array.isArray(node.enum)) return node.enum.map(String);
if (node.type === 'boolean') return ['true', 'false'];
return null;
}
function keyNodeAt(path, key) {
var c = containerAt(path);
if (!c || !c.properties) return null;
return c.properties[key] || null;
}
return {
keysAt: function (path) {
var c = containerAt(path);
if (!c || c.type !== 'object' || !c.properties) return [];
return Object.keys(c.properties).map(function (name) {
var n = deref(c.properties[name]) || {};
return { name: name, hint: n.description, values: valuesOf(n) };
});
},
valuesFor: function (path, key) { return valuesOf(keyNodeAt(path, key)); },
describe: function (path, key) {
var n = deref(keyNodeAt(path, key));
return n ? n.description : null;
}
};
}
window.app.modules.yamlComplete = {
attach: attach,
makeHinter: makeHinter,
flatProvider: flatProvider,
schemaProvider: schemaProvider
};
})();

View file

@ -684,12 +684,9 @@
var p = encodeURIComponent(project); var p = encodeURIComponent(project);
var stages = [ var stages = [
{ id: 'stageArchive', href: '/' + p + '/archive' }, { id: 'stageArchive', href: '/' + p + '/archive' },
// working/staging/reviewing get a trailing slash so the user lands { id: 'stageWorking', href: '/' + p + '/working' },
// INSIDE the folder (the dir_tool browse listing of parties), { id: 'stageStaging', href: '/' + p + '/staging' },
// not on the browse tool scoped at the project level. { id: 'stageReviewing', href: '/' + p + '/reviewing' },
{ id: 'stageWorking', href: '/' + p + '/working/' },
{ id: 'stageStaging', href: '/' + p + '/staging/' },
{ id: 'stageReviewing', href: '/' + p + '/reviewing/' },
]; ];
for (var i = 0; i < stages.length; i++) { for (var i = 0; i < stages.length; i++) {
var a = document.getElementById(stages[i].id); var a = document.getElementById(stages[i].id);

View file

@ -1 +0,0 @@
.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff}

File diff suppressed because one or more lines are too long

View file

@ -2665,7 +2665,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

File diff suppressed because one or more lines are too long

View file

@ -1876,7 +1876,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -1619,7 +1619,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -3864,12 +3864,9 @@ body {
var p = encodeURIComponent(project); var p = encodeURIComponent(project);
var stages = [ var stages = [
{ id: 'stageArchive', href: '/' + p + '/archive' }, { id: 'stageArchive', href: '/' + p + '/archive' },
// working/staging/reviewing get a trailing slash so the user lands { id: 'stageWorking', href: '/' + p + '/working' },
// INSIDE the folder (the dir_tool browse listing of parties), { id: 'stageStaging', href: '/' + p + '/staging' },
// not on the browse tool scoped at the project level. { id: 'stageReviewing', href: '/' + p + '/reviewing' },
{ id: 'stageWorking', href: '/' + p + '/working/' },
{ id: 'stageStaging', href: '/' + p + '/staging/' },
{ id: 'stageReviewing', href: '/' + p + '/reviewing/' },
]; ];
for (var i = 0; i < stages.length; i++) { for (var i = 0; i < stages.length; i++) {
var a = document.getElementById(stages[i].id); var a = document.getElementById(stages[i].id);

View file

@ -2718,7 +2718,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7 archive=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
transmittal=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7 transmittal=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
classifier=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 classifier=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
landing=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 landing=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
form=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 form=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
tables=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 tables=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
browse=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7 browse=v0.0.27-beta · 2026-06-08 13:10:36 · 48b8199

View file

@ -65,9 +65,6 @@ type Metadata struct {
type FrontMatterField struct { type FrontMatterField struct {
Name string `json:"name"` Name string `json:"name"`
Hint string `json:"hint"` Hint string `json:"hint"`
// Values is the closed set of valid values for this key (an enum), used
// by the editor for value completion. Empty/nil = free-text.
Values []string `json:"values,omitempty"`
} }
// RecognizedFrontMatter is the single source of truth for the front-matter keys // RecognizedFrontMatter is the single source of truth for the front-matter keys
@ -79,20 +76,18 @@ type FrontMatterField struct {
// user most needs told about. // user most needs told about.
func RecognizedFrontMatter() []FrontMatterField { func RecognizedFrontMatter() []FrontMatterField {
return []FrontMatterField{ return []FrontMatterField{
// doctype enum tracks the template set (internal/convert/templates/ {"doctype", "report | letter | specification"},
// *.html, sans the _-prefixed partials). {"numbering", "true to number headings (default false)"},
{"doctype", "report | letter | specification", []string{"report", "letter", "specification"}}, {"title", "mirrors the filename — rename the file to change it"},
{"numbering", "true to number headings (default false)", []string{"true", "false"}}, {"tracking_number", "mirrors the filename — rename the file to change it"},
{"title", "mirrors the filename — rename the file to change it", nil}, {"revision", "mirrors the filename — rename the file to change it"},
{"tracking_number", "mirrors the filename — rename the file to change it", nil}, {"status", "mirrors the filename — rename the file to change it"},
{"revision", "mirrors the filename — rename the file to change it", nil}, {"date", "document date (free text)"},
{"status", "mirrors the filename — rename the file to change it", nil}, {"custom_header", "extra line shown in the document header"},
{"date", "document date (free text)", nil}, {"client", "overrides the .zddc convert: cascade"},
{"custom_header", "extra line shown in the document header", nil}, {"project", "overrides the .zddc convert: cascade"},
{"client", "overrides the .zddc convert: cascade", nil}, {"project_number", "overrides the .zddc convert: cascade"},
{"project", "overrides the .zddc convert: cascade", nil}, {"contractor", "overrides the .zddc convert: cascade"},
{"project_number", "overrides the .zddc convert: cascade", nil},
{"contractor", "overrides the .zddc convert: cascade", nil},
} }
} }

View file

@ -1648,7 +1648,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">