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/html-entities.json +1 -0
- package/package.json +2 -1
- package/serve.mjs +1086 -236
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
|
-
|
|
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
|
-
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
broadcast({ type: '
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
659
|
-
const file =
|
|
660
|
-
const
|
|
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;
|
|
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
|
-
|
|
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
|
|
684
|
-
// packages/run is zero-dep by design, so
|
|
685
|
-
//
|
|
686
|
-
//
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
874
|
+
while (i > m[0].length && /\s/.test(openTag[i - 1])) i--;
|
|
875
|
+
return { tag: m[1], attrs, end: i };
|
|
707
876
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
const
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
877
|
+
const escAttr = (v) => String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if (
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
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
|
-
//
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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 += '<'; 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
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
if (
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
856
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
//
|
|
873
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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') {
|
|
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;
|
|
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
|
|
923
|
-
const
|
|
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)
|
|
927
|
-
|
|
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 &&
|
|
931
|
-
instanceAttrs[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()
|
|
935
|
-
|
|
936
|
-
instanceAttrs[name] =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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 (
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
|