mnfst-run 1.0.25 → 1.0.27

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/serve.mjs +44 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "description": "Zero-dependency dev server for Manifest projects",
5
5
  "type": "module",
6
6
  "bin": {
package/serve.mjs CHANGED
@@ -382,6 +382,8 @@ const APPWRITE_PROXY_TARGET = (process.env.APPWRITE_PROXY_TARGET || privateEnv.A
382
382
  const APPWRITE_PROXY_PREFIX = '/_appwrite';
383
383
 
384
384
  let envInjectScript = buildEnvInjectScript(publicEnv);
385
+ // Edit mode marker: the edit plugin reads /__edit/base only when it is served
386
+ if (EDIT_ENABLED) envInjectScript += '<script>window.__mnfstEdit=true;</script>';
385
387
  if (APPWRITE_PROXY_TARGET) {
386
388
  // Point the Appwrite endpoint at the same-origin proxy. Computed in the browser
387
389
  // from location.origin so it's port-independent (auto-port safe) and prod-safe
@@ -792,11 +794,15 @@ function parseCsvLine(line) {
792
794
  out.push(cur); return out;
793
795
  }
794
796
  const csvCell = (v) => /[",\n]/.test(v) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v);
797
+ // Why a data source is not a file mnfst-run writes.
798
+ const dataSourceRefusal = (name, manifest) => (manifest.data || {})[name] == null
799
+ ? `data source '${name}' is not declared in manifest.json: mnfst-run writes data only to CSV/JSON/YAML files declared there`
800
+ : `data source '${name}' is not a file (a cloud or remote source): save it with $x.${name}.$update, not mnfst-run`;
795
801
  // Data-value write-back (local CSV/JSON cell). Cloud sources are not files → caller should
796
802
  // persist via $x.<source>.$update(id,{field}) instead.
797
803
  function writeDataValue(p, manifest) {
798
804
  const src = (manifest.data || {})[p.source], rel = typeof src === 'string' ? src : null;
799
- if (!rel) return { kind: 'data-val', source: p.source, status: 'skipped', reason: 'not a file source (cloud → use $x.$update)' };
805
+ if (!rel) return { kind: 'data-val', source: p.source, status: 'error', reason: dataSourceRefusal(p.source, manifest) };
800
806
  const file = resolveRel(rel); if (!file || !isFile(file)) return { kind: 'data-val', source: p.source, status: 'error', reason: `file not found: ${rel}` };
801
807
  const refused = writeRefusal(file, 'data'); if (refused) return { kind: 'data-val', source: p.source, status: 'error', reason: `refused: ${refused}` };
802
808
  const ext = extname(file).toLowerCase(), text = vread(file);
@@ -1085,7 +1091,7 @@ const SAN_DROP = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link'
1085
1091
  // The one attribute Rich Text keeps on each of these inline tags.
1086
1092
  const TEXT_ATTR = { abbr: 'title', dfn: 'title', bdo: 'dir', data: 'value', time: 'datetime' };
1087
1093
  function textAttrOk(tag, n, v) {
1088
- if (tag === 'a' && n === 'href') return !attrRefusal(n, v, tag);
1094
+ if (tag === 'a' && (n === 'href' || n === 'title')) return !attrRefusal(n, v, tag);
1089
1095
  if (tag === 'img' && (n === 'alt' || n === 'src')) return !attrRefusal(n, v, tag);
1090
1096
  if (TEXT_ATTR[tag] === n) return n === 'dir' ? /^(ltr|rtl|auto)$/i.test(v) : !attrRefusal(n, v, tag);
1091
1097
  if (tag === 'input') return n === 'type' ? /^checkbox$/i.test(v) : n === 'checked';
@@ -1115,7 +1121,10 @@ function sanitizeHTML(html) {
1115
1121
  if (!SAN_OK.has(name)) continue;
1116
1122
  if (closing) { out += `</${name}>`; continue; }
1117
1123
  let tag = '<' + name;
1124
+ const seen = new Set();
1118
1125
  for (const a of scanTag(s.slice(lt, end + 1)).attrs) {
1126
+ if (seen.has(a.lower)) continue; // the first of a repeated attribute wins, as in the browser
1127
+ seen.add(a.lower);
1119
1128
  const v = a.value == null ? '' : decodeEntities(a.value, true);
1120
1129
  if (textAttrOk(name, a.lower, v)) tag += a.lower === 'checked' ? ' checked' : ` ${a.lower}="${escAttr(v)}"`;
1121
1130
  }
@@ -1545,8 +1554,8 @@ function resolveComponentFile(manifest, name) {
1545
1554
  }
1546
1555
 
1547
1556
  // Theme var write: rewrite (or append) a single `--var: value;` in the target CSS file.
1548
- // Scoped vars carry their file (data-edit-theme-file); global vars fall back to the
1549
- // standard theme file. Surgical — rewrites the existing declaration in place, else
1557
+ // Global vars go to the theme stylesheet the page links (else the default path);
1558
+ // a patch may name another stylesheet the page links. Surgical — rewrites the existing declaration in place, else
1550
1559
  // appends into the first :root{} block, else creates one.
1551
1560
  const DEFAULT_THEME_FILE = 'styles/core/manifest.theme.css';
1552
1561
  // A custom-property value must stay inside its declaration. A small tokenizer: strings
@@ -1588,10 +1597,21 @@ function themeValueRefusal(v) {
1588
1597
  }
1589
1598
  return stack.length ? 'unbalanced brackets' : null;
1590
1599
  }
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).
1600
+ // Stylesheets a theme edit may write: the default theme file and stylesheets index.html links.
1601
+ // The theme stylesheet index.html links (manifest.theme.css, wherever it lives), else the framework's default path.
1602
+ function defaultThemeFile() {
1603
+ let html = '';
1604
+ try { html = readFileSync(join(root, 'index.html'), 'utf8'); } catch { return DEFAULT_THEME_FILE; }
1605
+ for (const n of allElements(parseHTML(html))) {
1606
+ if (n.implied || n.name !== 'link') continue;
1607
+ const open = openOf(html, n), href = String(attrOf(open, 'href') || '').trim().split(/[?#]/)[0];
1608
+ if (!/(^|\s)stylesheet(\s|$)/i.test(attrOf(open, 'rel') || '') || /^[a-z][\w+.-]*:|^\/\//i.test(href)) continue;
1609
+ if (basename(href) === 'manifest.theme.css') { const rel = posix.normalize(href.replace(/^\/+/, '')); if (!rel.startsWith('..')) return rel; }
1610
+ }
1611
+ return DEFAULT_THEME_FILE;
1612
+ }
1593
1613
  function themeTargets() {
1594
- const out = new Set([DEFAULT_THEME_FILE]);
1614
+ const out = new Set([DEFAULT_THEME_FILE, defaultThemeFile()]);
1595
1615
  let html = '';
1596
1616
  try { html = readFileSync(join(root, 'index.html'), 'utf8'); } catch { return out; } // read off-batch: not a base-checked input
1597
1617
  const add = (href) => {
@@ -1604,14 +1624,26 @@ function themeTargets() {
1604
1624
  if (n.implied) continue;
1605
1625
  const open = openOf(html, n);
1606
1626
  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
1627
  }
1609
1628
  return out;
1610
1629
  }
1630
+ // Why a scoped theme value is not written to a stylesheet, for the case at hand
1631
+ function scopedThemeRefusal(p) {
1632
+ const what = `scoped value ${p.scope}:${p.var}`;
1633
+ for (const rel of Object.keys(baseHashes()).filter(f => /\.html?$/i.test(f))) {
1634
+ const file = safeResolve('/' + rel); if (!file) continue;
1635
+ let html = ''; try { html = readFileSync(file, 'utf8'); } catch { continue; }
1636
+ if (locateEditEl(html, p.scope)) return `${what} applies on its scope element (x-edit="${p.scope}" in ${rel}): it is written there as an inline style edit, not to a stylesheet, which would apply it site-wide`;
1637
+ }
1638
+ return `${what}: no source file mnfst-run can write holds x-edit="${p.scope}" (generated content, or not on any page), and a stylesheet would apply it site-wide`;
1639
+ }
1611
1640
  function writeThemeVar(p) {
1612
1641
  if (!/^--[\w-]+$/.test(p.var || '')) return { region: p.var, status: 'error', reason: 'bad var name' };
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)` };
1642
+ // A scoped value applies on its scope element: the page writes it there (its inline style).
1643
+ // One sent here has no element mnfst-run can address; a stylesheet would apply it site-wide.
1644
+ if (p.scope) return { region: `${p.scope}:${p.var}`, status: 'error', reason: scopedThemeRefusal(p) };
1645
+ const rel = posix.normalize(String(p.file || defaultThemeFile()).replace(/^\/+/, ''));
1646
+ if (!themeTargets().has(rel)) return { region: p.var, status: 'error', reason: `${rel} is not a stylesheet this page links` };
1615
1647
  const file = safeResolve('/' + rel);
1616
1648
  if (!file) return { region: p.var, status: 'error', reason: `path outside root: ${rel}` };
1617
1649
  const refused = writeRefusal(file, 'css'); if (refused) return { region: p.var, status: 'error', reason: `refused: ${refused}` };
@@ -1664,7 +1696,7 @@ function writeComponentEdits(p, manifest) {
1664
1696
  const seg = ed.path === '' ? 'root' : ed.path.replace(/\./g, '_');
1665
1697
 
1666
1698
  if (prop === 'style') {
1667
- if (p.scope !== 'main') { applied.push({ path: ed.path, prop, status: 'skipped', reason: 'instance styles are not written' }); continue; }
1699
+ if (p.scope !== 'main') { applied.push({ path: ed.path, prop, status: 'error', reason: 'instance styles are not written (only the component\'s own, in main scope)' }); continue; }
1668
1700
  const why = attrRefusal('style', ed.value, node.tag);
1669
1701
  if (why) { applied.push({ path: ed.path, prop, status: 'error', reason: why }); continue; }
1670
1702
  vwrite(compFile, html.slice(0, node.tagStart) + setAttr(openTag, 'style', ed.value ? String(ed.value) : null) + html.slice(node.openEnd + 1));
@@ -2055,7 +2087,7 @@ const server = createServer((req, res) => {
2055
2087
  if (p.kind === 'data') {
2056
2088
  const src = (manifest.data || {})[p.source];
2057
2089
  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` };
2090
+ if (!rel) return { region: p.region, status: 'error', reason: dataSourceRefusal(p.source, manifest) };
2059
2091
  const file = resolveRel(rel);
2060
2092
  if (!file || !isFile(file)) return { region: p.region, status: 'error', reason: `file not found: ${rel}` };
2061
2093
  const refused = writeRefusal(file, 'data'); if (refused) return { region: p.region, status: 'error', reason: `refused: ${refused}` };