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