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)
js_raw=$(mktemp)
js_temp=$(mktemp)
# Generated schema lives under dist/ (gitignored); concat_files resolves paths
# relative to $root_dir, so we pass the relative form.
schema_rel="dist/.zddc-schema.gen.js"
schema_js="$root_dir/$schema_rel"
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$schema_js"; }
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
trap cleanup EXIT
# Bake the .zddc JSON Schema into the bundle so the lint + completion + hover
# all share ONE grammar (no hand-kept key list to drift from the Go structs)
# AND work offline (file://), where /.api/zddc-schema is unreachable. This is
# the exact file the server serves at that endpoint.
schema_src="$root_dir/../zddc/internal/zddc/zddc.schema.json"
ensure_exists "$schema_src"
{ printf 'window.__ZDDC_SCHEMA__ = '; cat "$schema_src"; printf ';\n'; } > "$schema_js"
# CSS files: shared base first, then browse-specific. Toast UI's CSS
# is bundled because the markdown plugin uses Toast UI inside the
# preview pane (.md files render as a full editor).
@ -39,7 +27,6 @@ concat_files \
"../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \
"../shared/vendor/codemirror-yaml.min.css" \
"../shared/vendor/codemirror-show-hint.min.css" \
"../shared/context-menu.css" \
"../shared/elevation.css" \
"../shared/profile-menu.css" \
@ -47,7 +34,6 @@ concat_files \
"css/tree.css" \
"css/preview-yaml.css" \
"css/history.css" \
"css/manage-access.css" \
> "$css_temp"
# JS files: shared canonical helpers, then browse modules.
@ -62,7 +48,6 @@ concat_files \
"../shared/vendor/utif.min.js" \
"../shared/vendor/js-yaml.min.js" \
"../shared/vendor/codemirror-yaml.min.js" \
"../shared/vendor/codemirror-show-hint.min.js" \
"../shared/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \
"../shared/zddc-filter.js" \
@ -80,10 +65,7 @@ concat_files \
"../shared/icons.js" \
"../shared/zddc-source.js" \
"js/init.js" \
"$schema_rel" \
"js/util.js" \
"js/yaml-complete.js" \
"js/manage-access.js" \
"js/conflict.js" \
"js/menu-model.js" \
"js/loader.js" \
@ -91,6 +73,7 @@ concat_files \
"js/preview.js" \
"js/preview-markdown.js" \
"js/preview-yaml.js" \
"js/preview-zddc-form.js" \
"js/hovercard.js" \
"js/grid.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;
}
/* 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
`height: 300px` by default we override to 100% so it grows with
the preview pane. */

View file

