mnfst-run 1.0.24 → 1.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/serve.mjs CHANGED
@@ -58,9 +58,9 @@ import { connect as tlsConnect } from 'tls';
58
58
  import {
59
59
  readFileSync, statSync, watch,
60
60
  existsSync, writeFileSync, unlinkSync,
61
- mkdirSync, readdirSync,
61
+ mkdirSync, readdirSync, renameSync, accessSync, chmodSync, realpathSync, constants as fsConstants,
62
62
  } from 'fs';
63
- import { join, extname, resolve, basename, sep } from 'path';
63
+ import { join, extname, resolve, basename, sep, dirname, relative, isAbsolute, posix } from 'path';
64
64
  import { exec } from 'child_process';
65
65
  import { tmpdir } from 'os';
66
66
  import { createHash } from 'crypto';
@@ -108,6 +108,8 @@ const LIVE_RELOAD_SCRIPT = `<script>
108
108
  var es = new EventSource('/__mnfst_sse__?tabId=' + encodeURIComponent(tabId));
109
109
  es.onmessage = function (e) {
110
110
  var d = JSON.parse(e.data);
111
+ var files = d.files || (d.rel ? [{ rel: d.rel, hash: d.hash }] : []);
112
+ files.forEach(function (f) { window.dispatchEvent(new CustomEvent('manifest:source-change', { detail: { file: f.rel, hash: f.hash } })); });
111
113
  if (d.type === 'css') {
112
114
  document.querySelectorAll('link[rel="stylesheet"]').forEach(function (l) {
113
115
  var base = l.href.split('?')[0];
@@ -116,7 +118,8 @@ const LIVE_RELOAD_SCRIPT = `<script>
116
118
  } else if (d.type === 'data') {
117
119
  window.dispatchEvent(new CustomEvent('manifest:dev-reload'));
118
120
  } else {
119
- location.reload();
121
+ // Cancelable: a page that wrote these files itself (and is already up to date) may keep its state
122
+ if (window.dispatchEvent(new CustomEvent('manifest:live-reload', { cancelable: true, detail: { files: files } }))) location.reload();
120
123
  }
121
124
  };
122
125
  // Don't close on error — let EventSource auto-reconnect (carries the same
@@ -582,26 +585,52 @@ function dropTab(tabId) {
582
585
  }
583
586
 
584
587
  // --- File watcher ---
