mnfst-run 1.0.24 → 1.0.26

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