@ -249,7 +249,6 @@ body {
content's natural size (which clips the
YAML editor's bottom when there are many
lines, even with the editor's own scroll) */
min-width: 0;
overflow: auto;
display: flex;
flex-direction: column;
@ -257,16 +256,10 @@ body {
}
/* The body's children fill the available space. Plugins inject
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. */
different content here img, iframe, pre, custom markdown editor. */
.preview-pane__body > * {
flex: 1;
min-height: 0;
min-width: 0;
}
.preview-empty {
@ -903,55 +896,39 @@ body {
/* ── Front matter editor ────────────────────────────────────────────────── */
.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;
display: block;
overflow: hidden;
}
/* Recognised-keys caption under the header (tooltip carries the full list). */
.md-fm__hint {
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 {
.md-fm__textarea {
width: 100%;
height: 100%;
}
.md-fm__editor .CodeMirror {
box-sizing: border-box;
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-size: 0.8rem;
line-height: 1.45;
background: transparent;
color: var(--text);
resize: none;
outline: none;
white-space: pre;
overflow: auto;
tab-size: 2;
}
.md-fm__editor .CodeMirror-gutters {
background: var(--bg-secondary);
border-right: 1px solid var(--border);
.md-fm__textarea::placeholder {
color: var(--text-muted);
font-style: italic;
}
/* Schema-completion dropdown (show-hint add-on) theme it to the app
palette so it reads in dark mode; show-hint.css ships light-only. */
.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);
.md-fm__textarea:focus {
background: var(--surface-2, rgba(0, 0, 0, 0.025));
}
.CodeMirror-hint {
color: var(--text, #222);
padding: 2px 8px;
}
li.CodeMirror-hint-active {
background: var(--primary, #2868c8);
color: #fff;
.md-fm__textarea[readonly] {
color: var(--text-muted);
cursor: not-allowed;
}
/* 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 ──
{
// Guided "who can do what here" dialog — the front door for access.
// HIDDEN unless the user can administer here (admin verb 'a', or
// subtree/site admin).
// HIDDEN unless the user can actually edit access rules here
// (admin verb 'a', or subtree/site admin) — not shown greyed.
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
label: 'Manage access…',
label: 'Edit access rules…',
appliesTo: function (ctx) {
if (!isServer()) return false; // server-only tier
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;
},
action: function (ctx) { openZddcEditor(ctx.dir); }

View file

@ -82,38 +82,30 @@
// empty / unavailable. The promise dedupes concurrent fetches.
var fmPlaceholder = 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
// server's recognised front-matter fields, in server mode only. Async +
// best-effort: a failed fetch leaves the caption hidden, never an error.
// (Replaces the old textarea placeholder — CodeMirror 5 has no built-in
// placeholder without an unvendored add-on. Arbitrary keys stay free.)
function applyFrontMatterHint(el) {
// applyFrontMatterPlaceholder sets the textarea placeholder to the server's
// recognised-field hint, in server mode only. Async + best-effort: a failed
// fetch leaves the pane blank (no placeholder), never an error.
function applyFrontMatterPlaceholder(textarea) {
var st = window.app && window.app.state;
if (!st || st.source !== 'server') return;
function paint() {
if (!el.isConnected) return; // user switched files before resolve
if (!fmPlaceholder) { el.style.display = 'none'; return; }
el.textContent = 'ⓘ Recognised front-matter keys (hover) — any other key is allowed';
el.title = fmPlaceholder;
el.style.display = '';
if (fmPlaceholder !== null) {
textarea.placeholder = fmPlaceholder;
return;
}
if (fmPlaceholder !== null) { paint(); return; }
if (!fmPlaceholderPromise) {
fmPlaceholderPromise = fetch('/.api/frontmatter', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
fmPlaceholder = (j && j.placeholder) || '';
fmFields = (j && j.fields) || [];
})
.catch(function () { fmPlaceholder = ''; fmFields = []; });
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
.catch(function () { fmPlaceholder = ''; });
}
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:
@ -435,19 +427,22 @@
fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body';
// CodeMirror YAML editor host — mounted with the front-matter value
// once it's computed (sync-on-open) below. Same editor family as the
// .zddc previewer: syntax highlighting, line numbers, lint gutter.
var fmEditorHost = document.createElement('div');
fmEditorHost.className = 'md-fm__editor';
fmBody.appendChild(fmEditorHost);
// Recognised-keys hint (server mode): a greyed caption under the header
// whose tooltip carries the full "key: # hint" template from
// /.api/frontmatter. Replaces the old textarea placeholder.
var fmHint = document.createElement('div');
fmHint.className = 'md-fm__hint';
fmHint.style.display = 'none';
applyFrontMatterHint(fmHint);
var fmTextarea = document.createElement('textarea');
fmTextarea.className = 'md-fm__textarea';
fmTextarea.spellcheck = false;
fmTextarea.autocapitalize = 'off';
fmTextarea.autocomplete = 'off';
// Placeholder: in server mode, hint the recognised front-matter keys
// (doctype, numbering, …) as greyed text so authors can discover them.
// It's placeholder-only — inserts nothing, vanishes on the first
// keystroke — so arbitrary keys stay free and a file with no front
// matter still renders as a genuinely empty pane. The text is fetched
// from the server (/.api/frontmatter), the single source of truth, so
// it never drifts from what the converter honours. file:// mode shows
// no placeholder (conversion is server-only).
fmTextarea.placeholder = '';
applyFrontMatterPlaceholder(fmTextarea);
fmBody.appendChild(fmTextarea);
// Rename cue: shown when the author edits an identity field
// (tracking_number / revision / status / title) away from the
// filename. The filename owns identity, so the cue offers an explicit
@ -455,16 +450,12 @@
// discarding the value. Populated by renderIdentityCue().
var fmWarn = document.createElement('div');
fmWarn.className = 'md-fm__warn';
// Visibility is controlled via style.display (toggled in
// 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.hidden = true;
fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid '
+ '#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;'
+ 'display:none;';
+ '0.78rem;line-height:1.5;display:flex;flex-wrap:wrap;align-items:'
+ 'center;gap:6px;';
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmHint);
fmSection.appendChild(fmWarn);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
@ -609,59 +600,21 @@
// even if we tweak whitespace in the YAML lines.
var initialParsed = parseFrontMatter(text);
var bodyText = initialParsed.body;
// On open, RECONCILE existing front-matter identity keys with the
// filename (the single source of truth) — but never ADD them. A blank
// or new file opens blank (we don't inject a title etc.); a file whose
// 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
// On open, mirror the filename-derived identity into the front matter
// (the filename is the single source of truth; this keeps the values
// baked in for the converter). No-op for non-ZDDC filenames. The dirty
// baseline stays the ON-DISK state, so a correction opens the buffer
// dirty and a save persists it.
var onDiskFM = stringifyFrontMatter(initialParsed.data);
var fid = filenameIdentity(node.name);
if (fid) {
for (var ik in fid) {
if (Object.prototype.hasOwnProperty.call(fid, ik)
&& Object.prototype.hasOwnProperty.call(initialParsed.data, ik)) {
initialParsed.data[ik] = fid[ik];
if (Object.prototype.hasOwnProperty.call(fid, ik)) initialParsed.data[ik] = fid[ik];
}
}
}
var syncedFM = stringifyFrontMatter(initialParsed.data);
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
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 —
// arrow-key nav can continue through markdown files without
// diverting into the editor. The user clicks into the editor
@ -710,7 +663,7 @@
node: node,
hash: initialHash,
tocEl: tocBody,
fmEl: fmCM,
fmEl: fmTextarea,
ac: ac,
// Server version token captured at load — sent as If-Match on
// save and refreshed from each successful PUT's response ETag.
@ -722,7 +675,7 @@
if (!writableMode) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
// fmCM was created with readOnly:!writableMode — nothing more here.
fmTextarea.readOnly = true;
}
renderToc(tocBody, bodyText, editor);
@ -832,7 +785,7 @@
var onChange = debounce(async function () {
if (currentInstance !== instance) return;
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmCM.getValue(), body));
var h = await hashContent(assembleContent(fmTextarea.value, body));
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
renderToc(tocBody, body, editor);
@ -869,8 +822,8 @@
function renderIdentityCue() {
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
var fid = filenameIdentity(node.name);
if (!fid || !canSave(node)) { fmWarn.style.display = 'none'; return; }
var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
if (!fid || !canSave(node)) { fmWarn.hidden = true; return; }
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
var edits = [];
IDENTITY_FIELDS.forEach(function (f) {
if (!(f.fm in data)) return;
@ -878,7 +831,7 @@
var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim();
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');
msg.textContent = '✎ Identity comes from the filename. You changed '
+ edits.join(', ') + '. ';
@ -893,36 +846,7 @@
btn.addEventListener('click', function () { renameToMatch(newName); });
fmWarn.appendChild(btn);
}
// Cancel: discard the identity edits, restoring the filename values.
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();
fmWarn.hidden = false;
}
// Rename action: persist the current buffer (so body edits aren't
@ -933,26 +857,11 @@
async function renameToMatch(newName) {
var up = window.app.modules.upload;
if (!up || !up.renameNode || !newName) return;
// 1. Persist the current buffer first so body edits survive the
// rename. Force the write (no If-Match) — the user deliberately
// initiated this rename, so we commit their version rather than
// interrupting with the conflict-resolution modal (which save()
// raises on a 412). The identity edit that triggered the rename
// 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;
}
// 1. Save first so body/FM edits survive the rename. A failed save
// (conflict, ACL) leaves the buffer dirty — abort the rename.
if (instance.dirty) {
await save();
if (currentInstance !== instance || instance.dirty) return;
}
// 2. Rename on disk.
try {
@ -993,21 +902,17 @@
var onFmChange = debounce(async function () {
if (currentInstance !== instance) return;
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmCM.getValue(), body));
var h = await hashContent(assembleContent(fmTextarea.value, body));
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
renderIdentityCue();
}, 250);
fmCM.on('change', onFmChange);
fmTextarea.addEventListener('input', onFmChange);
renderIdentityCue(); // initial state on load (clean after sync-on-open)
// 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
// change is otherwise silent (the values just match the filename now).
if (writableMode && fmCM.getValue() !== onDiskFM) {
markDirty(true);
statusEl.textContent = 'Front matter synced to filename — review and save';
}
// a save bakes the filename-derived identity in.
if (writableMode && fmTextarea.value !== onDiskFM) markDirty(true);
// ── Save ───────────────────────────────────────────────────────────
// Mark a successful write: adopt the new server ETag (so the next
@ -1071,7 +976,7 @@
async function save() {
if (currentInstance !== instance) return;
if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
try {
statusEl.textContent = 'Saving…';
var res = await saveContent(node, content, {

View file

@ -43,26 +43,6 @@
return ext === 'yaml' || ext === 'yml';
}
// The CodeMirror editor is the general editor for editable TEXT files that
// aren't markdown (markdown has its own editor). Syntax highlighting is
// YAML-only — that's the one CM mode in the vendored bundle — so every
// other type opens as a plaintext editor (still line numbers, find,
// selection, save). svg/json-as-image etc. stay with their preview
// renderers; this set is deliberately the "edit the source" types.
var CODE_EXTS = {
yaml: 1, yml: 1, txt: 1, text: 1, csv: 1, tsv: 1, tab: 1,
json: 1, xml: 1, html: 1, htm: 1, css: 1, js: 1, mjs: 1,
log: 1, ini: 1, conf: 1, cfg: 1, toml: 1, env: 1,
sh: 1, bash: 1, properties: 1
};
function isCodeFile(node) {
if (!node || node.isDir || node.isZip) return false;
if (isYamlFile(node)) return true;
return !!CODE_EXTS[(node.ext || '').toLowerCase()];
}
// CodeMirror mode by extension — only yaml is vendored; others plaintext.
function codeMode(node) { return isYamlFile(node) ? 'yaml' : null; }
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
function saveContent(node, content, opts) {
@ -110,10 +90,49 @@
// any level surface as warnings — typos like `defaul_tool` are
// common and the cascade silently ignores them.
// The valid keys, types, enums and nesting are NOT hand-listed here any
// more — they come from the baked .zddc JSON Schema (window.__ZDDC_SCHEMA__,
// the same grammar the server serves at /.api/zddc-schema and that drives
// completion + hover). One source, no drift. See validateZddcSchema below.
var ALLOWED_TOOLS = {
archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
tables: 1, form: 1
};
var TOP_KEYS = {
title: 'string',
acl: 'acl',
admins: 'string[]',
roles: 'rolemap',
available_tools: 'tools[]',
default_tool: 'tool',
dir_tool: 'tool',
auto_own: 'bool',
auto_own_fenced: 'bool',
virtual: 'bool',
drop_target: 'bool',
worm: 'string[]',
paths: 'pathmap',
display: 'stringmap',
tables: 'stringmap',
views: 'viewmap',
convert: 'convert',
created_by: 'string',
inherit: 'bool',
// Keys the Go decoder (zddc/internal/zddc/file.go) accepts that the
// lint was missing — flagged valid configs as "unknown key".
party_source: 'string',
history: 'bool',
history_globs: 'string[]',
records: 'object',
auto_own_roles: 'string[]',
received_path: 'string',
planned_response_date: 'string',
planned_review_date: 'string',
field_codes: 'object'
};
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
allow: 'string[]', deny: 'string[]' };
var ROLE_KEYS = { members: 'string[]', reset: 'bool' };
var CONVERT_KEYS = { client: 'string', project: 'string',
contractor: 'string', project_number: 'string' };
function typeOf(v) {
if (v === null || v === undefined) return 'null';
@ -121,87 +140,168 @@
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
}
// The .zddc JSON Schema, baked into the bundle at build time
// (window.__ZDDC_SCHEMA__ — the same file the server serves at
// /.api/zddc-schema). Single source for lint, completion and hover; works
// offline. Synchronous, so the lint helper can use it directly.
function getZddcSchema() {
return (window.__ZDDC_SCHEMA__ && window.__ZDDC_SCHEMA__.properties)
? window.__ZDDC_SCHEMA__ : {};
}
// Validate a parsed .zddc document against the JSON Schema, producing
// { keyPath, severity, message } issues (mapped to source lines by
// findLine). Covers the draft-2020-12 subset .zddc uses: type, enum,
// properties, additionalProperties (false | schema), patternProperties,
// items, pattern, and the recursive $ref:"#" (paths:).
// Collect schema issues for a parsed .zddc document. Each issue is
// { keyPath: string[], message: string, severity: 'error' | 'warning' }.
// keyPath is used by findLine() to locate the offending source line.
function validateZddc(doc) {
var schema = getZddcSchema();
var issues = [];
if (!schema || !schema.properties) return issues; // schema unavailable
if (typeOf(doc) === 'null') return issues;
if (typeOf(doc) !== 'object') {
issues.push({ keyPath: [], severity: 'error',
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
return issues;
}
function deref(n) { return (n && n.$ref === '#') ? schema : n; }
function typeOk(t, want) {
if (Array.isArray(want)) {
for (var i = 0; i < want.length; i++) if (typeOk(t, want[i])) return true;
return false;
walkObject(doc, TOP_KEYS, [], issues);
return issues;
}
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) {
sch = deref(sch);
if (!sch) return;
var t = typeOf(value);
if (t === 'null') return; // empty value mid-edit — don't flag
if (sch.type && !typeOk(t, sch.type)) {
checkValue(obj[key], kind, here, issues);
}
}
function checkValue(val, kind, path, issues) {
var t = typeOf(val);
switch (kind) {
case 'string':
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'bool':
if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'string[]':
if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'tools[]':
if (t !== 'array' && t !== 'null') {
addTypeErr(path, kind, t, issues); return;
}
if (t === 'array') {
for (var i = 0; i < val.length; i++) {
if (typeOf(val[i]) !== 'string') {
issues.push({ keyPath: path, severity: 'error',
message: 'Expected ' + (Array.isArray(sch.type) ? sch.type.join('/') : sch.type)
+ ', got ' + t + '.' });
message: 'available_tools[' + i + '] must be a string.' });
} else if (!ALLOWED_TOOLS[val[i]]) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown tool "' + val[i]
+ '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
}
}
}
return;
case 'tool':
if (t === 'null') return;
if (t !== 'string') { addTypeErr(path, kind, t, issues); return; }
if (!ALLOWED_TOOLS[val]) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown tool "' + val + '". Known: '
+ Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
}
return;
case 'stringmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var k in val) {
if (!Object.prototype.hasOwnProperty.call(val, k)) continue;
if (typeOf(val[k]) !== 'string') {
issues.push({ keyPath: path.concat([k]), severity: 'error',
message: 'Value must be a string (got '
+ typeOf(val[k]) + ').' });
}
}
return;
case 'pathmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var seg in val) {
if (!Object.prototype.hasOwnProperty.call(val, seg)) continue;
if (seg.indexOf('/') !== -1) {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'Path keys must be a single segment — '
+ 'nest blocks instead of using "' + seg + '".' });
}
var v = val[seg];
if (typeOf(v) === 'null') continue;
if (typeOf(v) !== 'object') {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'paths.' + seg + ' must be a map of cascade rules.' });
continue;
}
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
}
return;
case 'viewmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var shape in val) {
if (!Object.prototype.hasOwnProperty.call(val, shape)) continue;
if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) {
issues.push({ keyPath: path.concat([shape]), severity: 'warning',
message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' });
}
var vv = val[shape];
if (typeOf(vv) !== 'object') {
issues.push({ keyPath: path.concat([shape]), severity: 'error',
message: 'views.' + shape + ' must be a map ({tool, config}).' });
continue;
}
if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) {
issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning',
message: 'views.' + shape + '.tool should be a known tool ('
+ Object.keys(ALLOWED_TOOLS).join(', ') + ').' });
}
if (vv.config !== undefined && typeOf(vv.config) !== 'string') {
issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error',
message: 'views.' + shape + '.config must be a filename string.' });
}
}
return;
case 'rolemap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var rn in val) {
if (!Object.prototype.hasOwnProperty.call(val, rn)) continue;
var rv = val[rn];
if (typeOf(rv) === 'null') continue;
if (typeOf(rv) !== 'object') {
issues.push({ keyPath: path.concat([rn]), severity: 'error',
message: 'roles.' + rn + ' must be a map ({members, reset}).' });
continue;
}
walkObject(rv, ROLE_KEYS, path.concat([rn]), issues);
}
return;
case 'acl':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, ACL_KEYS, path, issues);
return;
case 'convert':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, CONVERT_KEYS, path, issues);
return;
case 'object':
// Free-form map (records, field_codes) — the server accepts any
// nested shape, so we only check it's a mapping, not its keys.
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
return;
}
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',
message: 'Value "' + value + '" must match ' + sch.pattern + '.' });
}
if (t === 'object') {
var props = sch.properties || {};
for (var k in value) {
if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
var kp = path.concat([k]);
if (props[k]) { walk(value[k], props[k], kp); continue; }
var ap = sch.additionalProperties;
if (ap && typeof ap === 'object') { walk(value[k], ap, kp); continue; }
if (sch.patternProperties) {
var matched = null;
for (var p in sch.patternProperties) {
if (Object.prototype.hasOwnProperty.call(sch.patternProperties, p)
&& new RegExp(p).test(k)) { matched = sch.patternProperties[p]; break; }
}
if (matched) { walk(value[k], matched, kp); continue; }
}
if (ap === false) {
issues.push({ keyPath: kp, severity: 'warning',
message: 'Unknown key "' + k + '" — not in the .zddc schema; it will be ignored.' });
}
}
} else if (t === 'array' && sch.items) {
for (var i = 0; i < value.length; i++) {
walk(value[i], sch.items, path.concat([String(i)]));
}
}
}
walk(doc, schema, []);
return issues;
message: 'Expected ' + expected + ', got ' + got + '.' });
}
// Locate the source line for a key path. .zddc files are
@ -365,10 +465,8 @@
schemaTag.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
});
} else if (isYamlFile(node)) {
schemaTag.textContent = 'YAML';
} else {
schemaTag.textContent = (node.ext || 'text').toUpperCase();
schemaTag.textContent = 'YAML';
}
var dirtyEl = document.createElement('span');
@ -408,22 +506,16 @@
}
var writable = canSave(node);
var mode = codeMode(node);
// Lint (js-yaml + the .zddc schema) only applies to YAML; other text
// types are plaintext, so skip the lint gutter for them.
var yamlMode = mode === 'yaml';
var editor = window.CodeMirror(editorHost, {
value: text,
mode: mode,
mode: 'yaml',
lineNumbers: true,
tabSize: 2,
indentUnit: 2,
indentWithTabs: false,
lineWrapping: false,
gutters: yamlMode
? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers']
: ['CodeMirror-linenumbers'],
lint: yamlMode ? { hasGutters: true } : false,
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
lint: { hasGutters: true },
// autofocus:false keeps the keyboard caret in the browse
// tree pane so arrow-key nav can continue through yaml /
// .zddc files without diverting into the editor. User
@ -441,15 +533,8 @@
// Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer.
editor._zddcNode = node;
// Force an initial lint pass now that _zddcNode is set (YAML only).
if (yamlMode) editor.performLint();
// Schema completion + hover docs for .zddc files (the machine grammar
// drives keys, enum/boolean values, and nested paths via $ref:"#").
// Plain .yaml gets no schema (lint + highlighting only).
var yc = window.app.modules.yamlComplete;
if (yc && isZddcFile(node.name)) {
yc.attach(editor, yc.schemaProvider(getZddcSchema), { readOnly: !writable });
}
// Force an initial lint pass now that _zddcNode is set.
editor.performLint();
currentEditor = editor;
currentNodeRef = node;
currentDirty = false;
@ -571,7 +656,8 @@
}
function handles(node) {
return isCodeFile(node);
if (!node || node.isDir || node.isZip) return false;
return isYamlFile(node);
}
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() {
var m = window.app.modules;
return [m.markdown, m.yamledit].filter(Boolean);
return [m.markdown, m.yamledit, m.zddcform].filter(Boolean);
}
function disposeEditors() {
@ -132,9 +132,6 @@
disposeEditors();
var container = document.getElementById('previewBody');
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
@ -144,41 +141,6 @@
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 ────────────────────────────────────────────────────
// Bumped on every renderInline entry; a render that loses the race
@ -207,10 +169,9 @@
var dm = dirtyEditor();
if (dm) {
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
// and clobber the in-progress edits. (A deliberate view toggle
// falls through to the discard prompt below.)
// and clobber the in-progress edits.
return;
}
if (opts.auto) {
@ -238,32 +199,6 @@
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.
if ((ext === 'md' || ext === 'markdown') &&
window.app.modules.markdown &&
@ -276,27 +211,39 @@
return;
}
// CodeMirror editor: the general editor for editable text files that
// aren't markdown — yaml/.zddc (schema lint + completion + hover) plus
// txt/csv/tsv/json/xml/html/css/js/… as a plaintext code editor.
// Guided dialogs (Manage access, …) are the front door for the common
// .zddc tasks; this is the full/raw edit surface.
// .zddc form view: a schema-driven form (option fields editable,
// structure read-only) is the PRIMARY editor for .zddc files. It hands
// off to the raw YAML editor on demand. Other YAML files skip it.
var zddcForm = window.app.modules.zddcform;
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;
if (yamlMod && yamlMod.handles(node)) {
try {
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) {
renderError(container, 'Editor failed: ' + (e.message || e));
renderError(container, 'YAML render failed: ' + (e.message || e));
}
return;
}
// PDF → iframe (HTML now routes to the editor above).
if (ext === 'pdf') {
// PDF / HTML → iframe.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
try {
var info = await getBlobUrl(node);
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) {
renderError(container, e.message || String(e));
}
@ -485,25 +432,6 @@
}
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;
try {
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 stages = [
{ id: 'stageArchive', href: '/' + p + '/archive' },
// working/staging/reviewing get a trailing slash so the user lands
// INSIDE the folder (the dir_tool browse listing of parties),
// not on the browse tool scoped at the project level.
{ id: 'stageWorking', href: '/' + p + '/working/' },
{ id: 'stageStaging', href: '/' + p + '/staging/' },
{ 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++) {
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>
<div class="header-title-group">
<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>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</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>
<div class="header-title-group">
<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>
<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>

View file

@ -1619,7 +1619,7 @@ body {
</svg>
<div class="header-title-group">
<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 class="header-right">
@ -3864,12 +3864,9 @@ body {
var p = encodeURIComponent(project);
var stages = [
{ id: 'stageArchive', href: '/' + p + '/archive' },
// working/staging/reviewing get a trailing slash so the user lands
// INSIDE the folder (the dir_tool browse listing of parties),
// not on the browse tool scoped at the project level.
{ id: 'stageWorking', href: '/' + p + '/working/' },
{ id: 'stageStaging', href: '/' + p + '/staging/' },
{ 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++) {
var a = document.getElementById(stages[i].id);

View file

@ -2718,7 +2718,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<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>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- 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.
archive=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
transmittal=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
classifier=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
landing=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
form=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
tables=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
browse=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
archive=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
transmittal=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
classifier=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
landing=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
form=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
tables=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
browse=v0.0.27-beta · 2026-06-08 13:10:36 · 48b8199

View file

@ -65,9 +65,6 @@ type Metadata struct {
type FrontMatterField struct {
Name string `json:"name"`
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
@ -79,20 +76,18 @@ type FrontMatterField struct {
// user most needs told about.
func RecognizedFrontMatter() []FrontMatterField {
return []FrontMatterField{
// doctype enum tracks the template set (internal/convert/templates/
// *.html, sans the _-prefixed partials).
{"doctype", "report | letter | specification", []string{"report", "letter", "specification"}},
{"numbering", "true to number headings (default false)", []string{"true", "false"}},
{"title", "mirrors the filename — rename the file to change it", nil},
{"tracking_number", "mirrors the filename — rename the file to change it", nil},
{"revision", "mirrors the filename — rename the file to change it", nil},
{"status", "mirrors the filename — rename the file to change it", nil},
{"date", "document date (free text)", nil},
{"custom_header", "extra line shown in the document header", nil},
{"client", "overrides the .zddc convert: cascade", nil},
{"project", "overrides the .zddc convert: cascade", nil},
{"project_number", "overrides the .zddc convert: cascade", nil},
{"contractor", "overrides the .zddc convert: cascade", nil},
{"doctype", "report | letter | specification"},
{"numbering", "true to number headings (default false)"},
{"title", "mirrors the filename — rename the file to change it"},
{"tracking_number", "mirrors the filename — rename the file to change it"},
{"revision", "mirrors the filename — rename the file to change it"},
{"status", "mirrors the filename — rename the file to change it"},
{"date", "document date (free text)"},
{"custom_header", "extra line shown in the document header"},
{"client", "overrides the .zddc convert: cascade"},
{"project", "overrides the .zddc convert: cascade"},
{"project_number", "overrides the .zddc convert: cascade"},
{"contractor", "overrides the .zddc convert: cascade"},
}
}

View file

@ -1648,7 +1648,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<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 class="header-right">