585
- const IGNORE = /node_modules|\.git/;
588
+ // Source-writer temps (`<name>.mnfst-edit-<pid>-<time>.tmp`) never trigger a reload, and
589
+ // paths a failed commit touched (staging, rollback) stay quiet while it unwinds.
590
+ const EDIT_TEMP = /\.mnfst-edit-\d+-\d+\.tmp$/;
591
+ const IGNORE = /node_modules|\.git|\.mnfst-edit-\d+-\d+\.tmp$/;
592
+ const quietPaths = new Map(); // root-relative path → quiet until (ms)
593
+ const relKey = (p) => String(p).replace(/\\/g, '/').replace(/^\/+/, '');
594
+ const pendingChanges = new Set();
586
595
  try {
587
596
  watch(root, { recursive: true }, (_event, filename) => {
588
597
  if (!filename || IGNORE.test(filename)) return;
598
+ const until = quietPaths.get(relKey(filename));
599
+ if (until && until > Date.now()) return;
600
+ pendingChanges.add(relKey(filename));
589
601
  clearTimeout(debounce);
590
602
  debounce = setTimeout(() => {
591
- const ext = extname(filename).toLowerCase();
592
- if (ext === '.css') {
593
- broadcast({ type: 'css', file: '/' + filename.replace(/\\/g, '/') });
594
- } else if (['.csv', '.json', '.yaml', '.yml', '.md'].includes(ext)) {
595
- broadcast({ type: 'data' });
596
- } else {
597
- broadcast({ type: 'reload' });
603
+ const changed = [...pendingChanges]; pendingChanges.clear();
604
+ const reload = [];
605
+ for (const rel of changed) {
606
+ const ext = extname(rel).toLowerCase();
607
+ let hash = null; try { hash = fileHash(join(root, rel)); } catch { }
608
+ if (ext === '.css') broadcast({ type: 'css', file: '/' + rel, rel, hash });
609
+ else if (['.csv', '.json', '.yaml', '.yml', '.md'].includes(ext)) broadcast({ type: 'data', rel, hash });
610
+ else reload.push({ rel, hash });
598
611
  }
612
+ // One reload naming every file (with its hash), so a page can tell its own publish from other changes
613
+ if (reload.length) broadcast({ type: 'reload', files: reload });
599
614
  }, 60);
600
615
  });
601
616
  } catch {
602
617
  // fs.watch unavailable in this environment — live reload disabled
603
618
  }
604
619
 
620
+ // A crash between staging and renaming can leave temps behind: sweep them at startup.
621
+ function sweepEditTemps(dir, depth = 0) {
622
+ if (depth > 12) return;
623
+ let entries = [];
624
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
625
+ for (const e of entries) {
626
+ if (e.name === 'node_modules' || e.name === '.git') continue;
627
+ const p = join(dir, e.name);
628
+ if (e.isDirectory()) sweepEditTemps(p, depth + 1);
629
+ else if (EDIT_TEMP.test(e.name)) { try { unlinkSync(p); } catch { } }
630
+ }
631
+ }
632
+ if (EDIT_ENABLED) sweepEditTemps(root);
633
+
605
634
  // --- File serving ---
606
635
  function isFile(p) {
607
636
  try { return statSync(p).isFile(); } catch { return false; }
@@ -610,9 +639,121 @@ function isFile(p) {
610
639
  // Reorder a data file's rows/items to match `order` (array of ids). CSV (tabular,
611
640
  // header with an `id` column) or JSON (array of {id}). Ids not in `order` keep their
612
641
  // relative order at the end. Returns true if written. Used by /__edit/save (spike).
642
+ // Source writes go through an in-memory overlay during a save batch: every patch is
643
+ // prepared against it, and the files are written only if all of them succeed.
644
+ let VFS = null;
645
+ // Write a batch atomically: every target is resolved through symlinks to the real file,
646
+ // checked writable, staged as a temp file beside it (mode kept), then renamed over it;
647
+ // any failure restores the files already replaced and removes the temps. While the
648
+ // commit runs its paths are quiet for the watcher; after a rollback they stay quiet
649
+ // briefly, so a failed publish never live-reloads the page.
650
+ function commitFiles(files) {
651
+ const jobs = [...files].map(([file, content]) => {
652
+ let real = file;
653
+ try { real = realpathSync(file); } catch { try { real = join(realpathSync(dirname(file)), basename(file)); } catch { } }
654
+ return { file, real, content };
655
+ });
656
+ const keys = new Set();
657
+ jobs.forEach(j => { for (const p of [j.file, j.real]) { const r = relative(root, p); if (!r.startsWith('..')) keys.add(relKey(r)); } });
658
+ const quiet = (until) => keys.forEach(k => quietPaths.set(k, until));
659
+ quiet(Infinity);
660
+ try {
661
+ for (const j of jobs) { const why = writeRefusal(j.file, 'any'); if (why) throw new Error(`${relKey(relative(root, j.file))} refused: ${why}`); }
662
+ for (const j of jobs) {
663
+ try { accessSync(existsSync(j.real) ? j.real : dirname(j.real), fsConstants.W_OK); accessSync(dirname(j.real), fsConstants.W_OK); }
664
+ catch { throw new Error(`${basename(j.file)} is not writable`); }
665
+ }
666
+ const tag = `.mnfst-edit-${process.pid}-${Date.now()}.tmp`;
667
+ const cleanup = () => jobs.forEach(j => { if (j.tmp) try { unlinkSync(j.tmp); } catch { } });
668
+ try {
669
+ for (const j of jobs) {
670
+ j.tmp = j.real + tag;
671
+ writeFileSync(j.tmp, j.content);
672
+ if (existsSync(j.real)) { j.orig = readFileSync(j.real); try { chmodSync(j.tmp, statSync(j.real).mode); } catch { } }
673
+ }
674
+ } catch (e) { cleanup(); throw e; }
675
+ const done = [];
676
+ try {
677
+ for (const j of jobs) { renameSync(j.tmp, j.real); j.tmp = null; done.push(j); }
678
+ } catch (e) {
679
+ for (const j of done) { try { if (j.orig == null) unlinkSync(j.real); else writeFileSync(j.real, j.orig); } catch { } }
680
+ cleanup();
681
+ throw e;
682
+ }
683
+ keys.forEach(k => quietPaths.delete(k)); // success: the watcher reports the change as usual
684
+ } catch (e) {
685
+ quiet(Date.now() + 2000);
686
+ throw e;
687
+ }
688
+ }
689
+ let READS = null; // files a save batch read, for the base-version check
690
+ const vread = (file) => { if (READS) READS.add(file); return VFS && VFS.has(file) ? VFS.get(file) : readFileSync(file, 'utf8'); };
691
+ // What the source writer may touch. Every target must have its kind's extension,
692
+ // stay inside the project once symlinks are resolved, and never go through a hidden
693
+ // (dot) segment or node_modules.
694
+ const WRITE_EXT = { html: ['.html'], css: ['.css'], data: ['.json', '.csv', '.yaml', '.yml', '.md'] };
695
+ WRITE_EXT.any = [...WRITE_EXT.html, ...WRITE_EXT.css, ...WRITE_EXT.data];
696
+ let REAL_ROOT = null;
697
+ const realRoot = () => REAL_ROOT || (REAL_ROOT = realpathSync.native(root));
698
+ // Case-insensitive (APFS/NTFS), and segments Windows would alias (trailing dot or space).
699
+ const hiddenSegment = (rel) => rel.split(/[\\/]/).some(seg => seg.startsWith('.') || seg.toLowerCase() === 'node_modules' || /[. ]$/.test(seg));
700
+ const outside = (rel) => !rel || rel.startsWith('..') || isAbsolute(rel);
701
+ function realTarget(file) {
702
+ try { return realpathSync.native(file); } catch { }
703
+ try { return join(realpathSync.native(dirname(file)), basename(file)); } catch { return null; }
704
+ }
705
+ function writeRefusal(file, kind) {
706
+ const exts = WRITE_EXT[kind] || WRITE_EXT.any, ext = extname(file).toLowerCase();
707
+ if (!exts.includes(ext)) return `${kind === 'any' ? 'the editor' : kind} writes only ${exts.join(' ')} files`;
708
+ const rel = relative(root, file);
709
+ if (outside(rel)) return 'outside the project';
710
+ if (hiddenSegment(rel)) return 'a hidden or node_modules path';
711
+ const real = realTarget(file);
712
+ if (!real) return 'its folder does not exist';
713
+ const rrel = relative(realRoot(), real);
714
+ if (outside(rrel)) return 'it resolves outside the project';
715
+ if (hiddenSegment(rrel)) return 'it resolves into a hidden or node_modules path';
716
+ if (!exts.includes(extname(real).toLowerCase())) return 'it resolves to another file type';
717
+ return null;
718
+ }
719
+ // Base versions: content hashes of the files publish can write, so a publish can tell
720
+ // whether one changed since its page loaded. Cached by mtime+size.
721
+ const hashCache = new Map();
722
+ function fileHash(file) {
723
+ const st = statSync(file), c = hashCache.get(file);
724
+ if (c && c.mtimeMs === st.mtimeMs && c.size === st.size) return c.hash;
725
+ const hash = createHash('sha1').update(readFileSync(file)).digest('hex');
726
+ hashCache.set(file, { mtimeMs: st.mtimeMs, size: st.size, hash });
727
+ return hash;
728
+ }
729
+ function baseHashes() {
730
+ const out = {}; let n = 0;
731
+ const walk = (dir, depth) => {
732
+ if (depth > 12 || n > 5000) return;
733
+ let entries = []; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
734
+ const dirs = [];
735
+ for (const e of entries) {
736
+ if (e.name.startsWith('.') || e.name === 'node_modules' || EDIT_TEMP.test(e.name)) continue;
737
+ const p = join(dir, e.name);
738
+ let real; try { real = realpathSync.native(p); } catch { continue; }
739
+ const rrel = relative(realRoot(), real);
740
+ if (outside(rrel) && real !== realRoot()) continue;
741
+ if (hiddenSegment(rrel)) continue;
742
+ let st; try { st = statSync(p); } catch { continue; }
743
+ if (st.isDirectory()) { if (!e.isSymbolicLink()) dirs.push(p); }
744
+ else if (WRITE_EXT.any.includes(extname(e.name).toLowerCase())) { try { out[relKey(relative(root, p))] = fileHash(p); n++; } catch { } }
745
+ }
746
+ dirs.forEach(d => walk(d, depth + 1));
747
+ };
748
+ walk(root, 0);
749
+ return out;
750
+ }
751
+ const vwrite = (file, content) => { if (!VFS) throw new Error('source writes happen only inside a save batch'); VFS.set(file, String(content)); };
752
+ const vexists = (file) => (VFS && VFS.has(file)) || isFile(file);
613
753
  function reorderDataFile(file, order) {
754
+ const why = writeRefusal(file, 'data'); if (why) throw new Error(`refused: ${why}`);
614
755
  const ext = extname(file).toLowerCase();
615
- const text = readFileSync(file, 'utf8');
756
+ const text = vread(file);
616
757
  const ord = (order || []).map(String);
617
758
  if (ext === '.json') {
618
759
  const data = JSON.parse(text);
@@ -620,7 +761,7 @@ function reorderDataFile(file, order) {
620
761
  const byId = new Map(data.map(it => [String(it.id), it]));
621
762
  const next = ord.map(id => byId.get(id)).filter(Boolean);
622
763
  data.forEach(it => { if (!ord.includes(String(it.id))) next.push(it); });
623
- writeFileSync(file, JSON.stringify(next, null, 2) + (text.endsWith('\n') ? '\n' : ''));
764
+ vwrite(file, JSON.stringify(next, null, 2) + (text.endsWith('\n') ? '\n' : ''));
624
765
  return true;
625
766
  }
626
767
  if (ext === '.csv') {
@@ -637,7 +778,7 @@ function reorderDataFile(file, order) {
637
778
  const byId = new Map(rows.map(r => [idOf(r), r]));
638
779
  const next = ord.map(id => byId.get(id)).filter(Boolean);
639
780
  rows.forEach(r => { if (!ord.includes(idOf(r))) next.push(r); });
640
- writeFileSync(file, [header, ...next].join(eol) + (trailing ? eol : ''));
781
+ vwrite(file, [header, ...next].join(eol) + (trailing ? eol : ''));
641
782
  return true;
642
783
  }
643
784
  return false;
@@ -656,12 +797,13 @@ const csvCell = (v) => /[",\n]/.test(v) ? '"' + String(v).replace(/"/g, '""') +
656
797
  function writeDataValue(p, manifest) {
657
798
  const src = (manifest.data || {})[p.source], rel = typeof src === 'string' ? src : null;
658
799
  if (!rel) return { kind: 'data-val', source: p.source, status: 'skipped', reason: 'not a file source (cloud → use $x.$update)' };
659
- const file = safeResolve(rel); if (!file || !isFile(file)) return { kind: 'data-val', source: p.source, status: 'error', reason: `file not found: ${rel}` };
660
- const ext = extname(file).toLowerCase(), text = readFileSync(file, 'utf8');
800
+ const file = resolveRel(rel); if (!file || !isFile(file)) return { kind: 'data-val', source: p.source, status: 'error', reason: `file not found: ${rel}` };
801
+ const refused = writeRefusal(file, 'data'); if (refused) return { kind: 'data-val', source: p.source, status: 'error', reason: `refused: ${refused}` };
802
+ const ext = extname(file).toLowerCase(), text = vread(file);
661
803
  if (ext === '.json') {
662
804
  const data = JSON.parse(text); if (!Array.isArray(data)) return { kind: 'data-val', source: p.source, status: 'error', reason: 'JSON is not an array' };
663
805
  const rec = data.find(r => String(r.id) === String(p.id)); if (!rec) return { kind: 'data-val', source: p.source, status: 'error', reason: `id ${p.id} not found` };
664
- rec[p.field] = p.value; writeFileSync(file, JSON.stringify(data, null, 2) + (text.endsWith('\n') ? '\n' : ''));
806
+ rec[p.field] = p.value; vwrite(file, JSON.stringify(data, null, 2) + (text.endsWith('\n') ? '\n' : ''));
665
807
  return { kind: 'data-val', source: p.source, id: p.id, field: p.field, status: 'written', file: basename(file) };
666
808
  }
667
809
  if (ext === '.csv') {
@@ -674,194 +816,728 @@ function writeDataValue(p, manifest) {
674
816
  let hit = false;
675
817
  const rows = lines.slice(1).map(line => { const cells = parseCsvLine(line); if (String(cells[idIdx]).trim() === String(p.id)) { cells[fIdx] = p.value; hit = true; return cells.map(csvCell).join(','); } return line; });
676
818
  if (!hit) return { kind: 'data-val', source: p.source, status: 'error', reason: `id ${p.id} not found` };
677
- writeFileSync(file, [lines[0], ...rows].join(eol) + (trailing ? eol : ''));
819
+ vwrite(file, [lines[0], ...rows].join(eol) + (trailing ? eol : ''));
678
820
  return { kind: 'data-val', source: p.source, id: p.id, field: p.field, status: 'written', file: basename(file) };
679
821
  }
680
822
  return { kind: 'data-val', source: p.source, status: 'error', reason: `unsupported ext ${ext}` };
681
823
  }
682
824
 
683
- // --- Dependency-free HTML region editor (spike) ---
684
- // packages/run is zero-dep by design, so instead of pulling in an HTML parser we do a
685
- // small tag-aware scan: find the element carrying x-edit="<key>", depth-match its close,
686
- // and splice. Robust for authored Manifest source (well-formed); not a general parser.
825
+ // --- Dependency-free HTML editor ---
826
+ // packages/run is zero-dep by design, so the source writer carries its own small HTML
827
+ // parser: tokenizer (raw-text elements, comments and attribute quoting as browsers read
828
+ // them) plus the tree builder's implied end tags and implied <tbody>, which is what makes
829
+ // its element tree match the DOM the client keyed.
687
830
  const escapeReg = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
688
- function tagEnd(html, from) { // from = index of '<'; returns index of the unquoted '>'
689
- let q = null;
690
- for (let i = from + 1; i < html.length; i++) {
691
- const ch = html[i];
692
- if (q) { if (ch === q) q = null; }
693
- else if (ch === '"' || ch === "'") q = ch;
694
- else if (ch === '>') return i;
831
+ // Index of the '>' that ends the tag opened at `from`, reading attributes as a browser does.
832
+ function tagEnd(s, from) {
833
+ let i = from + 1;
834
+ while (i < s.length && !/[\s/>]/.test(s[i])) i++;
835
+ for (;;) {
836
+ while (i < s.length && /[\s/]/.test(s[i])) i++;
837
+ if (i >= s.length) return -1;
838
+ if (s[i] === '>') return i;
839
+ i++;
840
+ while (i < s.length && !/[\s/>=]/.test(s[i])) i++;
841
+ while (i < s.length && /\s/.test(s[i])) i++;
842
+ if (s[i] !== '=') continue;
843
+ i++;
844
+ while (i < s.length && /\s/.test(s[i])) i++;
845
+ if (s[i] === '"' || s[i] === "'") { const q = s.indexOf(s[i], i + 1); if (q < 0) return -1; i = q + 1; }
846
+ else while (i < s.length && !/[\s>]/.test(s[i])) i++;
695
847
  }
696
- return -1;
697
- }
698
- function matchingClose(html, tag, from) { // depth-match </tag> accounting for nested same-name tags
699
- const re = new RegExp(`<(/?)${escapeReg(tag)}(?=[\\s/>])`, 'gi');
700
- re.lastIndex = from;
701
- let depth = 1, m;
702
- while ((m = re.exec(html))) {
703
- if (m[1] === '/') { depth--; if (depth === 0) { const gt = html.indexOf('>', m.index); return { start: m.index, end: gt + 1 }; } }
704
- else { const ge = tagEnd(html, m.index); if (ge >= 0) { if (html[ge - 1] !== '/') depth++; re.lastIndex = ge; } }
848
+ }
849
+
850
+ // Opening-tag attribute scanner: attributes in order, with the exact span each occupies
851
+ // (leading whitespace included), so edits replace or remove in place and quoted values
852
+ // can never be mistaken for attributes.
853
+ function scanTag(openTag) {
854
+ const m = /^<([a-zA-Z][^\s/>]*)/.exec(openTag); if (!m) return null;
855
+ const re = /(\s*)([^\s"'>\/=]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+))?/y;
856
+ const attrs = []; let i = m[0].length;
857
+ for (;;) {
858
+ while (i < openTag.length && /[\s/]/.test(openTag[i]) && openTag.slice(i, i + 2) !== '/>') i++;
859
+ if (i >= openTag.length || openTag[i] === '>' || openTag.slice(i, i + 2) === '/>') break;
860
+ let j = i; while (j > m[0].length && /\s/.test(openTag[j - 1])) j--;
861
+ re.lastIndex = i;
862
+ const a = re.exec(openTag); if (!a || !a[0]) break;
863
+ let v = a[3] === undefined ? null : a[3];
864
+ if (v != null && /^["']/.test(v)) v = v.slice(1, -1);
865
+ attrs.push({ name: a[2], lower: a[2].toLowerCase(), value: v, start: j, end: re.lastIndex });
866
+ i = re.lastIndex;
705
867
  }
706
- return null;
868
+ while (i > m[0].length && /\s/.test(openTag[i - 1])) i--;
869
+ return { tag: m[1], attrs, end: i };
707
870
  }
708
- function locateEditEl(html, key) { // element whose x-edit*-named attr value === key
709
- const m = new RegExp(`x-edit[.\\w-]*\\s*=\\s*(?:"${escapeReg(key)}"|'${escapeReg(key)}')`).exec(html);
710
- if (!m) return null;
711
- const openStart = html.lastIndexOf('<', m.index); if (openStart < 0) return null;
712
- const nameM = /^<([a-zA-Z][\w-]*)/.exec(html.slice(openStart)); if (!nameM) return null;
713
- const openEnd = tagEnd(html, openStart); if (openEnd < 0) return null;
714
- const innerStart = openEnd + 1;
715
- const cl = matchingClose(html, nameM[1], innerStart); if (!cl) return null;
716
- return { tag: nameM[1], openStart, openEnd, innerStart, innerEnd: cl.start };
717
- }
718
- function setAttr(openTag, name, value) { // replace or insert an attribute in an opening-tag string
719
- const val = `"${String(value).replace(/"/g, '&quot;')}"`;
720
- const re = new RegExp(`(\\s${escapeReg(name)}\\s*=\\s*)(?:"[^"]*"|'[^']*')`);
721
- if (re.test(openTag)) return openTag.replace(re, `$1${val}`);
722
- return openTag.replace(/\s*\/?>$/, m => ` ${name}=${val}${m}`);
723
- }
724
- // Static per-node ops + reorder — surgical source edits (no whole-innerHTML replacement).
725
- function navInRegion(html, loc, path) {
726
- let node = { innerStart: loc.innerStart, innerEnd: loc.innerEnd };
727
- for (const i of path.split('.').map(Number)) { node = childAt(html, node.innerStart, node.innerEnd, i); if (!node) return null; }
728
- return node;
871
+ const escAttr = (v) => String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
872
+ // Set (null removes) one attribute; repeats of the name collapse into the first.
873
+ function setAttr(openTag, name, value) {
874
+ const t = scanTag(openTag); if (!t) return openTag;
875
+ const lower = String(name).toLowerCase(), hits = t.attrs.filter(a => a.lower === lower);
876
+ const put = value == null ? '' : ` ${name}="${escAttr(value)}"`;
877
+ if (!hits.length) return value == null ? openTag : openTag.slice(0, t.end) + put + openTag.slice(t.end);
878
+ let out = openTag;
879
+ for (let i = hits.length - 1; i >= 0; i--) out = out.slice(0, hits[i].start) + (i === 0 ? put : '') + out.slice(hits[i].end);
880
+ return out;
729
881
  }
730
- function serverStaticKey(html, child) { // mirror the client's staticKey (tag + first 24 text chars)
731
- const text = html.slice(child.innerStart, child.innerEnd).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 24);
732
- return child.tag.toUpperCase() + ':' + text;
733
- }
734
- function writeStaticOps(file, key, edits, order, aliases) {
735
- // Order FIRST: keys match on authored content (edits haven't rewritten it yet) and
736
- // edit paths are current DOM positions, valid only once the file order matches.
737
- let reordered = false;
738
- if (order && order.length) {
739
- let html = readFileSync(file, 'utf8'); const loc = locateEditEl(html, key);
740
- if (loc) {
741
- const kids = []; let n = 0, c;
742
- while ((c = childAt(html, loc.innerStart, loc.innerEnd, n++))) kids.push(c);
743
- const keys = dedupedKeys(html, kids); // ordinal-deduped, mirroring the client
744
- // A block edited BEFORE its move carries a live key derived from the EDITED text;
745
- // the file holds the authored text, so file keys can't resolve it and the whole
746
- // order would be skipped. writeStaticMove recorded (live toKey → inserted slice):
747
- // bind those entries to their exact inserted child, then key the remaining kids
748
- // counting the live base the client's rekey counted (twins keep their ordinals).
749
- const bound = new Map();
750
- for (const a of (aliases || [])) {
751
- if (!order.includes(a.key) || keys.includes(a.key)) continue; // unedited arrival: file keys already match
752
- const kid = kids.find(k => !bound.has(k) && html.slice(k.tagStart, k.closeEnd) === a.slice);
753
- if (kid) bound.set(kid, a.key);
882
+ const removeAttr = (openTag, name) => setAttr(openTag, name, null);
883
+ const attrOf = (openTag, name) => { const t = scanTag(openTag); const a = t && t.attrs.find(x => x.lower === String(name).toLowerCase()); return a ? (a.value == null ? '' : decodeEntities(a.value, true)) : null; };
884
+ const hasAttrNamed = (openTag, test) => { const t = scanTag(openTag); return !!t && t.attrs.some(a => test(a.lower)); };
885
+
886
+ // Character references: the full WHATWG table (html-entities.json, generated by
887
+ // scripts/gen-html-entities.mjs), legacy semicolon-less names, and numeric references
888
+ // with the spec's replacements.
889
+ let ENTITIES = null;
890
+ const entityTable = () => ENTITIES || (ENTITIES = JSON.parse(readFileSync(new URL('./html-entities.json', import.meta.url), 'utf8')));
891
+ const C1 = { 0x80: 0x20AC, 0x82: 0x201A, 0x83: 0x0192, 0x84: 0x201E, 0x85: 0x2026, 0x86: 0x2020, 0x87: 0x2021, 0x88: 0x02C6, 0x89: 0x2030, 0x8A: 0x0160, 0x8B: 0x2039, 0x8C: 0x0152, 0x8E: 0x017D, 0x91: 0x2018, 0x92: 0x2019, 0x93: 0x201C, 0x94: 0x201D, 0x95: 0x2022, 0x96: 0x2013, 0x97: 0x2014, 0x98: 0x02DC, 0x99: 0x2122, 0x9A: 0x0161, 0x9B: 0x203A, 0x9C: 0x0153, 0x9E: 0x017E, 0x9F: 0x0178 };
892
+ function decodeEntities(s, attr) {
893
+ const t = entityTable();
894
+ return String(s).replace(/&(#[xX][0-9a-fA-F]+;?|#[0-9]+;?|[A-Za-z][A-Za-z0-9]*;?)/g, (m, e, off, str) => {
895
+ if (e[0] === '#') {
896
+ let n = /^#[xX]/.test(e) ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
897
+ if (!Number.isFinite(n) || n === 0 || n > 0x10FFFF || (n >= 0xD800 && n <= 0xDFFF)) return '�';
898
+ return String.fromCodePoint(C1[n] || n);
899
+ }
900
+ if (e.endsWith(';') && t[e] !== undefined) return t[e];
901
+ const name = e.replace(/;$/, '');
902
+ for (let k = name.length; k > 1; k--) {
903
+ const p = name.slice(0, k);
904
+ if (t[p] === undefined) continue;
905
+ if (attr) { const next = k < name.length ? name[k] : (e.endsWith(';') ? ';' : str[off + m.length] || ''); if (/[A-Za-z0-9=]/.test(next)) return m; }
906
+ return t[p] + e.slice(k);
907
+ }
908
+ return m;
909
+ });
910
+ }
911
+
912
+ const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', 'keygen', 'frame']);
913
+ const RAW_TEXT = new Set(['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript']);
914
+ const RCDATA = new Set(['textarea', 'title']);
915
+ const SCOPE = ['applet', 'caption', 'html', 'table', 'td', 'th', 'marquee', 'object', 'template', '#root'];
916
+ const BUTTON_SCOPE = new Set([...SCOPE, 'button']);
917
+ const LIST_STOP = new Set([...SCOPE, 'ol', 'ul']);
918
+ const DL_STOP = new Set([...SCOPE, 'dl']);
919
+ const CLOSES_P = new Set(['address', 'article', 'aside', 'blockquote', 'center', 'details', 'dialog', 'dir', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'section', 'search', 'summary', 'table', 'ul', 'li', 'dd', 'dt', 'listing', 'plaintext', 'xmp']);
920
+ const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
921
+ const set = (...a) => new Set(a);
922
+
923
+ // Element tree of s[from, to). Nodes: { name, tag, tagStart, openEnd, innerStart, innerEnd,
924
+ // closeEnd, void?, implied? (no tags in source), impliedEnd? (no end tag), children, content }.
925
+ function parseHTML(s, from = 0, to = s.length) {
926
+ const root = { name: '#root', tag: '#root', tagStart: from, openEnd: from - 1, innerStart: from, innerEnd: to, closeEnd: to, children: [], content: [], parent: null };
927
+ const stack = [root];
928
+ const top = () => stack[stack.length - 1];
929
+ const close = (idx, at, end) => {
930
+ while (stack.length > idx) {
931
+ const el = stack.pop();
932
+ if (stack.length === idx && end) { el.innerEnd = end.start; el.closeEnd = end.end; }
933
+ else { el.innerEnd = at; el.closeEnd = at; el.impliedEnd = true; }
934
+ }
935
+ };
936
+ const find = (names, stops) => { for (let k = stack.length - 1; k > 0; k--) { const n = stack[k].name; if (names.has(n)) return k; if (stops && stops.has(n)) return -1; } return -1; };
937
+ const insert = (name, tag, start, openEnd, extra) => {
938
+ const el = Object.assign({ name, tag, tagStart: start, openEnd, innerStart: openEnd + 1, children: [], content: [], parent: top() }, extra);
939
+ top().children.push(el); top().content.push({ el });
940
+ return el;
941
+ };
942
+ const implied = (name, at) => stack.push(insert(name, name, at, at - 1, { implied: true }));
943
+ const text = (a, b, raw) => { if (b > a) top().content.push({ text: raw ? s.slice(a, b) : decodeEntities(s.slice(a, b)) }); };
944
+ const foreign = () => stack.some(n => n.name === 'svg' || n.name === 'math');
945
+ let i = from;
946
+ while (i < to) {
947
+ const lt = s.indexOf('<', i);
948
+ if (lt < 0 || lt >= to) { text(i, to); break; }
949
+ text(i, lt);
950
+ if (s.startsWith('<!--', lt)) {
951
+ // Comments end at --> or --!>; <!--> and <!---> are complete (empty) comments.
952
+ if (s.startsWith('<!-->', lt)) { i = lt + 5; continue; }
953
+ if (s.startsWith('<!--->', lt)) { i = lt + 6; continue; }
954
+ const re = /--!?>/g; re.lastIndex = lt + 4; const e = re.exec(s);
955
+ i = !e || e.index + e[0].length > to ? to : e.index + e[0].length; continue;
956
+ }
957
+ const c = s[lt + 1];
958
+ if (s.startsWith('<![CDATA[', lt) && foreign()) { const e = s.indexOf(']]>', lt + 9); const end = e < 0 || e >= to ? to : e; text(lt + 9, end, true); i = Math.min(to, end + 3); continue; }
959
+ if (c === '!' || c === '?') { const e = s.indexOf('>', lt); i = e < 0 || e >= to ? to : e + 1; continue; }
960
+ if (c === '/') {
961
+ const m = /^<\/([a-zA-Z][^\s/>]*)/.exec(s.slice(lt, lt + 80));
962
+ const e = s.indexOf('>', lt), end = e < 0 || e >= to ? to : e + 1;
963
+ if (m) {
964
+ const name = m[1].toLowerCase(), k = find(set(name));
965
+ if (name === 'br' && !foreign()) insert('br', 'br', lt, end - 1, { void: true, implied: true, innerEnd: end, closeEnd: end }); // </br> is a <br>
966
+ else if (name === 'p' && find(set('p'), BUTTON_SCOPE) < 0 && !foreign()) insert('p', 'p', lt, lt - 1, { implied: true, innerEnd: lt, closeEnd: end }); // stray </p> makes an empty <p>
967
+ else if (k > 0) close(k, lt, { start: lt, end });
754
968
  }
755
- const byKey = {};
756
- if (bound.size) {
757
- const seen = Object.create(null);
758
- kids.forEach(k => {
759
- const live = bound.get(k), base = live ? ((/^([\s\S]*)#\d+$/.exec(live) || [, live])[1]) : serverStaticKey(html, k); // ordinal = last #digits suffix, matching the client
760
- const d = seen[base] = (seen[base] || 0) + 1;
761
- byKey[live || (d > 1 ? base + '#' + d : base)] = k;
762
- });
763
- } else kids.forEach((k, i) => { byKey[keys[i]] = k; });
764
- const seq = order.map(k => byKey[k]).filter(Boolean);
765
- if (seq.length === kids.length && seq.length && seq.some((k, i) => k !== kids[i])) { // skip when already in order
766
- const reassembled = seq.map(k => html.slice(k.tagStart, k.closeEnd)).join('\n ');
767
- html = html.slice(0, kids[0].tagStart) + reassembled + html.slice(kids[kids.length - 1].closeEnd);
768
- writeFileSync(file, html); reordered = true;
969
+ i = end; continue;
970
+ }
971
+ const m = /^<([a-zA-Z][^\s/>]*)/.exec(s.slice(lt, lt + 80));
972
+ if (!m) { text(lt, lt + 1); i = lt + 1; continue; }
973
+ const e = tagEnd(s, lt);
974
+ if (e < 0 || e >= to) { i = to; break; }
975
+ const tag = m[1], name = tag.toLowerCase(), inForeign = foreign();
976
+ if (!inForeign) {
977
+ if (CLOSES_P.has(name)) { const k = find(set('p'), BUTTON_SCOPE); if (k > 0) close(k, lt); }
978
+ if (HEADINGS.has(name) && HEADINGS.has(top().name)) close(stack.length - 1, lt);
979
+ if (name === 'li') { const k = find(set('li'), LIST_STOP); if (k > 0) close(k, lt); }
980
+ if (name === 'dd' || name === 'dt') { const k = find(set('dd', 'dt'), DL_STOP); if (k > 0) close(k, lt); }
981
+ if (name === 'option' || name === 'optgroup') { if (top().name === 'option') close(stack.length - 1, lt); if (name === 'optgroup' && top().name === 'optgroup') close(stack.length - 1, lt); }
982
+ if (name === 'td' || name === 'th') {
983
+ const k = find(set('td', 'th', 'tr', 'tbody', 'thead', 'tfoot', 'table'));
984
+ if (k > 0 && (stack[k].name === 'td' || stack[k].name === 'th')) close(k, lt);
985
+ if (top().name === 'table') implied('tbody', lt);
986
+ if (/^t(body|head|foot)$/.test(top().name)) implied('tr', lt);
987
+ }
988
+ if (name === 'tr') {
989
+ const k = find(set('tr', 'tbody', 'thead', 'tfoot', 'table'));
990
+ if (k > 0 && stack[k].name === 'tr') close(k, lt);
991
+ if (top().name === 'table') implied('tbody', lt);
769
992
  }
993
+ if (name === 'tbody' || name === 'thead' || name === 'tfoot') { const k = find(set('tbody', 'thead', 'tfoot', 'table')); if (k > 0 && stack[k].name !== 'table') close(k, lt); }
994
+ }
995
+ const el = insert(name, tag, lt, e);
996
+ i = e + 1;
997
+ if (VOID_TAGS.has(name) || ((inForeign || name === 'svg' || name === 'math') && s[e - 1] === '/')) { el.void = true; el.innerEnd = el.closeEnd = e + 1; continue; }
998
+ stack.push(el);
999
+ if (!inForeign && (RAW_TEXT.has(name) || RCDATA.has(name))) {
1000
+ const re = new RegExp(`</${escapeReg(name)}(?=[\\s/>])`, 'ig'); re.lastIndex = i;
1001
+ const r = re.exec(s);
1002
+ if (!r || r.index >= to) { text(i, to, RAW_TEXT.has(name)); i = to; break; }
1003
+ text(i, r.index, RAW_TEXT.has(name));
1004
+ const ce = s.indexOf('>', r.index);
1005
+ close(stack.length - 1, r.index, { start: r.index, end: ce + 1 });
1006
+ i = ce + 1;
770
1007
  }
771
1008
  }
772
- for (const ed of (edits || [])) { // re-read+re-locate each (offsets shift after writes)
773
- let html = readFileSync(file, 'utf8');
774
- const loc = locateEditEl(html, key); if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
775
- const node = ed.path === '' ? { tagStart: loc.openStart, openEnd: loc.openEnd, innerStart: loc.innerStart, innerEnd: loc.innerEnd } : navInRegion(html, loc, ed.path);
776
- if (!node) continue;
777
- const openTag = html.slice(node.tagStart, node.openEnd + 1);
778
- if (ed.prop === 'text') html = html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd);
779
- else if (ed.prop === 'class') html = html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1);
780
- else if (ed.prop === 'style') html = html.slice(0, node.tagStart) + setAttr(openTag, 'style', ed.value) + html.slice(node.openEnd + 1);
781
- writeFileSync(file, html);
1009
+ close(1, to);
1010
+ return root;
1011
+ }
1012
+ const openOf = (s, n) => n.implied ? '' : s.slice(n.tagStart, n.openEnd + 1);
1013
+ const isPluginNode = (s, n) => !n.implied && /\sdata-edit-(handle|ghost|grip)[\s=/>]/.test(openOf(s, n));
1014
+ const kidsOf = (s, n) => n.children.filter(k => !isPluginNode(s, k));
1015
+ const ancestorsOf = (n) => { const out = []; for (let p = n.parent; p && p.name; p = p.parent) out.push(p.name); return out; };
1016
+ function allElements(root) { const out = []; const walk = (n) => n.children.forEach(k => { out.push(k); walk(k); }); walk(root); return out; }
1017
+ // The element carrying x-edit*="<key>" as a real attribute.
1018
+ function locateEditEl(html, key, doc) {
1019
+ doc = doc || parseHTML(html);
1020
+ return allElements(doc).find(n => !n.implied && (scanTag(openOf(html, n)) || { attrs: [] }).attrs.some(a => /^x-edit(\.|$)/.test(a.lower) && a.value === key)) || null;
1021
+ }
1022
+ const escapeText = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1023
+
1024
+ // Attribute guardrails — the same rules the client applies, checked here on both the
1025
+ // raw and the entity-decoded value.
1026
+ const SVG_ANIM = new Set(['set', 'animate', 'animatetransform', 'animatemotion']);
1027
+ const DATA_IMAGE = /^data:image\/(png|jpeg|gif|webp|avif)[;,]/;
1028
+ const LOADS = { script: ['src', 'type'], iframe: ['src', 'srcdoc'], embed: ['src', 'srcdoc'], object: ['src', 'srcdoc', 'data'], frame: ['src', 'srcdoc'], link: ['href', 'rel'], base: ['href', 'rel'], meta: ['http-equiv', 'content'] };
1029
+ function attrRefusal(name, value, tag) {
1030
+ const n = String(name || '').trim().toLowerCase(), t = String(tag || '').toLowerCase();
1031
+ if (!n || !/^[a-z_][\w:.-]*$/i.test(n)) return 'invalid name';
1032
+ if (n === 'text' || n === 'srcdoc' || n === 'action' || n === 'formaction' || n.startsWith('on') || n.startsWith('x-') || n.startsWith('data-edit-')) return 'refused name';
1033
+ if (LOADS[t] && LOADS[t].includes(n)) return 'loads or runs content';
1034
+ if (SVG_ANIM.has(t) && ['attributename', 'to', 'values', 'from', 'by'].includes(n)) return 'SVG animation target';
1035
+ if (value == null) return null;
1036
+ for (const raw of [String(value), decodeEntities(value, true)]) {
1037
+ const v = raw.replace(/[\u0000- \u007f]/g, '').toLowerCase();
1038
+ if (/(^|[,(;'"=])(javascript|vbscript):/.test(v)) return 'script URL';
1039
+ const datas = v.match(/(^|[,(;'"=])data:[^,)]*/g) || [];
1040
+ if (datas.length && !((t === 'img' || t === 'source') && (n === 'src' || n === 'srcset') && datas.every(d => DATA_IMAGE.test(d.replace(/^[,(;'"=]/, ''))))) return 'data URL';
782
1041
  }
783
- return { region: key, status: 'written', file: basename(file), edits: (edits || []).length, reordered };
1042
+ return null;
1043
+ }
1044
+ // HTML content model: drops the parser would re-nest, foster-parent or drop. Mirrors
1045
+ // the client's CONTENT_RULES (manifest.edit.core.js).
1046
+ const CONTENT_RULES = {
1047
+ only: { // parent → the only children it takes
1048
+ table: ['caption', 'colgroup', 'thead', 'tbody', 'tfoot', 'script', 'template', 'style'],
1049
+ thead: ['tr', 'script', 'template'], tbody: ['tr', 'script', 'template'], tfoot: ['tr', 'script', 'template'],
1050
+ tr: ['td', 'th', 'script', 'template'], colgroup: ['col', 'template'],
1051
+ ul: ['li', 'script', 'template'], ol: ['li', 'script', 'template'], menu: ['li', 'script', 'template'],
1052
+ dl: ['dt', 'dd', 'div', 'script', 'template'],
1053
+ select: ['option', 'optgroup', 'hr', 'script', 'template'], datalist: ['option', 'script', 'template'], optgroup: ['option', 'script', 'template']
1054
+ },
1055
+ parent: { // child → the parents it needs
1056
+ tr: ['thead', 'tbody', 'tfoot'], td: ['tr'], th: ['tr'], caption: ['table'], colgroup: ['table'], col: ['colgroup'],
1057
+ thead: ['table'], tbody: ['table'], tfoot: ['table'], li: ['ul', 'ol', 'menu'], dt: ['dl', 'div'], dd: ['dl', 'div'],
1058
+ option: ['select', 'datalist', 'optgroup'], optgroup: ['select']
1059
+ },
1060
+ interactive: ['a', 'button', 'input', 'select', 'textarea', 'details', 'label', 'iframe', 'embed', 'object', 'audio', 'video', 'keygen'],
1061
+ phrasingOnly: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre'],
1062
+ block: ['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'search', 'section', 'table', 'ul', 'li', 'dd', 'dt']
1063
+ };
1064
+ // Reason a <child> (with its subtree's tag names) cannot be placed in <parent> (whose
1065
+ // ancestor tag names are given, nearest first), or null.
1066
+ function contentRefusal(child, parent, ancestors, subtree) {
1067
+ const R = CONTENT_RULES;
1068
+ if (R.only[parent] && !R.only[parent].includes(child)) return `<${parent}> takes only ${R.only[parent].filter(t => t !== 'script' && t !== 'template' && t !== 'style').join(', ')}`;
1069
+ if (R.parent[child] && !R.parent[child].includes(parent)) return `<${child}> belongs in ${R.parent[child].join(' or ')}`;
1070
+ if ((child === 'dt' || child === 'dd') && parent === 'div' && ancestors[1] !== 'dl') return `<${child}> belongs in a dl`;
1071
+ if (subtree.has('form') && ancestors.includes('form')) return 'a form cannot contain a form';
1072
+ if ((ancestors.includes('a') || ancestors.includes('button')) && R.interactive.some(t => subtree.has(t))) return 'interactive content cannot go inside a link or button';
1073
+ if (R.phrasingOnly.includes(parent) && R.block.some(t => subtree.has(t))) return `<${parent}> takes only inline content`;
1074
+ return null;
784
1075
  }
785
- // Cross-region move: lift a child's authored markup out of one x-edit region and
786
- // splice it into another (same file). Keys mirror the client's staticKeys (ordinal-deduped).
787
- function regionChildren(html, loc) {
788
- const kids = []; let n = 0, c;
789
- while ((c = childAt(html, loc.innerStart, loc.innerEnd, n++))) kids.push(c);
790
- return kids;
1076
+ // Elements whose content is not markup: never given text edits.
1077
+ const RAW_EDIT = new Set(['script', 'style', 'textarea', 'title', 'noscript', 'template', 'xmp', 'iframe', 'noembed', 'noframes']);
1078
+
1079
+ // Text sanitizer for edited content: the client's allowlist (inline marks, plus the
1080
+ // rich editor's blocks), script-bearing elements dropped with their content, other
1081
+ // tags unwrapped, attributes allowlisted per tag.
1082
+ const SAN_OK = new Set(['b', 'i', 'em', 'strong', 'u', 's', 'del', 'ins', 'small', 'code', 'mark', 'sub', 'sup', 'br', 'wbr', 'span', 'a', 'kbd', 'samp', 'var', 'abbr', 'cite', 'q', 'dfn', 'time', 'data', 'bdi', 'bdo', 'img',
1083
+ 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'address', 'figure', 'figcaption', 'dl', 'dt', 'dd', 'ul', 'ol', 'li', 'hr', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'caption', 'colgroup', 'col', 'input']);
1084
+ const SAN_DROP = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta', 'noscript', 'template', 'svg', 'math', 'textarea', 'title', 'select', 'frame', 'frameset', 'base', 'form', 'button', 'xmp', 'noembed', 'noframes', 'plaintext']);
1085
+ // The one attribute Rich Text keeps on each of these inline tags.
1086
+ const TEXT_ATTR = { abbr: 'title', dfn: 'title', bdo: 'dir', data: 'value', time: 'datetime' };
1087
+ function textAttrOk(tag, n, v) {
1088
+ if (tag === 'a' && n === 'href') return !attrRefusal(n, v, tag);
1089
+ if (tag === 'img' && (n === 'alt' || n === 'src')) return !attrRefusal(n, v, tag);
1090
+ if (TEXT_ATTR[tag] === n) return n === 'dir' ? /^(ltr|rtl|auto)$/i.test(v) : !attrRefusal(n, v, tag);
1091
+ if (tag === 'input') return n === 'type' ? /^checkbox$/i.test(v) : n === 'checked';
1092
+ if ((tag === 'td' || tag === 'th') && (n === 'colspan' || n === 'rowspan')) return /^[1-9]\d{0,2}$/.test(v);
1093
+ if (n === 'style') return !/expression|url\s*\(|javascript:|@import/i.test(v) && !attrRefusal(n, v, tag);
1094
+ return false;
791
1095
  }
792
- function dedupedKeys(html, kids) {
1096
+ function sanitizeHTML(html) {
1097
+ const s = String(html == null ? '' : html);
1098
+ let out = '', i = 0;
1099
+ while (i < s.length) {
1100
+ const lt = s.indexOf('<', i);
1101
+ if (lt < 0) { out += s.slice(i); break; }
1102
+ out += s.slice(i, lt);
1103
+ // A comment is kept verbatim when the HTML spec allows its text, so it parses back the same
1104
+ if (s.startsWith('<!--', lt)) { const e = s.indexOf('-->', lt + 4); if (e >= 0 && !/^-?>|--!>|<!-$/.test(s.slice(lt + 4, e))) out += s.slice(lt, e + 3); i = e < 0 ? s.length : e + 3; continue; }
1105
+ const m = /^<(\/?)([a-zA-Z][^\s/>]*)/.exec(s.slice(lt, lt + 80));
1106
+ if (!m) { out += '&lt;'; i = lt + 1; continue; }
1107
+ const end = m[1] ? s.indexOf('>', lt) : tagEnd(s, lt);
1108
+ if (end < 0) break;
1109
+ const name = m[2].toLowerCase(), closing = !!m[1];
1110
+ i = end + 1;
1111
+ if (SAN_DROP.has(name)) {
1112
+ if (!closing && s[end - 1] !== '/') { const r = new RegExp(`</${escapeReg(name)}(?=[\\s/>])`, 'ig'); r.lastIndex = i; const c = r.exec(s); i = c ? s.indexOf('>', c.index) + 1 || s.length : s.length; }
1113
+ continue;
1114
+ }
1115
+ if (!SAN_OK.has(name)) continue;
1116
+ if (closing) { out += `</${name}>`; continue; }
1117
+ let tag = '<' + name;
1118
+ for (const a of scanTag(s.slice(lt, end + 1)).attrs) {
1119
+ const v = a.value == null ? '' : decodeEntities(a.value, true);
1120
+ if (textAttrOk(name, a.lower, v)) tag += a.lower === 'checked' ? ' checked' : ` ${a.lower}="${escAttr(v)}"`;
1121
+ }
1122
+ out += tag + '>';
1123
+ }
1124
+ return out;
1125
+ }
1126
+
1127
+ // Plugin-rendered hosts: their children are produced at runtime, not authored.
1128
+ const RENDERED = ['x-html', 'x-text', 'x-markdown', 'x-icon', 'x-svg', 'x-code', 'x-code-group', 'x-chart', 'x-anchors', 'x-carousel', 'x-color', 'x-colorpicker', 'x-combobox', 'x-date', 'x-defer', 'x-virtual', 'x-import', 'x-files', 'x-data-files'];
1129
+ const isRenderedAttr = (a) => RENDERED.some(r => a === r || a.startsWith(r + '.'));
1130
+
1131
+ // A static area's identities, mirroring the client: origin area + tag + the first 24
1132
+ // chars of source text (rendered content left out), ordinal per repeat, document order;
1133
+ // plus each element's path and the comment titling it (moves with it).
1134
+ const canonKey = (region, k) => '@' + encodeURIComponent(region) + '@' + k;
1135
+ function indexRegion(html, name, node) {
1136
+ const reg = { name, root: node, byKey: new Map(), byPath: new Map() };
1137
+ const open = (n) => openOf(html, n);
1138
+ const rendered = (n) => !n.implied && hasAttrNamed(open(n), isRenderedAttr);
1139
+ const textOf = (n) => {
1140
+ if (n.void || n.name === 'template' || rendered(n)) return '';
1141
+ let t = '';
1142
+ for (const c of n.content) t += c.text != null ? c.text : (isPluginNode(html, c.el) || c.el.name === 'template' ? '' : textOf(c.el));
1143
+ return t;
1144
+ };
1145
+ const baseOf = (n) => n.name.toUpperCase() + ':' + textOf(n).replace(/\s+/g, ' ').trim().slice(0, 24);
793
1146
  const seen = Object.create(null);
794
- return kids.map(k => { const base = serverStaticKey(html, k); const n = seen[base] = (seen[base] || 0) + 1; return n > 1 ? base + '#' + n : base; });
795
- }
796
- function writeStaticMove(file, p, aliases) {
797
- let html = readFileSync(file, 'utf8');
798
- const fromLoc = locateEditEl(html, p.from);
799
- if (!fromLoc) return { region: p.from, status: 'error', reason: `x-edit="${p.from}" not found in ${basename(file)}` };
800
- if (!locateEditEl(html, p.to)) return { region: p.to, status: 'error', reason: `x-edit="${p.to}" not found in ${basename(file)}` };
801
- const kids = regionChildren(html, fromLoc);
802
- const i = dedupedKeys(html, kids).indexOf(p.key);
803
- if (i < 0) return { region: p.from, status: 'error', reason: `child '${p.key}' not found in region '${p.from}'` };
804
- const child = kids[i], slice = html.slice(child.tagStart, child.closeEnd);
805
- let remStart = child.tagStart;
806
- const pre = html.slice(0, remStart), nl = pre.lastIndexOf('\n');
807
- if (nl >= 0 && /^[ \t]*$/.test(pre.slice(nl + 1))) remStart = nl; // take the indent + newline with it
808
- html = html.slice(0, remStart) + html.slice(child.closeEnd);
809
- const toLoc = locateEditEl(html, p.to); // re-locate: offsets shifted
810
- if (!toLoc) return { region: p.to, status: 'error', reason: `x-edit="${p.to}" not found after removal` };
811
- const toKids = regionChildren(html, toLoc);
812
- // Positional placement: toOrder minus the arrival is the destination's children, so
813
- // the arrival's index in toOrder is where it goes — robust against ordinal re-keys
814
- // (a collision renames the twin, so key matching would miss). Any residual order
815
- // drift is settled by the region's own order patch, which follows the move.
816
- const at = (p.toOrder || []).indexOf(p.toKey);
817
- let insertAt = toLoc.innerEnd; // default: end of region
818
- if (at >= 0 && at < toKids.length) insertAt = toKids[at].tagStart;
819
- const head = html.slice(0, insertAt), hnl = head.lastIndexOf('\n');
820
- const indent = hnl >= 0 && /^[ \t]*$/.test(head.slice(hnl + 1)) ? head.slice(hnl + 1) : '';
821
- writeFileSync(file, head + slice + '\n' + indent + html.slice(insertAt));
822
- if (aliases) (aliases[p.to] = aliases[p.to] || []).push({ key: p.toKey, slice }); // the region's order patch may address this child by its LIVE key
823
- return { region: `${p.from} → ${p.to}`, status: 'written', file: basename(file), key: p.key };
824
- }
825
- function writeStaticRegion(file, key, innerHTML, style) {
826
- let html = readFileSync(file, 'utf8');
827
- const loc = locateEditEl(html, key);
828
- if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
829
- let openTag = html.slice(loc.openStart, loc.openEnd + 1);
830
- if (style) openTag = setAttr(openTag, 'style', style);
831
- html = html.slice(0, loc.openStart) + openTag + '\n' + (innerHTML || '') + '\n' + html.slice(loc.innerEnd);
832
- writeFileSync(file, html);
833
- return { region: key, status: 'written', file: basename(file) };
834
- }
835
- // Structural navigation for component-file edits (dependency-free).
836
- const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
837
- const escapeText = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
838
- function childAt(html, from, to, idx) { // nth element child within [from,to), skipping comments/text
839
- let i = from, count = 0;
840
- while (i < to) {
841
- const lt = html.indexOf('<', i); if (lt < 0 || lt >= to) return null;
842
- if (html.startsWith('<!--', lt)) { const e = html.indexOf('-->', lt); i = e < 0 ? to : e + 3; continue; }
843
- if (html[lt + 1] === '/') { i = html.indexOf('>', lt) + 1; continue; }
844
- const nameM = /^<([a-zA-Z][\w-]*)/.exec(html.slice(lt)); if (!nameM) { i = lt + 1; continue; }
845
- const tag = nameM[1], openEnd = tagEnd(html, lt); if (openEnd < 0) return null;
846
- const selfClose = html[openEnd - 1] === '/' || VOID_TAGS.has(tag.toLowerCase());
847
- let innerStart = openEnd + 1, innerEnd = innerStart, closeEnd = openEnd + 1;
848
- if (!selfClose) { const cl = matchingClose(html, tag, innerStart); if (!cl) return null; innerEnd = cl.start; closeEnd = cl.end; }
849
- if (/\sdata-edit-(handle|ghost)[\s=/>]/.test(html.slice(lt, openEnd + 1))) { i = closeEnd; continue; } // plugin-injected, not an authored child (mirrors the client's realChildren)
850
- if (count === idx) return { tag, tagStart: lt, openEnd, innerStart, innerEnd, closeEnd };
851
- count++; i = closeEnd;
1147
+ const visit = (n) => {
1148
+ const base = n.base = baseOf(n);
1149
+ const c = seen[base] = (seen[base] || 0) + 1;
1150
+ n.key = canonKey(name, c > 1 ? base + '#' + c : base);
1151
+ reg.byKey.set(n.key, n); reg.byPath.set(n.path, n);
1152
+ n.kids = kidsOf(html, n);
1153
+ let floor = n.innerStart;
1154
+ n.kids.forEach((k, i) => { k.path = (n.path ? n.path + '.' : '') + i; k.lead = leadOf(html, k, floor, i === 0); floor = k.closeEnd; });
1155
+ if (n.name === 'template' || rendered(n) || (!n.implied && hasAttrNamed(open(n), a => a.startsWith('x-rich-text')))) return;
1156
+ n.kids.forEach(visit);
1157
+ };
1158
+ node.path = '';
1159
+ visit(node);
1160
+ // Fingerprint: the opening tag and own text; where the key has twins, also their
1161
+ // number and the element's ancestors (tag and key), so a reordered twin cannot match.
1162
+ const count = Object.create(null);
1163
+ for (const n of reg.byKey.values()) count[n.base] = (count[n.base] || 0) + 1;
1164
+ for (const n of reg.byKey.values()) {
1165
+ if (n === node) continue;
1166
+ let own = open(n);
1167
+ for (const c of n.content) if (c.text != null) own += '\0' + c.text.replace(/\s+/g, ' ').trim();
1168
+ n.fp = fpHash(own);
1169
+ if (count[n.base] > 1) {
1170
+ let ctx = String(count[n.base]);
1171
+ for (let a = n.parent; a && a !== node; a = a.parent) ctx += '\0' + open(a) + '\0' + a.base;
1172
+ n.fp += '.' + fpHash(ctx);
1173
+ }
1174
+ }
1175
+ return reg;
1176
+ }
1177
+ const fpHash = (s) => createHash('sha1').update(s).digest('hex').slice(0, 12);
1178
+ // Fingerprints of every static element by file: index.html and each component file.
1179
+ function allSourceIds(files) {
1180
+ let manifest = {}; try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); } catch {}
1181
+ const out = { 'index.html': sourceIds(join(root, 'index.html')) };
1182
+ for (const rel of files) {
1183
+ if (rel === 'index.html' || !/\.html$/i.test(rel)) continue;
1184
+ const names = [basename(rel, '.html'), rel.replace(/^components\//, '').replace(/\.html$/i, '')];
1185
+ if (names.some(n => resolveComponentFile(manifest, n) === rel)) out[rel] = sourceIds(join(root, rel));
1186
+ }
1187
+ return out;
1188
+ }
1189
+ function sourceIds(file) {
1190
+ const out = {};
1191
+ let html; try { html = readFileSync(file, 'utf8'); } catch { return out; }
1192
+ const doc = parseHTML(html);
1193
+ if (checkNesting(doc)) return out;
1194
+ for (const n of allElements(doc)) {
1195
+ if (n.implied) continue;
1196
+ const a = (scanTag(openOf(html, n)) || { attrs: [] }).attrs.find(x => /^x-edit(\.|$)/.test(x.lower));
1197
+ if (!a || !a.value) continue;
1198
+ for (const [k, m] of indexRegion(html, a.value, n).byKey) if (m.fp) out[k] = m.fp;
1199
+ }
1200
+ return out;
1201
+ }
1202
+ // A comment directly above an element (whitespace between, no blank line) titles it.
1203
+ // A comment that opens its container titles the section, not the first child.
1204
+ function leadOf(html, n, floor, first) {
1205
+ let lead = n.tagStart;
1206
+ for (;;) {
1207
+ const before = html.slice(floor, lead);
1208
+ const m = /<!--(?:(?!-->)[\s\S])*-->([ \t]*\r?\n?[ \t]*)$/.exec(before);
1209
+ if (!m || (m[1].match(/\n/g) || []).length > 1) return lead;
1210
+ if (first && !before.slice(0, m.index).trim()) return lead;
1211
+ lead = floor + m.index;
1212
+ }
1213
+ }
1214
+ // Element skeleton (tags only), for checking the written file parses as intended.
1215
+ const skeletonOf = (html, n) => n.name + (n.name === 'template' ? '()' : '(' + kidsOf(html, n).map(k => skeletonOf(html, k)).join(',') + ')');
1216
+ // Returns the first element whose range does not nest in its parent, or null.
1217
+ function checkNesting(n) {
1218
+ let prev = n.innerStart;
1219
+ for (const k of n.children) {
1220
+ if (k.tagStart < prev || k.closeEnd > n.innerEnd || k.closeEnd < k.tagStart) return k;
1221
+ prev = k.closeEnd;
1222
+ const bad = checkNesting(k);
1223
+ if (bad) return bad;
852
1224
  }
853
1225
  return null;
854
1226
  }
855
- function navigateToNode(html, path) { // path indices are relative to the component's first top-level element ('' = the root itself)
856
- let node = childAt(html, 0, html.length, 0); if (!node) return null;
1227
+ // Where two skeletons first differ, as "<tag> ... (expected …, got …)".
1228
+ function skeletonDiff(want, got) {
1229
+ let i = 0; while (i < want.length && want[i] === got[i]) i++;
1230
+ const ctx = (s) => s.slice(Math.max(0, i - 40), i + 40);
1231
+ return `expected …${ctx(want)}… got …${ctx(got || '')}…`;
1232
+ }
1233
+
1234
+ // Rebuild every static area from the client's final trees, from the original files: one
1235
+ // writer per file; an element moved between files is rendered by its own file's writer.
1236
+ // All files render, then all are checked, then all are written (or none).
1237
+ function writeStaticTrees(groups, manifest) {
1238
+ const writers = [], byRegion = new Map(), early = new Map();
1239
+ for (const [file, patches] of groups) {
1240
+ const w = staticWriter(file, patches, (name) => byRegion.get(name) || null, manifest || {});
1241
+ if (w.refusal) { patches.forEach(p => early.set(p, { region: p.region, status: 'error', reason: w.refusal })); continue; }
1242
+ writers.push(w); patches.forEach(p => byRegion.set(p.region, w));
1243
+ }
1244
+ const all = groups.flatMap(([, ps]) => ps), results = new Map(early);
1245
+ if (early.size) { all.forEach(p => { if (!results.has(p)) results.set(p, { region: p.region, status: 'skipped', reason: 'batch aborted: another patch failed; nothing was written' }); }); return results; }
1246
+ try {
1247
+ writers.forEach(w => w.render());
1248
+ writers.forEach(w => w.check());
1249
+ } catch (e) {
1250
+ if (!e.edit) throw e;
1251
+ if (!all.some(p => p.region === e.region)) e.region = null;
1252
+ all.forEach(p => results.set(p, e.region == null || p.region === e.region ? { region: p.region, status: 'error', reason: e.message, ...(e.region != null ? { failed: e.region } : {}) }
1253
+ : { region: p.region, status: 'skipped', reason: `not written: x-edit="${e.region}" failed; nothing was written` }));
1254
+ return results;
1255
+ }
1256
+ const texts = [];
1257
+ for (const w of writers) {
1258
+ const r = w.assemble();
1259
+ if (r.error) { all.forEach(p => results.set(p, { region: p.region, status: 'error', reason: r.error })); return results; }
1260
+ texts.push([w, r.next]);
1261
+ }
1262
+ for (const [w, next] of texts) if (next !== null) vwrite(w.file, next);
1263
+ writers.forEach(w => w.patches.forEach(p => results.set(p, { region: p.region, status: 'written', file: basename(w.file) })));
1264
+ return results;
1265
+ }
1266
+ function staticWriter(file, patches, foreign, manifest) {
1267
+ const self = { file, patches };
1268
+ const refused = writeRefusal(file, 'html');
1269
+ if (refused) return { refusal: `refused: ${refused}` };
1270
+ const html = vread(file);
1271
+ const doc = parseHTML(html);
1272
+ // A failure names the area it is in, so the page can publish the others without it.
1273
+ let current = null;
1274
+ const regionOfKey = (k) => { const m = /^(?:~[a-z0-9]+~)?@([^@]*)@/.exec(String(k || '')); return m ? decodeURIComponent(m[1]) : current; };
1275
+ const fail = (msg, region) => { const e = new Error(msg); e.edit = true; e.region = region === undefined ? current : region; throw e; };
1276
+ const bad = checkNesting(doc);
1277
+ if (bad) return { refusal: `source does not nest cleanly at <${bad.name}> (offset ${bad.tagStart}); not editing it` };
1278
+ // A component's <x-name> host counts as what the component renders: its first root's tag, and every tag in it.
1279
+ const hosts = new Map();
1280
+ const hostOf = (tag) => {
1281
+ if (!/^x-/.test(tag)) return null;
1282
+ if (!hosts.has(tag)) {
1283
+ let info = null;
1284
+ const rel = resolveComponentFile(manifest, tag.slice(2));
1285
+ if (rel) { try { const f = resolveRel(rel), t = f && readFileSync(f, 'utf8'), d = t && parseHTML(t), top = d && kidsOf(t, d); if (top && top.length) info = { tag: top[0].name, tags: new Set(allElements(d).map(k => k.name)) }; } catch { } }
1286
+ hosts.set(tag, info);
1287
+ }
1288
+ return hosts.get(tag);
1289
+ };
1290
+ // Whether component `tag` includes `target`, directly or through other components.
1291
+ const reaches = (tag, target) => {
1292
+ const seen = new Set(), stack = [tag];
1293
+ while (stack.length) {
1294
+ const t = stack.pop();
1295
+ if (t === target) return true;
1296
+ if (seen.has(t)) continue; seen.add(t);
1297
+ const h = hostOf(t); if (h) h.tags.forEach(x => { if (/^x-/.test(x) && !seen.has(x)) stack.push(x); });
1298
+ }
1299
+ return false;
1300
+ };
1301
+ const ownComponent = /^components\//.test(relKey(relative(root, file))) || basename(file) !== 'index.html' ? 'x-' + basename(file, '.html') : null;
1302
+ const regions = new Map();
1303
+ const region = (name) => {
1304
+ if (!regions.has(name)) { const node = locateEditEl(html, name, doc); regions.set(name, node ? indexRegion(html, name, node) : null); }
1305
+ return regions.get(name);
1306
+ };
1307
+ const removed = new Set(patches.flatMap(p => p.removed || []));
1308
+ const placed = new Map(), rebuilt = new Set(), renderedUnder = new Map();
1309
+ // `kids` (the node's listed children) lets a container whose text changed (its children
1310
+ // reordered elsewhere) be found by path: its listed children must all sit in it.
1311
+ // A node the page sent with a fingerprint must still match it in the source.
1312
+ const resolve = (t) => {
1313
+ const { key: k, at, children: kids, fp } = t || {};
1314
+ const m = /^(?:~([a-z0-9]+)~)?@([^@]*)@([\s\S]*)$/.exec(String(k || ''));
1315
+ if (!m) fail(`not a source element: ${k}`);
1316
+ const reg = region(decodeURIComponent(m[2]));
1317
+ if (!reg) { const w = foreign(decodeURIComponent(m[2])); if (w && w !== self) return Object.assign(w.resolve(t), { w }); }
1318
+ if (!reg) fail(`x-edit="${decodeURIComponent(m[2])}" not found in ${basename(file)}`);
1319
+ let node = reg.byKey.get('@' + m[2] + '@' + m[3]);
1320
+ // The path is a fallback only when tag and text agree, i.e. just the ordinal differs.
1321
+ if (!node && at != null) {
1322
+ const p = reg.byPath.get(String(at));
1323
+ if (p && p.base === m[3].replace(/#\d+$/, '')) node = p;
1324
+ else if (p && p.name.toUpperCase() === m[3].split(':')[0] && Array.isArray(kids) && kids.some(c => !/^~/.test(c.key))
1325
+ && kids.every(c => { if (/^~/.test(c.key)) return true; try { return resolve(c).node.parent === p; } catch { return false; } })) node = p;
1326
+ }
1327
+ if (!node) fail(`element '${m[3]}' not found in x-edit="${reg.name}": it changed or was removed since this page loaded; reload to continue`);
1328
+ // Required on source elements; a copy names one only when its source has one.
1329
+ if (typeof fp !== 'string' && !m[1] && node !== reg.root) fail(`'${m[3]}' came without the fingerprint it loaded with; reload to continue`);
1330
+ if (typeof fp === 'string' && node !== reg.root && fp !== node.fp) {
1331
+ if (fp.includes('.') || String(node.fp).includes('.')) fail(`identical elements '${m[3].replace(/#\d+$/, '')}' were reordered or changed since this page loaded; reload to continue`);
1332
+ fail(`'${m[3]}' changed or moved since this page loaded; reload to continue`);
1333
+ }
1334
+ return { node, copy: !!m[1] };
1335
+ };
1336
+ const indentOf = (n) => { const nl = html.lastIndexOf('\n', n.tagStart); const pre = html.slice(nl + 1, n.tagStart); return /^[ \t]*$/.test(pre) ? pre : ''; };
1337
+ const EOL = html.includes('\r\n') ? '\r\n' : '\n';
1338
+ // The separator a gap ends with: its last line break and indent (no blank lines, no comments).
1339
+ const trailingWs = (s) => { const t = s.replace(/<!--[\s\S]*?-->/g, ''); return (/\r?\n[ \t]*$/.exec(t) || /[ \t]*$/.exec(t))[0]; };
1340
+ const fragSkeleton = (frag) => { const d = parseHTML(frag); return kidsOf(frag, d).map(k => skeletonOf(frag, k)).join(','); };
1341
+ // Returns [markup, skeleton].
1342
+ // `anc`: tag names of the node's final ancestors, nearest first (the node itself excluded).
1343
+ function renderAt(node, n, copy, inRebuilt, anc) {
1344
+ let open = node.implied ? `<${node.name}>` : openOf(html, node);
1345
+ const e = n.edits || {};
1346
+ let attrsEdited = false;
1347
+ for (const [prop, v] of Object.entries(e)) {
1348
+ if (prop === 'text') continue;
1349
+ const val = (prop === 'class' || prop === 'style') && (v === '' || v == null) ? null : v === false ? null : v === true ? '' : v;
1350
+ const why = attrRefusal(prop, val, node.name);
1351
+ if (why) fail(`${prop} on ${node.name}: ${why}`);
1352
+ const cur = ((scanTag(open) || { attrs: [] }).attrs.find(a => a.lower === String(prop).toLowerCase()) || {}).value;
1353
+ if (cur && /\$modify\(/.test(cur)) fail(`${prop} on ${node.name} is set by a component prop ($modify); edit it where the component is used`);
1354
+ open = setAttr(open, prop, val == null ? null : String(val));
1355
+ attrsEdited = true;
1356
+ }
1357
+ if (node.implied && !attrsEdited) open = '';
1358
+ if (node.void) return [node.implied && !attrsEdited ? html.slice(node.tagStart, node.closeEnd) : open, node.name + '()'];
1359
+ let inner, sk;
1360
+ if ('text' in e) {
1361
+ if (RAW_EDIT.has(node.name)) fail(`text edits are refused on <${node.name}>`);
1362
+ inner = sanitizeHTML(e.text); sk = fragSkeleton(inner);
1363
+ const chain = [node.name, ...(anc || [])];
1364
+ for (let p = node.parent; !anc && p && p.name; p = p.parent) chain.push(p.name);
1365
+ const nested = allElements(parseHTML(inner)).find(k => CONTENT_RULES.interactive.includes(k.name) && (chain.includes('a') || chain.includes('button') || ancestorsOf(k).some(t => CONTENT_RULES.interactive.includes(t))));
1366
+ if (nested) fail(`text in <${node.name}>: <${nested.name}> is interactive content and cannot go inside a link or button`);
1367
+ } else if (Array.isArray(n.children)) [inner, sk] = n.path && !copy
1368
+ ? renderPathInner(node, n.children, [node.name, ...(anc || [])])
1369
+ : rebuildInner(node, n.children, copy, [node.name, ...(anc || [])], n.base);
1370
+ else { inner = html.slice(node.innerStart, node.innerEnd); sk = kidsOf(html, node).map(k => skeletonOf(html, k)).join(','); }
1371
+ let close = html.slice(node.innerEnd, node.closeEnd);
1372
+ if ((node.implied && attrsEdited) || (node.impliedEnd && inRebuilt && !node.implied)) close = `</${node.name}>`;
1373
+ return [open + inner + close, node.name + '(' + (node.name === 'template' ? '' : sk) + ')'];
1374
+ }
1375
+ function renderNode(n, parentRebuilt, anc) {
1376
+ if (!n || typeof n !== 'object') fail('malformed tree');
1377
+ const { node, copy, w } = resolve(n);
1378
+ if (w) { const was = current; try { return w.renderNode(n, parentRebuilt, anc, regionOfKey(n.key)); } finally { current = was; } }
1379
+ return renderHere(n, parentRebuilt, anc, node, copy);
1380
+ }
1381
+ function renderHere(n, parentRebuilt, anc, node, copy) {
1382
+ if (copy && [...regions.values()].some(r => r && r.root === node)) fail(`an area cannot be copied: ${n.key}`);
1383
+ if (!copy) {
1384
+ if (placed.has(node)) fail(`'${n.key}' placed twice`);
1385
+ if (removed.has(node.key)) fail(`'${n.key}' is both placed and removed`);
1386
+ placed.set(node, Array.isArray(n.children) ? (n.path ? 'path' : 'rebuilt') : 'kept');
1387
+ }
1388
+ const [markup, sk] = renderAt(node, n, copy, parentRebuilt, anc);
1389
+ return [(copy ? '' : html.slice(node.lead ?? node.tagStart, node.tagStart)) + markup, sk];
1390
+ }
1391
+ // Source ancestors of a node (for content-model checks on what moves in).
1392
+ // Tag names in a node's final subtree: listed children as placed, otherwise its source
1393
+ // subtree (template contents included — an x-if can render them here); text edits by
1394
+ // what they parse to.
1395
+ function finalSubtree(n, out = new Set()) {
1396
+ const { node } = resolve(n);
1397
+ const add = (name) => { out.add(name); const h = hostOf(name); if (h) h.tags.forEach(t => out.add(t)); };
1398
+ add(node.name);
1399
+ if (n.edits && 'text' in n.edits) { const f = sanitizeHTML(n.edits.text); allElements(parseHTML(f)).forEach(k => out.add(k.name)); }
1400
+ else if (Array.isArray(n.children)) n.children.forEach(c => finalSubtree(c, out));
1401
+ else allElements(node).forEach(k => add(k.name));
1402
+ return out;
1403
+ }
1404
+ // A container listed only as a path to edits: its current children, in the source's
1405
+ // current order; listed ones render with their edits, the rest stay as written.
1406
+ function renderPathInner(node, listed, anc) {
1407
+ const kids = node.kids || kidsOf(html, node);
1408
+ const want = new Map();
1409
+ for (const c of listed) {
1410
+ const { node: k, copy } = resolve(c);
1411
+ if (copy || k.parent !== node) fail(`'${c.key}' is no longer in its <${node.name}> (the source changed since this page loaded); reload to continue`);
1412
+ want.set(k, c);
1413
+ }
1414
+ let out = '', prev = node.innerStart;
1415
+ const sk = [];
1416
+ for (const k of kids) {
1417
+ const c = want.get(k);
1418
+ if (!c) { sk.push(skeletonOf(html, k)); continue; }
1419
+ out += html.slice(prev, k.lead ?? k.tagStart);
1420
+ renderedUnder.set(k, node);
1421
+ const [m, s2] = renderNode(c, false, anc);
1422
+ out += m; sk.push(s2); prev = k.closeEnd;
1423
+ }
1424
+ return [out + html.slice(prev, node.innerEnd), sk.join(',')];
1425
+ }
1426
+ function rebuildInner(node, children, copy, anc, base) {
1427
+ if (!copy) rebuilt.add(node);
1428
+ const kids = node.kids || kidsOf(html, node);
1429
+ // A restructured container must still hold what the page loaded with.
1430
+ if (Array.isArray(base) && !copy) {
1431
+ const changed = () => fail(`<${node.name}> ${node.key ? `'${node.key}' ` : ''}changed since this page loaded; reload to continue`);
1432
+ const current = kids.filter(k => k.name !== 'template');
1433
+ if (base.length !== current.length) changed();
1434
+ base.forEach((b, i) => { let r; try { r = resolve(b); } catch (e) { if (/reordered or changed/.test(e.message)) throw e; changed(); } if (r.node !== current[i]) changed(); });
1435
+ }
1436
+ for (const c of children) {
1437
+ const { node: child } = resolve(c);
1438
+ if (child.parent === node && !/^~/.test(c.key)) continue; // authored placement is left as written
1439
+ const sub = finalSubtree(c);
1440
+ const loop = ownComponent && [...sub].find(t => reaches(t, ownComponent));
1441
+ if (loop) fail(loop === ownComponent ? `<${ownComponent}> cannot go into its own component file` : `<${loop}> cannot go into ${basename(file)}: it includes <${ownComponent}>, which would then include itself`);
1442
+ const h = hostOf(child.name);
1443
+ const why = contentRefusal(h ? h.tag : child.name, node.name, anc, sub);
1444
+ if (why) fail(`<${child.name}> cannot go into <${node.name}>: ${why}`);
1445
+ }
1446
+ children.forEach(c => { const r = resolve(c); if (!r.copy) (r.w ? r.w.renderedUnder : renderedUnder).set(r.node, node); });
1447
+ const rendered = children.map(c => renderNode(c, true, anc));
1448
+ const sk = rendered.map(r => r[1]).join(',');
1449
+ const gaps = []; let prev = node.innerStart;
1450
+ for (const k of kids) { const start = k.lead ?? k.tagStart; gaps.push(html.slice(prev, start)); prev = k.closeEnd; }
1451
+ gaps.push(html.slice(prev, node.innerEnd));
1452
+ if (!kids.length) {
1453
+ if (!rendered.length) return [gaps[0], sk];
1454
+ const ind = indentOf(node);
1455
+ return [rendered.map(r => EOL + ind + ' ' + r[0]).join('') + EOL + ind, sk];
1456
+ }
1457
+ if (!rendered.length) return [trailingWs(gaps[kids.length]) || '', sk];
1458
+ const extra = trailingWs(kids.length > 1 ? gaps[kids.length - 1] : gaps[0]);
1459
+ let out = gaps[0];
1460
+ rendered.forEach((r, i) => { if (i > 0) out += i < kids.length ? gaps[i] : extra; out += r[0]; });
1461
+ return [out + gaps[kids.length], sk];
1462
+ }
1463
+
1464
+ const out = [];
1465
+ self.resolve = resolve;
1466
+ self.renderedUnder = renderedUnder;
1467
+ // An element of this file placed by another file's tree.
1468
+ self.renderNode = (n, parentRebuilt, anc, region) => { current = region; const r = resolve(n); return renderHere(n, parentRebuilt, anc, r.node, r.copy); };
1469
+ self.render = () => {
1470
+ for (const p of patches) {
1471
+ current = p.region;
1472
+ const reg = region(p.region);
1473
+ if (!reg) fail(`x-edit="${p.region}" not found in ${basename(file)}`);
1474
+ const t = p.tree || {};
1475
+ if (!t.edits && !Array.isArray(t.children)) continue;
1476
+ placed.set(reg.root, Array.isArray(t.children) ? (t.path ? 'path' : 'rebuilt') : 'kept');
1477
+ const rootAnc = []; for (let a = reg.root.parent; a && a !== doc; a = a.parent) rootAnc.push(a.name);
1478
+ const [markup, sk] = renderAt(reg.root, t, false, false, rootAnc);
1479
+ out.push({ reg, text: markup, sk });
1480
+ }
1481
+ };
1482
+ self.check = () => {
1483
+ for (const r of rebuilt) for (const k of r.kids || []) if (!placed.has(k) && !removed.has(k.key)) fail(`'${k.key}' would be lost (not placed, not removed)`, regionOfKey(k.key));
1484
+ const roots = new Set([...regions.values()].filter(Boolean).map(r => r.root));
1485
+ // Every placed element's source ancestors must be rebuilt (or removed), up to its area.
1486
+ for (const [node] of placed) {
1487
+ if (roots.has(node) || renderedUnder.get(node) === node.parent) continue; // in place: its parent is checked in turn
1488
+ for (let a = node.parent; a; a = a.parent) {
1489
+ // Its source parent must be rebuilt; higher up, a path (rendered from the tree) is fine too.
1490
+ const how = placed.get(a);
1491
+ if (how === 'rebuilt' || (how === 'path' && a !== node.parent)) { if (roots.has(a)) break; continue; }
1492
+ if (how) fail(`'${node.key}' would appear twice: its source container is kept as is`, regionOfKey(node.key));
1493
+ if (removed.has(a.key)) break;
1494
+ if (roots.has(a) || !a.parent || a.parent === doc) fail(`'${node.key}' moved, but its source area is not rebuilt`, regionOfKey(node.key));
1495
+ }
1496
+ }
1497
+ // A removal needs its container rebuilt (or an ancestor removed), or it stays in the file.
1498
+ for (const k of removed) {
1499
+ const m = /^@([^@]*)@/.exec(k); if (!m) fail(`not a source element: ${k}`, null);
1500
+ current = decodeURIComponent(m[1]);
1501
+ const reg = region(current); const node = reg && reg.byKey.get(k);
1502
+ if (!node) fail(`removed element '${k}' not found`);
1503
+ let ok = placed.get(node.parent) === 'rebuilt';
1504
+ for (let a = node.parent; !ok && a && !roots.has(a); a = a.parent) if (removed.has(a.key)) ok = true;
1505
+ if (!ok) fail(`'${k}' removed, but its container is kept as is`);
1506
+ }
1507
+ };
1508
+ // The written file must parse into exactly the structure that was rendered.
1509
+ self.assemble = () => {
1510
+ let next = html;
1511
+ out.slice().sort((a, b) => b.reg.root.tagStart - a.reg.root.tagStart).forEach(({ reg, text }) => { next = next.slice(0, reg.root.tagStart) + text + next.slice(reg.root.closeEnd); });
1512
+ if (next === html) return { next: null };
1513
+ const after = parseHTML(next);
1514
+ for (const { reg, sk } of out) {
1515
+ const node = locateEditEl(next, reg.name, after);
1516
+ const got = node && skeletonOf(next, node), want = sk;
1517
+ if (got !== want) return { error: `x-edit="${reg.name}" would not parse as rebuilt (${skeletonDiff(want, got)}); nothing written` };
1518
+ }
1519
+ return { next };
1520
+ };
1521
+ return self;
1522
+ }
1523
+ // Component files: the first top-level element, then paths through element children.
1524
+ function navigateToNode(html, path) {
1525
+ const doc = parseHTML(html);
1526
+ let node = kidsOf(html, doc)[0]; if (!node) return null;
857
1527
  if (!path) return node;
858
- for (const i of path.split('.').map(Number)) { node = childAt(html, node.innerStart, node.innerEnd, i); if (!node) return null; }
1528
+ for (const i of path.split('.').map(Number)) { node = kidsOf(html, node)[i]; if (!node) return null; }
859
1529
  return node;
860
1530
  }
1531
+ // A project-relative path from manifest.json ("components/x.html", "/data/a.csv").
1532
+ const resolveRel = (rel) => safeResolve('/' + String(rel).replace(/^\/+/, ''));
1533
+ // A component the project declares: a manifest.json entry (matched by file name, as the
1534
+ // client names components by tag), or the convention components/<name>.html.
1535
+ const COMPONENT_NAME = /^[\w-]+(\/[\w-]+)*$/;
861
1536
  function resolveComponentFile(manifest, name) {
1537
+ if (typeof name !== 'string' || !COMPONENT_NAME.test(name)) return null;
862
1538
  const all = [...(manifest.preloadedComponents || []), ...(manifest.components || [])];
863
- const listed = all.find(p => String(p).split('/').pop().replace('.html', '') === name);
864
- if (listed) return listed;
1539
+ const listed = all.find(p => String(p).split('/').pop().replace('.html', '') === name.split('/').pop());
1540
+ if (listed) { const rel = String(listed).replace(/^\/+/, ''); return /(^|\/)\.\.?(\/|$)/.test(rel) ? null : rel; }
865
1541
  // Convention component: components/<name>.html needs no manifest entry.
866
1542
  const rel = 'components/' + name + '.html';
867
1543
  const file = safeResolve('/' + rel);
@@ -873,24 +1549,99 @@ function resolveComponentFile(manifest, name) {
873
1549
  // standard theme file. Surgical — rewrites the existing declaration in place, else
874
1550
  // appends into the first :root{} block, else creates one.
875
1551
  const DEFAULT_THEME_FILE = 'styles/core/manifest.theme.css';
1552
+ // A custom-property value must stay inside its declaration. A small tokenizer: strings
1553
+ // and url(...) are opaque, so ; { } and comment openers are refused only outside them.
1554
+ // Script schemes in url() and expression() are refused anywhere; data: urls only as images.
1555
+ function themeValueRefusal(v) {
1556
+ const s = String(v == null ? '' : v);
1557
+ if (!s.trim()) return 'empty';
1558
+ if (/expression\s*\(/i.test(s)) return 'expression()';
1559
+ if (/`|\$\(/.test(s)) return 'contains ` or $(';
1560
+ const stack = [];
1561
+ for (let i = 0; i < s.length; i++) {
1562
+ const ch = s[i];
1563
+ if (ch === '"' || ch === "'") {
1564
+ let j = i + 1;
1565
+ for (; j < s.length && s[j] !== ch; j++) { if (s[j] === '\\') j++; else if (s[j] === '\n') return 'unbalanced quotes'; }
1566
+ if (j >= s.length) return 'unbalanced quotes';
1567
+ i = j; continue;
1568
+ }
1569
+ if (/^url\(/i.test(s.slice(i, i + 4)) && !/[\w-]/.test(s[i - 1] || '')) {
1570
+ let j = i + 4, q = null, body = '';
1571
+ for (; j < s.length; j++) {
1572
+ const c = s[j];
1573
+ if (q) { if (c === '\\') { body += s[++j] || ''; continue; } if (c === q) { q = null; continue; } body += c; continue; }
1574
+ if (c === '"' || c === "'") { q = c; continue; }
1575
+ if (c === ')') break;
1576
+ body += c;
1577
+ }
1578
+ if (j >= s.length) return 'unterminated url()';
1579
+ const u = body.replace(/[\u0000-\u0020\u007f]/g, '').toLowerCase();
1580
+ if (/^(javascript|vbscript):/.test(u)) return 'script URL';
1581
+ if (u.startsWith('data:') && !u.startsWith('data:image/')) return 'data URL';
1582
+ i = j; continue;
1583
+ }
1584
+ if (ch === ';' || ch === '{' || ch === '}') return `contains ${ch}`;
1585
+ if (s.startsWith('/*', i) || s.startsWith('<!--', i) || s.startsWith('</', i)) return 'contains a comment or markup';
1586
+ if (ch === '(' || ch === '[') stack.push(ch);
1587
+ else if (ch === ')' || ch === ']') { if (stack.pop() !== (ch === ')' ? '(' : '[')) return 'unbalanced brackets'; }
1588
+ }
1589
+ return stack.length ? 'unbalanced brackets' : null;
1590
+ }
1591
+ // Stylesheets a theme edit may write: the default theme file, stylesheets index.html
1592
+ // links, and files it names in data-edit-theme-file (what the client sends).
1593
+ function themeTargets() {
1594
+ const out = new Set([DEFAULT_THEME_FILE]);
1595
+ let html = '';
1596
+ try { html = readFileSync(join(root, 'index.html'), 'utf8'); } catch { return out; } // read off-batch: not a base-checked input
1597
+ const add = (href) => {
1598
+ const h = String(href || '').trim().split(/[?#]/)[0];
1599
+ if (!h || /^[a-z][\w+.-]*:|^\/\//i.test(h)) return; // other origins and schemes
1600
+ const rel = posix.normalize(h.replace(/^\/+/, ''));
1601
+ if (!rel.startsWith('..')) out.add(rel);
1602
+ };
1603
+ for (const n of allElements(parseHTML(html))) {
1604
+ if (n.implied) continue;
1605
+ const open = openOf(html, n);
1606
+ if (n.name === 'link' && /(^|\s)stylesheet(\s|$)/i.test(attrOf(open, 'rel') || '')) add(attrOf(open, 'href'));
1607
+ const tf = attrOf(open, 'data-edit-theme-file'); if (tf != null) add(tf);
1608
+ }
1609
+ return out;
1610
+ }
876
1611
  function writeThemeVar(p) {
877
1612
  if (!/^--[\w-]+$/.test(p.var || '')) return { region: p.var, status: 'error', reason: 'bad var name' };
878
- const rel = (p.file || DEFAULT_THEME_FILE).replace(/^\//, '');
1613
+ const rel = posix.normalize(String(p.file || DEFAULT_THEME_FILE).replace(/^\/+/, ''));
1614
+ if (!themeTargets().has(rel)) return { region: p.var, status: 'error', reason: `${rel} is not a stylesheet this page uses (link it, or name it in data-edit-theme-file)` };
879
1615
  const file = safeResolve('/' + rel);
880
1616
  if (!file) return { region: p.var, status: 'error', reason: `path outside root: ${rel}` };
881
- let css = isFile(file) ? readFileSync(file, 'utf8') : '';
882
- const decl = `${p.var}: ${p.value};`;
883
- const declRe = new RegExp(`(${escapeReg(p.var)}\\s*:)[^;]*;`);
884
- if (declRe.test(css)) css = css.replace(declRe, `$1 ${p.value};`); // rewrite in place
885
- else if (/:root\s*[^{]*\{/.test(css)) css = css.replace(/(:root\s*[^{]*\{)/, `$1\n ${decl}`); // append into :root
886
- else css = `:root {\n ${decl}\n}\n${css}`; // create :root
887
- writeFileSync(file, css);
1617
+ const refused = writeRefusal(file, 'css'); if (refused) return { region: p.var, status: 'error', reason: `refused: ${refused}` };
1618
+ const why = themeValueRefusal(p.value);
1619
+ if (why) return { region: p.var, status: 'error', reason: `value refused (${why})` };
1620
+ const value = String(p.value).trim();
1621
+ let css = vexists(file) ? vread(file) : '';
1622
+ const decl = `${p.var}: ${value};`;
1623
+ const head = new RegExp(`(^|[\\s;{])${escapeReg(p.var)}\\s*:`).exec(css);
1624
+ if (head) {
1625
+ // The declaration ends at the first ; or } outside strings and url(), as CSS reads it.
1626
+ const from = head.index + head[0].length;
1627
+ let i = from;
1628
+ for (; i < css.length; i++) {
1629
+ const ch = css[i];
1630
+ if (ch === '"' || ch === "'") { for (i++; i < css.length && css[i] !== ch; i++) if (css[i] === '\\') i++; continue; }
1631
+ if (/^url\(/i.test(css.slice(i, i + 4))) { const e = css.indexOf(')', i); i = e < 0 ? css.length : e; continue; }
1632
+ if (ch === ';' || ch === '}') break;
1633
+ }
1634
+ css = css.slice(0, from) + ' ' + value + (css[i] === ';' ? '' : ';') + css.slice(i);
1635
+ } else if (/:root\s*[^{]*\{/.test(css)) css = css.replace(/(:root\s*[^{]*\{)/, (m) => `${m}\n ${decl}`);
1636
+ else css = `:root {\n ${decl}\n}\n${css}`;
1637
+ vwrite(file, css);
888
1638
  return { region: `${p.scope ? p.scope + ':' : ''}${p.var}`, status: 'written', file: rel };
889
1639
  }
890
- const modParamIn = (openTag, attr) => { const m = openTag.match(new RegExp(escapeReg(attr) + `\\s*=\\s*("[^"]*"|'[^']*')`)); if (!m) return null; const mm = m[1].slice(1, -1).match(/\$modify\(['"]([\w-]+)['"]\)/); return mm ? mm[1] : null; };
1640
+ const modParamIn = (openTag, attr) => { const v = attrOf(openTag, attr); const mm = v && v.match(/\$modify\(['"]([\w-]+)['"]\)/); return mm ? mm[1] : null; };
1641
+ // A value placed inside a single-quoted JS string in an Alpine expression.
1642
+ const jsStr = (v) => String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r?\n/g, '\\n').replace(/\u2028|\u2029/g, '');
891
1643
  const allModifyParams = (html) => { const s = new Set(); let m; const re = /\$modify\(['"]([\w-]+)['"]\)/g; while ((m = re.exec(html))) s.add(m[1]); return [...s]; };
892
- const removeAttr = (openTag, name) => openTag.replace(new RegExp(`\\s${escapeReg(name)}\\s*=\\s*(?:"[^"]*"|'[^']*')`, 'g'), '');
893
- const classOf = (openTag) => { const m = openTag.match(/\sclass\s*=\s*("[^"]*"|'[^']*')/); return m ? m[1].slice(1, -1) : ''; };
1644
+ const classOf = (openTag) => attrOf(openTag, 'class') || '';
894
1645
 
895
1646
  // Component edits, per node (structural path), per prop (text|class), routed by scope.
896
1647
  // main → edit the component source literal/class (affects every instance)
@@ -900,44 +1651,57 @@ const classOf = (openTag) => { const m = openTag.match(/\sclass\s*=\s*("[^"]*"|'
900
1651
  function writeComponentEdits(p, manifest) {
901
1652
  const compRel = resolveComponentFile(manifest, p.component);
902
1653
  if (!compRel) return { region: p.region, status: 'error', reason: `component '${p.component}' not registered` };
903
- const compFile = safeResolve(compRel);
1654
+ const compFile = resolveRel(compRel);
904
1655
  if (!compFile || !isFile(compFile)) return { region: p.region, status: 'error', reason: `component file not found: ${compRel}` };
1656
+ const refused = writeRefusal(compFile, 'html'); if (refused) return { region: p.region, status: 'error', reason: `refused: ${refused}` };
905
1657
  const indexFile = join(root, 'index.html'), instanceAttrs = {}, removeNames = new Set(), applied = [];
906
1658
  for (const ed of (p.edits || [])) {
907
1659
  const prop = ed.prop || 'text';
908
- let html = readFileSync(compFile, 'utf8');
1660
+ let html = vread(compFile);
909
1661
  const node = navigateToNode(html, ed.path);
910
1662
  if (!node) { applied.push({ path: ed.path, status: 'not-found' }); continue; }
911
1663
  let openTag = html.slice(node.tagStart, node.openEnd + 1);
912
1664
  const seg = ed.path === '' ? 'root' : ed.path.replace(/\./g, '_');
913
1665
 
1666
+ if (prop === 'style') {
1667
+ if (p.scope !== 'main') { applied.push({ path: ed.path, prop, status: 'skipped', reason: 'instance styles are not written' }); continue; }
1668
+ const why = attrRefusal('style', ed.value, node.tag);
1669
+ if (why) { applied.push({ path: ed.path, prop, status: 'error', reason: why }); continue; }
1670
+ vwrite(compFile, html.slice(0, node.tagStart) + setAttr(openTag, 'style', ed.value ? String(ed.value) : null) + html.slice(node.openEnd + 1));
1671
+ applied.push({ path: ed.path, prop, status: 'main' });
1672
+ continue;
1673
+ }
914
1674
  if (prop === 'class') {
915
1675
  const param = modParamIn(openTag, ':class');
916
- if (p.scope === 'main') { writeFileSync(compFile, html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1)); applied.push({ path: ed.path, prop, status: 'main' }); }
1676
+ if (p.scope === 'main') { vwrite(compFile, html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value ? String(ed.value) : null) + html.slice(node.openEnd + 1)); applied.push({ path: ed.path, prop, status: 'main' }); }
917
1677
  else if (param) { instanceAttrs[param] = ed.value; applied.push({ path: ed.path, prop, status: 'instance', param }); }
918
- else { const name = 'c_' + seg; writeFileSync(compFile, html.slice(0, node.tagStart) + setAttr(openTag, ':class', `$modify('${name}') ?? '${classOf(openTag).replace(/'/g, "\\'")}'`) + html.slice(node.openEnd + 1)); instanceAttrs[name] = ed.value; applied.push({ path: ed.path, prop, status: 'promoted', param: name }); }
1678
+ else { const name = 'c_' + seg; vwrite(compFile, html.slice(0, node.tagStart) + setAttr(openTag, ':class', `$modify('${name}') ?? '${jsStr(classOf(openTag))}'`) + html.slice(node.openEnd + 1)); instanceAttrs[name] = ed.value; applied.push({ path: ed.path, prop, status: 'promoted', param: name }); }
919
1679
  continue;
920
1680
  }
921
1681
 
922
- // text — value is innerHTML; may contain nested elements (<i>/<br>), bound via x-html.
923
- const hasMarkup = /<[a-z!\/][\s\S]*>/i.test(ed.value);
1682
+ // text — value is innerHTML, sanitized like the client's; bound via x-html when it has markup.
1683
+ const value = sanitizeHTML(ed.value);
1684
+ const hasMarkup = /<[a-z!\/][\s\S]*>/i.test(value);
924
1685
  const param = modParamIn(openTag, 'x-html') || modParamIn(openTag, 'x-text');
925
1686
  if (p.scope === 'main') {
926
- if (param) writeFileSync(compFile, html.slice(0, node.tagStart) + openTag.replace(/((?:\?\?|\|\|)\s*)(['"]).*?\2/, `$1'${ed.value.replace(/'/g, "\\'")}'`) + html.slice(node.openEnd + 1));
927
- else writeFileSync(compFile, html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd)); // raw HTML literal
1687
+ if (param) {
1688
+ const bound = attrOf(openTag, 'x-html') != null && modParamIn(openTag, 'x-html') ? 'x-html' : 'x-text';
1689
+ const expr = attrOf(openTag, bound).replace(/((?:\?\?|\|\|)\s*)(['"])(?:\\.|(?!\2)[^\\])*\2/, (_, op) => `${op}'${jsStr(value)}'`);
1690
+ vwrite(compFile, html.slice(0, node.tagStart) + setAttr(openTag, bound, expr) + html.slice(node.openEnd + 1));
1691
+ } else vwrite(compFile, html.slice(0, node.innerStart) + value + html.slice(node.innerEnd));
928
1692
  applied.push({ path: ed.path, prop, status: 'main' });
929
1693
  } else if (param) {
930
- if (hasMarkup && /\sx-text\s*=/.test(openTag) && !/\sx-html\s*=/.test(openTag)) { writeFileSync(compFile, html.slice(0, node.tagStart) + openTag.replace(/\sx-text(\s*=)/, ' x-html$1') + html.slice(node.openEnd + 1)); }
931
- instanceAttrs[param] = ed.value; applied.push({ path: ed.path, prop, status: 'instance', param });
1694
+ if (hasMarkup && attrOf(openTag, 'x-text') != null && attrOf(openTag, 'x-html') == null) { vwrite(compFile, html.slice(0, node.tagStart) + setAttr(setAttr(openTag, 'x-text', null), 'x-html', attrOf(openTag, 'x-text')) + html.slice(node.openEnd + 1)); }
1695
+ instanceAttrs[param] = value; applied.push({ path: ed.path, prop, status: 'instance', param });
932
1696
  } else { // PROMOTE: x-html if rich, else x-text
933
1697
  const name = 'p_' + seg, bind = hasMarkup ? 'x-html' : 'x-text';
934
- const orig = html.slice(node.innerStart, node.innerEnd).trim().replace(/'/g, "\\'");
935
- writeFileSync(compFile, html.slice(0, node.tagStart) + setAttr(openTag, bind, `$modify('${name}') ?? '${orig}'`) + html.slice(node.openEnd + 1));
936
- instanceAttrs[name] = ed.value; applied.push({ path: ed.path, prop, status: 'promoted', param: name, bind });
1698
+ const orig = jsStr(html.slice(node.innerStart, node.innerEnd).trim());
1699
+ vwrite(compFile, html.slice(0, node.tagStart) + setAttr(openTag, bind, `$modify('${name}') ?? '${orig}'`) + html.slice(node.openEnd + 1));
1700
+ instanceAttrs[name] = value; applied.push({ path: ed.path, prop, status: 'promoted', param: name, bind });
937
1701
  }
938
1702
  }
939
1703
  if (p.scope === 'instance' && (p.reverts || []).length) {
940
- const html = readFileSync(compFile, 'utf8');
1704
+ const html = vread(compFile);
941
1705
  for (const rp of p.reverts) {
942
1706
  if (rp === '*') { allModifyParams(html).forEach(n => removeNames.add(n)); applied.push({ path: '*', status: 'revert-all' }); continue; }
943
1707
  const node = navigateToNode(html, rp); if (!node) continue;
@@ -947,22 +1711,23 @@ function writeComponentEdits(p, manifest) {
947
1711
  }
948
1712
  }
949
1713
  const inst = (Object.keys(instanceAttrs).length || removeNames.size) ? writeComponentInstance(indexFile, p.region, instanceAttrs, [...removeNames]) : null;
1714
+ const failed = applied.find(x => x.status === 'error' || x.status === 'not-found') || (inst && inst.status === 'error' && inst);
1715
+ if (failed) return { region: p.region, status: 'error', reason: failed.reason || `node ${failed.path} not found`, applied };
950
1716
  return { region: p.region, status: 'written', scope: p.scope, file: basename(compFile), applied, instance: inst && inst.status };
951
1717
  }
952
1718
  function writeComponentInstance(file, key, overrides, removals) {
953
- let html = readFileSync(file, 'utf8');
1719
+ const refused = writeRefusal(file, 'html'); if (refused) return { region: key, status: 'error', reason: `refused: ${refused}` };
1720
+ let html = vread(file);
954
1721
  const loc = locateEditEl(html, key);
955
1722
  if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
956
- const inner = html.slice(loc.innerStart, loc.innerEnd);
957
- const cm = /<x-[\w-]+/.exec(inner); // first component instance in the region
1723
+ const cm = allElements(loc).find(n => /^x-/.test(n.name) && !n.implied); // first component instance in the region
958
1724
  if (!cm) return { region: key, status: 'error', reason: 'no <x-*> instance in region' };
959
- const instStart = loc.innerStart + cm.index, instEnd = tagEnd(html, instStart);
960
- if (instEnd < 0) return { region: key, status: 'error', reason: 'malformed instance tag' };
1725
+ const instStart = cm.tagStart, instEnd = cm.openEnd;
961
1726
  let openTag = html.slice(instStart, instEnd + 1);
962
- for (const [k, v] of Object.entries(overrides || {})) openTag = setAttr(openTag, k, v);
1727
+ for (const [k, v] of Object.entries(overrides || {})) { if (attrRefusal(k, null, 'x')) continue; openTag = setAttr(openTag, k, v == null ? null : String(v)); }
963
1728
  for (const name of (removals || [])) openTag = removeAttr(openTag, name);
964
1729
  html = html.slice(0, instStart) + openTag + html.slice(instEnd + 1);
965
- writeFileSync(file, html);
1730
+ vwrite(file, html);
966
1731
  return { region: key, status: 'written', file: basename(file), applied: Object.keys(overrides || {}), removed: removals || [] };
967
1732
  }
968
1733
 
@@ -1236,39 +2001,92 @@ const server = createServer((req, res) => {
1236
2001
  return;
1237
2002
  }
1238
2003
 
2004
+ if (urlPath === '/__edit/base') {
2005
+ if (!EDIT_ENABLED) { res.writeHead(404); res.end(); return; }
2006
+ if (listenPort && req.headers.origin && !isLocalOrigin(req.headers.origin, listenPort)) { res.writeHead(403); res.end(); return; }
2007
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
2008
+ const files = baseHashes();
2009
+ res.end(JSON.stringify({ files, ids: allSourceIds(Object.keys(files)) }));
2010
+ return;
2011
+ }
2012
+
1239
2013
  if (urlPath === '/__edit/save') {
1240
2014
  if (!EDIT_ENABLED) { res.writeHead(404); res.end(); return; } // opt-in (--edit / MNFST_EDIT=1): source write-back is authoring-only
1241
2015
  if (req.method !== 'POST') { res.writeHead(405, { 'Allow': 'POST' }); res.end(); return; }
1242
2016
  if (listenPort && !isLocalOrigin(req.headers.origin, listenPort)) { res.writeHead(403); res.end(); return; }
1243
2017
  let raw = '';
1244
2018
  req.on('data', c => { raw += c; if (raw.length > 1e6) req.destroy(); });
1245
- req.on('end', () => {
2019
+ const reply = (code, body) => { if (res.headersSent) { try { res.end(); } catch { } return; } res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
2020
+ req.on('end', () => { try {
1246
2021
  let patches; try { patches = JSON.parse(raw); } catch { res.writeHead(400); res.end('bad json'); return; }
2022
+ // Body: an array of patches, or { patches, base } where base maps files to the hashes the page loaded with.
2023
+ let base = null;
2024
+ if (patches && !Array.isArray(patches) && Array.isArray(patches.patches)) { base = patches.base && typeof patches.base === 'object' ? patches.base : null; patches = patches.patches; }
1247
2025
  if (!Array.isArray(patches)) patches = [patches];
1248
2026
  let manifest = {}; try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); } catch {}
1249
2027
  const indexFile = join(root, 'index.html');
1250
- const moveAliases = {}; // region → [{key: live toKey, slice}] from this batch's moves
1251
- const results = patches.map(p => {
1252
- try {
1253
- if (p.kind === 'data') {
1254
- const src = (manifest.data || {})[p.source];
1255
- const rel = typeof src === 'string' ? src : null;
1256
- if (!rel) return { region: p.region, status: 'error', reason: `data source '${p.source}' is not a plain file path` };
1257
- const file = safeResolve(rel);
1258
- if (!file || !isFile(file)) return { region: p.region, status: 'error', reason: `file not found: ${rel}` };
1259
- return { region: p.region, status: reorderDataFile(file, p.order) ? 'written' : 'noop', file: rel };
2028
+ // All or nothing: every patch is prepared against an in-memory overlay; files are
2029
+ // written only if none of them failed.
2030
+ VFS = new Map(); READS = new Set();
2031
+ let results, written = null;
2032
+ try {
2033
+ // Static areas: index.html, or the component file a region is authored in.
2034
+ const trees = patches.filter(p => p && p.kind === 'static-tree');
2035
+ const treeResults = new Map(), byFile = new Map();
2036
+ for (const p of trees) {
2037
+ let file = indexFile;
2038
+ if (p.component != null) {
2039
+ const rel = resolveComponentFile(manifest, p.component);
2040
+ if (!rel) { treeResults.set(p, { region: p.region, status: 'error', reason: `component '${p.component}' not registered` }); continue; }
2041
+ file = resolveRel(rel);
2042
+ if (!file || !isFile(file)) { treeResults.set(p, { region: p.region, status: 'error', reason: `file not found: ${rel}` }); continue; }
1260
2043
  }
1261
- if (p.kind === 'data-val') return writeDataValue(p, manifest);
1262
- if (p.kind === 'static') return writeStaticOps(indexFile, p.region, p.edits, p.order, moveAliases[p.region]);
1263
- if (p.kind === 'static-move') return writeStaticMove(indexFile, p, moveAliases);
1264
- if (p.kind === 'component') return writeComponentEdits(p, manifest);
1265
- if (p.kind === 'theme') return writeThemeVar(p);
1266
- return { region: p.region, status: 'skipped', reason: `unknown kind ${p.kind}` };
1267
- } catch (e) { return { region: p.region, status: 'error', reason: e.message }; }
1268
- });
1269
- res.writeHead(200, { 'Content-Type': 'application/json' });
1270
- res.end(JSON.stringify({ results }));
1271
- });
2044
+ if (!byFile.has(file)) byFile.set(file, []);
2045
+ byFile.get(file).push(p);
2046
+ }
2047
+ if (byFile.size) {
2048
+ try { writeStaticTrees([...byFile], manifest).forEach((r, p) => treeResults.set(p, r)); }
2049
+ catch (e) { [...byFile.values()].flat().forEach(p => treeResults.set(p, { region: p.region, status: 'error', reason: e.message })); }
2050
+ }
2051
+ results = patches.map(p => {
2052
+ if (p && p.kind === 'static-tree') return treeResults.get(p);
2053
+ try {
2054
+ if (!p || typeof p !== 'object') return { status: 'error', reason: 'malformed patch' };
2055
+ if (p.kind === 'data') {
2056
+ const src = (manifest.data || {})[p.source];
2057
+ const rel = typeof src === 'string' ? src : null;
2058
+ if (!rel) return { region: p.region, status: 'error', reason: `data source '${p.source}' is not a plain file path` };
2059
+ const file = resolveRel(rel);
2060
+ if (!file || !isFile(file)) return { region: p.region, status: 'error', reason: `file not found: ${rel}` };
2061
+ const refused = writeRefusal(file, 'data'); if (refused) return { region: p.region, status: 'error', reason: `refused: ${refused}` };
2062
+ return { region: p.region, status: reorderDataFile(file, p.order) ? 'written' : 'noop', file: rel };
2063
+ }
2064
+ if (p.kind === 'data-val') return writeDataValue(p, manifest);
2065
+ if (p.kind === 'component') return writeComponentEdits(p, manifest);
2066
+ if (p.kind === 'theme') return writeThemeVar(p);
2067
+ return { region: p.region, status: 'error', reason: `unknown kind ${p.kind}` };
2068
+ } catch (e) { return { region: p && p.region, status: 'error', reason: e.message }; }
2069
+ });
2070
+ const abort = (why) => results.map(r => r && r.status === 'error' ? r : Object.assign({}, r, { status: 'skipped', reason: why }));
2071
+ // Refuse the whole batch if a file it read changed since the page loaded.
2072
+ // Structural rewrites (HTML) need the version the page loaded; CSS declarations and
2073
+ // data records are written by key, so changes elsewhere in those files are fine.
2074
+ const html = [...new Set([...READS, ...VFS.keys()].map(f => relKey(relative(root, f))))].filter(rel => extname(rel).toLowerCase() === '.html');
2075
+ const staleFiles = !base ? [] : html.filter(rel => !(rel in base) || (() => { try { return fileHash(join(root, rel)) !== base[rel]; } catch { return true; } })());
2076
+ if (html.length && !base) results = results.map(r => Object.assign({}, r, { status: 'error', reason: 'the publish carries no base (the source hashes the page loaded with); reload the page and publish again' }));
2077
+ else if (staleFiles.length) results = results.map(r => Object.assign({}, r, { status: 'error', stale: staleFiles, reason: `source changed since this page loaded (${staleFiles.join(', ')}); reload, or publish again with rebase to write over it` }));
2078
+ else if (results.some(r => r && r.status === 'error')) results = abort('batch aborted: another patch failed; nothing was written');
2079
+ else {
2080
+ try { commitFiles(VFS); written = {}; for (const f of VFS.keys()) { const rel = relKey(relative(root, f)); try { written[rel] = fileHash(f); } catch { } } }
2081
+ catch (e) { results = results.map(r => Object.assign({}, r, { status: 'error', reason: `nothing written: ${e.message}` })); }
2082
+ }
2083
+ } finally { VFS = null; READS = null; }
2084
+ const stale = results.find(r => r && r.stale);
2085
+ reply(200, written ? { results, base: written, ids: allSourceIds(Object.keys(baseHashes())) } : stale ? { results, stale: stale.stale } : { results });
2086
+ } catch (e) {
2087
+ console.error('[mnfst-run] /__edit/save failed:', e);
2088
+ reply(500, { results: [], error: String(e && e.message || e) });
2089
+ } });
1272
2090
  return;
1273
2091
  }
1274
2092