mnfst-run 1.0.22 → 1.0.24

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/bin/mnfst-run.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "Zero-dependency dev server for Manifest projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "license": "MIT",
25
25
  "repository": {
26
26
  "type": "git",
27
- "url": "git+https://github.com/andrewmatlock/Manifest.git",
27
+ "url": "git+https://github.com/Manifest-X/Manifest.git",
28
28
  "directory": "packages/run"
29
29
  }
30
30
  }
package/serve.mjs CHANGED
@@ -101,6 +101,7 @@ const MIME = {
101
101
  // server the tab is gone.
102
102
  const LIVE_RELOAD_SCRIPT = `<script>
103
103
  (function () {
104
+ window.__mnfstRun = true; // dev marker: the framework loader skips its service worker here
104
105
  var tabId = (window.crypto && crypto.randomUUID)
105
106
  ? crypto.randomUUID()
106
107
  : (Math.random().toString(36).slice(2) + Date.now().toString(36));
@@ -396,6 +397,32 @@ const aiConfig = (() => {
396
397
  try { return JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')).ai || null; }
397
398
  catch { return null; }
398
399
  })();
400
+
401
+ // Doc-grounding: `ai.system` (persona/instructions) + optional `ai.grounding`
402
+ // (URL or project-relative file whose text is appended to the system prompt).
403
+ // Resolved ONCE at startup so the combined prompt is byte-identical across
404
+ // requests — the precondition for the server-side prompt cache to hit.
405
+ let aiSystemResolved = (aiConfig && aiConfig.system) || '';
406
+ async function resolveAiGrounding() {
407
+ const src = aiConfig && aiConfig.grounding;
408
+ if (!src) return;
409
+ try {
410
+ let text;
411
+ if (/^https?:\/\//.test(src)) {
412
+ const r = await fetch(src);
413
+ if (!r.ok) throw new Error(`http ${r.status}`);
414
+ text = await r.text();
415
+ } else {
416
+ text = readFileSync(join(root, src), 'utf8');
417
+ }
418
+ aiSystemResolved = (aiSystemResolved ? aiSystemResolved + '\n\n' : '')
419
+ + '<reference_documentation>\n' + text + '\n</reference_documentation>';
420
+ console.log(`[mnfst-run] AI grounding loaded from ${src} (${text.length} bytes)`);
421
+ } catch (e) {
422
+ console.warn(`[mnfst-run] AI grounding failed to load from ${src}: ${e.message} — continuing without it`);
423
+ }
424
+ }
425
+ if (aiConfig) resolveAiGrounding();
399
426
  const aiKey = process.env.ANTHROPIC_API_KEY || privateEnv.ANTHROPIC_API_KEY || '';
400
427
  if (publicEnv.PUBLIC_ANTHROPIC_API_KEY) {
401
428
  // The one footgun: a PUBLIC_-prefixed LLM key would ship to every visitor.
@@ -420,6 +447,12 @@ function streamMockAi(res) {
420
447
  res.on('close', () => clearInterval(tick));
421
448
  }
422
449
  async function streamRealAi(res, payload) {
450
+ // System prompt as a cache_control block: the (instructions + grounding) text
451
+ // is byte-identical every request, so Anthropic serves it from the prompt
452
+ // cache (~0.1× input price) after the first request — long doc-grounded
453
+ // prompts cost near-nothing per message. Per-request payload.system overrides
454
+ // (adapter opts) still work but won't share the project-level cache entry.
455
+ const systemText = payload.system || aiSystemResolved;
423
456
  const upstream = await fetch('https://api.anthropic.com/v1/messages', {
424
457
  method: 'POST',
425
458
  headers: { 'content-type': 'application/json', 'x-api-key': aiKey, 'anthropic-version': '2023-06-01' },
@@ -427,7 +460,7 @@ async function streamRealAi(res, payload) {
427
460
  model: payload.model || aiConfig.model || 'claude-haiku-4-5',
428
461
  max_tokens: payload.max_tokens || aiConfig.maxTokens || 1024,
429
462
  stream: true,
430
- system: payload.system || aiConfig.system || undefined,
463
+ system: systemText ? [{ type: 'text', text: systemText, cache_control: { type: 'ephemeral' } }] : undefined,
431
464
  messages: payload.messages || []
432
465
  })
433
466
  });
@@ -698,35 +731,97 @@ function serverStaticKey(html, child) { // mirror the client's staticKey (tag
698
731
  const text = html.slice(child.innerStart, child.innerEnd).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().slice(0, 24);
699
732
  return child.tag.toUpperCase() + ':' + text;
700
733
  }
701
- function writeStaticOps(file, key, edits, order) {
702
- for (const ed of (edits || [])) { // re-read+re-locate each (offsets shift after writes)
703
- let html = readFileSync(file, 'utf8');
704
- const loc = locateEditEl(html, key); if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
705
- const node = ed.path === '' ? { tagStart: loc.openStart, openEnd: loc.openEnd, innerStart: loc.innerStart, innerEnd: loc.innerEnd } : navInRegion(html, loc, ed.path);
706
- if (!node) continue;
707
- const openTag = html.slice(node.tagStart, node.openEnd + 1);
708
- if (ed.prop === 'text') html = html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd);
709
- else if (ed.prop === 'class') html = html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1);
710
- else if (ed.prop === 'style') html = html.slice(0, node.tagStart) + setAttr(openTag, 'style', ed.value) + html.slice(node.openEnd + 1);
711
- writeFileSync(file, html);
712
- }
734
+ function writeStaticOps(file, key, edits, order, aliases) {
735
+ // Order FIRST: keys match on authored content (edits haven't rewritten it yet) and
736
+ // edit paths are current DOM positions, valid only once the file order matches.
713
737
  let reordered = false;
714
738
  if (order && order.length) {
715
739
  let html = readFileSync(file, 'utf8'); const loc = locateEditEl(html, key);
716
740
  if (loc) {
717
741
  const kids = []; let n = 0, c;
718
742
  while ((c = childAt(html, loc.innerStart, loc.innerEnd, n++))) kids.push(c);
719
- const byKey = {}; kids.forEach(k => { byKey[serverStaticKey(html, k)] = k; });
743
+ const keys = dedupedKeys(html, kids); // ordinal-deduped, mirroring the client
744
+ // A block edited BEFORE its move carries a live key derived from the EDITED text;
745
+ // the file holds the authored text, so file keys can't resolve it and the whole
746
+ // order would be skipped. writeStaticMove recorded (live toKey → inserted slice):
747
+ // bind those entries to their exact inserted child, then key the remaining kids
748
+ // counting the live base the client's rekey counted (twins keep their ordinals).
749
+ const bound = new Map();
750
+ for (const a of (aliases || [])) {
751
+ if (!order.includes(a.key) || keys.includes(a.key)) continue; // unedited arrival: file keys already match
752
+ const kid = kids.find(k => !bound.has(k) && html.slice(k.tagStart, k.closeEnd) === a.slice);
753
+ if (kid) bound.set(kid, a.key);
754
+ }
755
+ const byKey = {};
756
+ if (bound.size) {
757
+ const seen = Object.create(null);
758
+ kids.forEach(k => {
759
+ const live = bound.get(k), base = live ? ((/^([\s\S]*)#\d+$/.exec(live) || [, live])[1]) : serverStaticKey(html, k); // ordinal = last #digits suffix, matching the client
760
+ const d = seen[base] = (seen[base] || 0) + 1;
761
+ byKey[live || (d > 1 ? base + '#' + d : base)] = k;
762
+ });
763
+ } else kids.forEach((k, i) => { byKey[keys[i]] = k; });
720
764
  const seq = order.map(k => byKey[k]).filter(Boolean);
721
- if (seq.length === kids.length && seq.length) {
765
+ if (seq.length === kids.length && seq.length && seq.some((k, i) => k !== kids[i])) { // skip when already in order
722
766
  const reassembled = seq.map(k => html.slice(k.tagStart, k.closeEnd)).join('\n ');
723
767
  html = html.slice(0, kids[0].tagStart) + reassembled + html.slice(kids[kids.length - 1].closeEnd);
724
768
  writeFileSync(file, html); reordered = true;
725
769
  }
726
770
  }
727
771
  }
772
+ for (const ed of (edits || [])) { // re-read+re-locate each (offsets shift after writes)
773
+ let html = readFileSync(file, 'utf8');
774
+ const loc = locateEditEl(html, key); if (!loc) return { region: key, status: 'error', reason: `x-edit="${key}" not found in ${basename(file)}` };
775
+ const node = ed.path === '' ? { tagStart: loc.openStart, openEnd: loc.openEnd, innerStart: loc.innerStart, innerEnd: loc.innerEnd } : navInRegion(html, loc, ed.path);
776
+ if (!node) continue;
777
+ const openTag = html.slice(node.tagStart, node.openEnd + 1);
778
+ if (ed.prop === 'text') html = html.slice(0, node.innerStart) + ed.value + html.slice(node.innerEnd);
779
+ else if (ed.prop === 'class') html = html.slice(0, node.tagStart) + setAttr(openTag, 'class', ed.value) + html.slice(node.openEnd + 1);
780
+ else if (ed.prop === 'style') html = html.slice(0, node.tagStart) + setAttr(openTag, 'style', ed.value) + html.slice(node.openEnd + 1);
781
+ writeFileSync(file, html);
782
+ }
728
783
  return { region: key, status: 'written', file: basename(file), edits: (edits || []).length, reordered };
729
784
  }
785
+ // Cross-region move: lift a child's authored markup out of one x-edit region and
786
+ // splice it into another (same file). Keys mirror the client's staticKeys (ordinal-deduped).
787
+ function regionChildren(html, loc) {
788
+ const kids = []; let n = 0, c;
789
+ while ((c = childAt(html, loc.innerStart, loc.innerEnd, n++))) kids.push(c);
790
+ return kids;
791
+ }
792
+ function dedupedKeys(html, kids) {
793
+ const seen = Object.create(null);
794
+ return kids.map(k => { const base = serverStaticKey(html, k); const n = seen[base] = (seen[base] || 0) + 1; return n > 1 ? base + '#' + n : base; });
795
+ }
796
+ function writeStaticMove(file, p, aliases) {
797
+ let html = readFileSync(file, 'utf8');
798
+ const fromLoc = locateEditEl(html, p.from);
799
+ if (!fromLoc) return { region: p.from, status: 'error', reason: `x-edit="${p.from}" not found in ${basename(file)}` };
800
+ if (!locateEditEl(html, p.to)) return { region: p.to, status: 'error', reason: `x-edit="${p.to}" not found in ${basename(file)}` };
801
+ const kids = regionChildren(html, fromLoc);
802
+ const i = dedupedKeys(html, kids).indexOf(p.key);
803
+ if (i < 0) return { region: p.from, status: 'error', reason: `child '${p.key}' not found in region '${p.from}'` };
804
+ const child = kids[i], slice = html.slice(child.tagStart, child.closeEnd);
805
+ let remStart = child.tagStart;
806
+ const pre = html.slice(0, remStart), nl = pre.lastIndexOf('\n');
807
+ if (nl >= 0 && /^[ \t]*$/.test(pre.slice(nl + 1))) remStart = nl; // take the indent + newline with it
808
+ html = html.slice(0, remStart) + html.slice(child.closeEnd);
809
+ const toLoc = locateEditEl(html, p.to); // re-locate: offsets shifted
810
+ if (!toLoc) return { region: p.to, status: 'error', reason: `x-edit="${p.to}" not found after removal` };
811
+ const toKids = regionChildren(html, toLoc);
812
+ // Positional placement: toOrder minus the arrival is the destination's children, so
813
+ // the arrival's index in toOrder is where it goes — robust against ordinal re-keys
814
+ // (a collision renames the twin, so key matching would miss). Any residual order
815
+ // drift is settled by the region's own order patch, which follows the move.
816
+ const at = (p.toOrder || []).indexOf(p.toKey);
817
+ let insertAt = toLoc.innerEnd; // default: end of region
818
+ if (at >= 0 && at < toKids.length) insertAt = toKids[at].tagStart;
819
+ const head = html.slice(0, insertAt), hnl = head.lastIndexOf('\n');
820
+ const indent = hnl >= 0 && /^[ \t]*$/.test(head.slice(hnl + 1)) ? head.slice(hnl + 1) : '';
821
+ writeFileSync(file, head + slice + '\n' + indent + html.slice(insertAt));
822
+ if (aliases) (aliases[p.to] = aliases[p.to] || []).push({ key: p.toKey, slice }); // the region's order patch may address this child by its LIVE key
823
+ return { region: `${p.from} → ${p.to}`, status: 'written', file: basename(file), key: p.key };
824
+ }
730
825
  function writeStaticRegion(file, key, innerHTML, style) {
731
826
  let html = readFileSync(file, 'utf8');
732
827
  const loc = locateEditEl(html, key);
@@ -751,6 +846,7 @@ function childAt(html, from, to, idx) { // nth element child within [f
751
846
  const selfClose = html[openEnd - 1] === '/' || VOID_TAGS.has(tag.toLowerCase());
752
847
  let innerStart = openEnd + 1, innerEnd = innerStart, closeEnd = openEnd + 1;
753
848
  if (!selfClose) { const cl = matchingClose(html, tag, innerStart); if (!cl) return null; innerEnd = cl.start; closeEnd = cl.end; }
849
+ if (/\sdata-edit-(handle|ghost)[\s=/>]/.test(html.slice(lt, openEnd + 1))) { i = closeEnd; continue; } // plugin-injected, not an authored child (mirrors the client's realChildren)
754
850
  if (count === idx) return { tag, tagStart: lt, openEnd, innerStart, innerEnd, closeEnd };
755
851
  count++; i = closeEnd;
756
852
  }
@@ -764,7 +860,12 @@ function navigateToNode(html, path) { // path indices are relative t
764
860
  }
765
861
  function resolveComponentFile(manifest, name) {
766
862
  const all = [...(manifest.preloadedComponents || []), ...(manifest.components || [])];
767
- return all.find(p => String(p).split('/').pop().replace('.html', '') === name) || null;
863
+ const listed = all.find(p => String(p).split('/').pop().replace('.html', '') === name);
864
+ if (listed) return listed;
865
+ // Convention component: components/<name>.html needs no manifest entry.
866
+ const rel = 'components/' + name + '.html';
867
+ const file = safeResolve('/' + rel);
868
+ return file && isFile(file) ? rel : null;
768
869
  }
769
870
 
770
871
  // Theme var write: rewrite (or append) a single `--var: value;` in the target CSS file.
@@ -865,6 +966,20 @@ function writeComponentInstance(file, key, overrides, removals) {
865
966
  return { region: key, status: 'written', file: basename(file), applied: Object.keys(overrides || {}), removed: removals || [] };
866
967
  }
867
968
 
969
+ // No-op service worker served at /sw.js when the project has none: a stale
970
+ // production worker (cached shell from a previous deploy) can never hold a
971
+ // dev session. It replaces that worker at the next update check, clears the
972
+ // framework's caches and unregisters itself.
973
+ const NOOP_SW = `// mnfst-run: no-op service worker (unregisters itself, clears Manifest caches)
974
+ self.addEventListener('install', function () { self.skipWaiting(); });
975
+ self.addEventListener('activate', function (e) {
976
+ e.waitUntil((async function () {
977
+ try { var keys = await caches.keys(); await Promise.all(keys.filter(function (k) { return k.indexOf('mnfst-sw:') === 0; }).map(function (k) { return caches.delete(k); })); } catch (_) {}
978
+ try { await self.registration.unregister(); } catch (_) {}
979
+ })());
980
+ });
981
+ `;
982
+
868
983
  // Resolve a request path against `root` and refuse anything that escapes.
869
984
  // `path.join` does NOT prevent `..` traversal — `join('/a/b', '/../../etc/passwd')`
870
985
  // returns `/etc/passwd`. Use `path.resolve` + an explicit prefix check.
@@ -1132,6 +1247,7 @@ const server = createServer((req, res) => {
1132
1247
  if (!Array.isArray(patches)) patches = [patches];
1133
1248
  let manifest = {}; try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); } catch {}
1134
1249
  const indexFile = join(root, 'index.html');
1250
+ const moveAliases = {}; // region → [{key: live toKey, slice}] from this batch's moves
1135
1251
  const results = patches.map(p => {
1136
1252
  try {
1137
1253
  if (p.kind === 'data') {
@@ -1143,7 +1259,8 @@ const server = createServer((req, res) => {
1143
1259
  return { region: p.region, status: reorderDataFile(file, p.order) ? 'written' : 'noop', file: rel };
1144
1260
  }
1145
1261
  if (p.kind === 'data-val') return writeDataValue(p, manifest);
1146
- if (p.kind === 'static') return writeStaticOps(indexFile, p.region, p.edits, p.order);
1262
+ if (p.kind === 'static') return writeStaticOps(indexFile, p.region, p.edits, p.order, moveAliases[p.region]);
1263
+ if (p.kind === 'static-move') return writeStaticMove(indexFile, p, moveAliases);
1147
1264
  if (p.kind === 'component') return writeComponentEdits(p, manifest);
1148
1265
  if (p.kind === 'theme') return writeThemeVar(p);
1149
1266
  return { region: p.region, status: 'skipped', reason: `unknown kind ${p.kind}` };
@@ -1158,6 +1275,12 @@ const server = createServer((req, res) => {
1158
1275
  const exact = safeResolve(urlPath);
1159
1276
  if (exact && isFile(exact)) return serveFile(res, exact);
1160
1277
 
1278
+ if (urlPath === '/sw.js') {
1279
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
1280
+ res.end(NOOP_SW);
1281
+ return;
1282
+ }
1283
+
1161
1284
  const indexPath = safeResolve(urlPath.replace(/\/$/, '') + '/index.html');
1162
1285
  if (indexPath && isFile(indexPath)) return serveFile(res, indexPath);
1163
1286