novac 2.2.1 → 2.3.0

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.
@@ -38,10 +38,16 @@ function executeAst(ast, opts = {}) {
38
38
  * Render a live NvmlDocument to an HTML string.
39
39
  * @param {NvmlDocument} doc
40
40
  * @param {object} opts
41
- * novaEmitter(code) — compiles Nova code to JS for client scripts
41
+ * novaEmitter(code) — compiles Nova code to JS for client scripts
42
+ * registerAction(name,code,type) — registers a named server action
43
+ * csrfToken — CSRF token embedded in page JS
42
44
  */
43
45
  function renderDoc(doc, opts = {}) {
44
- const renderer = new Renderer({ novaEmitter: opts.novaEmitter || null });
46
+ const renderer = new Renderer({
47
+ novaEmitter: opts.novaEmitter || null,
48
+ registerAction: opts.registerAction || null,
49
+ csrfToken: opts.csrfToken || '',
50
+ });
45
51
  return renderer.render(doc);
46
52
  }
47
53
 
@@ -64,4 +70,4 @@ function run(source, opts = {}) {
64
70
  return { doc, html, ast };
65
71
  }
66
72
 
67
- module.exports = { parse, executeAst, renderDoc, compile, run, NvmlDocument, makeBfObject };
73
+ module.exports = { parse, executeAst, renderDoc, compile, run, NvmlDocument, makeBfObject };
@@ -29,70 +29,72 @@
29
29
  const { makeBfObject } = require('./executor');
30
30
 
31
31
  const VOID_ELEMENTS = new Set([
32
- 'area','base','br','col','embed','hr','img','input',
33
- 'link','meta','param','source','track','wbr',
32
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
33
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
34
34
  ]);
35
35
 
36
36
  function safeAttr(name) { return String(name).replace(/[^a-zA-Z0-9\-_:.]/g, ''); }
37
- function escHtml(str) { return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
38
- function esc(str) { return String(str).replace(/\\/g,'\\\\').replace(/`/g,'\\`').replace(/\$\{/g,'\\${'); }
37
+ function escHtml(str) { return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
38
+ function esc(str) { return String(str).replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); }
39
39
 
40
40
  const HTML_ATTRS = new Set([
41
- 'id','class','style','href','src','alt','title','placeholder','type','value','name',
42
- 'action','method','target','rel','for','checked','disabled','readonly','required',
43
- 'autofocus','autocomplete','multiple','size','rows','cols','maxlength','minlength',
44
- 'min','max','step','pattern','tabindex','accesskey','dir','draggable','hidden',
45
- 'spellcheck','translate','contenteditable','width','height','loading','decoding',
46
- 'crossorigin','integrity','referrerpolicy','charset','media','onload','onclick',
47
- 'onchange','oninput','onsubmit','onkeydown','onkeyup','onkeypress','onmouseover',
48
- 'onmouseout','onmouseenter','onmouseleave','onfocus','onblur','ondblclick',
49
- 'onpointerdown','onpointerup','onpointermove','role',
50
- 'aria-label','aria-hidden','aria-expanded','aria-controls','aria-describedby',
51
- 'aria-labelledby','aria-live','aria-atomic','aria-relevant','aria-busy',
52
- 'aria-checked','aria-selected','aria-pressed','aria-disabled','aria-invalid',
53
- 'aria-required','aria-multiline','aria-multiselectable','aria-orientation',
54
- 'aria-valuemin','aria-valuemax','aria-valuenow','aria-valuetext','aria-setsize',
55
- 'aria-posinset','aria-level','aria-readonly','aria-autocomplete','aria-haspopup',
56
- 'aria-modal','aria-sort','aria-colcount','aria-colindex','aria-rowcount','aria-rowindex',
57
- 'data','form','formaction','formmethod','formnovalidate','formtarget','enctype',
58
- 'accept','accept-charset','list','inputmode','enterkeyhint','is','part','slot',
59
- 'exportparts','inert','popover','popovertarget','popovertargetaction',
41
+ 'id', 'class', 'style', 'href', 'src', 'alt', 'title', 'placeholder', 'type', 'value', 'name',
42
+ 'action', 'method', 'target', 'rel', 'for', 'checked', 'disabled', 'readonly', 'required',
43
+ 'autofocus', 'autocomplete', 'multiple', 'size', 'rows', 'cols', 'maxlength', 'minlength',
44
+ 'min', 'max', 'step', 'pattern', 'tabindex', 'accesskey', 'dir', 'draggable', 'hidden',
45
+ 'spellcheck', 'translate', 'contenteditable', 'width', 'height', 'loading', 'decoding',
46
+ 'crossorigin', 'integrity', 'referrerpolicy', 'charset', 'media', 'onload', 'onclick',
47
+ 'onchange', 'oninput', 'onsubmit', 'onkeydown', 'onkeyup', 'onkeypress', 'onmouseover',
48
+ 'onmouseout', 'onmouseenter', 'onmouseleave', 'onfocus', 'onblur', 'ondblclick',
49
+ 'onpointerdown', 'onpointerup', 'onpointermove', 'role',
50
+ 'aria-label', 'aria-hidden', 'aria-expanded', 'aria-controls', 'aria-describedby',
51
+ 'aria-labelledby', 'aria-live', 'aria-atomic', 'aria-relevant', 'aria-busy',
52
+ 'aria-checked', 'aria-selected', 'aria-pressed', 'aria-disabled', 'aria-invalid',
53
+ 'aria-required', 'aria-multiline', 'aria-multiselectable', 'aria-orientation',
54
+ 'aria-valuemin', 'aria-valuemax', 'aria-valuenow', 'aria-valuetext', 'aria-setsize',
55
+ 'aria-posinset', 'aria-level', 'aria-readonly', 'aria-autocomplete', 'aria-haspopup',
56
+ 'aria-modal', 'aria-sort', 'aria-colcount', 'aria-colindex', 'aria-rowcount', 'aria-rowindex',
57
+ 'data', 'form', 'formaction', 'formmethod', 'formnovalidate', 'formtarget', 'enctype',
58
+ 'accept', 'accept-charset', 'list', 'inputmode', 'enterkeyhint', 'is', 'part', 'slot',
59
+ 'exportparts', 'inert', 'popover', 'popovertarget', 'popovertargetaction',
60
60
  ]);
61
61
 
62
62
  class Renderer {
63
63
  constructor(options = {}) {
64
64
  this.novaEmitter = options.novaEmitter || null;
65
+ this.registerAction = options.registerAction || null; // (name, code, type) => void
66
+ this._csrfToken = options.csrfToken || '';
65
67
  }
66
68
 
67
69
  render(doc) {
68
70
  const config = doc.config || {};
69
- const hasState = Object.keys(doc.state || {}).length > 0;
71
+ const hasState = Object.keys(doc.state || {}).length > 0;
70
72
  const hasComputed = (doc.computed || []).length > 0;
71
- const hasEffects = (doc.effects || []).length > 0;
72
- const hasRoutes = (doc.routes || []).length > 0;
73
+ const hasEffects = (doc.effects || []).length > 0;
74
+ const hasRoutes = (doc.routes || []).length > 0;
73
75
 
74
76
  // ── <head> ──────────────────────────────────────────────────────
75
77
  const head = [];
76
78
 
77
79
  head.push(` <meta charset="${escHtml(config.charset || 'UTF-8')}">`);
78
80
  head.push(` <meta name="viewport" content="${escHtml(config.viewport || 'width=device-width, initial-scale=1.0')}">`);
79
- if (config.title) head.push(` <title>${escHtml(config.title)}</title>`);
81
+ if (config.title) head.push(` <title>${escHtml(config.title)}</title>`);
80
82
  if (config.description) head.push(` <meta name="description" content="${escHtml(config.description)}">`);
81
- if (config.author) head.push(` <meta name="author" content="${escHtml(config.author)}">`);
83
+ if (config.author) head.push(` <meta name="author" content="${escHtml(config.author)}">`);
82
84
  if (config.keywords) {
83
85
  const kw = Array.isArray(config.keywords) ? config.keywords.join(', ') : config.keywords;
84
86
  head.push(` <meta name="keywords" content="${escHtml(kw)}">`);
85
87
  }
86
88
  if (config['theme-color']) head.push(` <meta name="theme-color" content="${escHtml(config['theme-color'])}">`);
87
- if (config.robots) head.push(` <meta name="robots" content="${escHtml(config.robots)}">`);
89
+ if (config.robots) head.push(` <meta name="robots" content="${escHtml(config.robots)}">`);
88
90
  if (config.canonical) head.push(` <link rel="canonical" href="${escHtml(config.canonical)}">`);
89
- if (config.favicon) head.push(` <link rel="icon" href="${escHtml(config.favicon)}">`);
90
- if (config.base) head.push(` <base href="${escHtml(config.base)}">`);
91
+ if (config.favicon) head.push(` <link rel="icon" href="${escHtml(config.favicon)}">`);
92
+ if (config.base) head.push(` <base href="${escHtml(config.base)}">`);
91
93
 
92
- for (const k of ['og:title','og:description','og:image','og:url','og:type']) {
94
+ for (const k of ['og:title', 'og:description', 'og:image', 'og:url', 'og:type']) {
93
95
  if (config[k]) head.push(` <meta property="${escHtml(k)}" content="${escHtml(config[k])}">`);
94
96
  }
95
- for (const k of ['twitter:card','twitter:title','twitter:description','twitter:image']) {
97
+ for (const k of ['twitter:card', 'twitter:title', 'twitter:description', 'twitter:image']) {
96
98
  if (config[k]) head.push(` <meta name="${escHtml(k)}" content="${escHtml(config[k])}">`);
97
99
  }
98
100
 
@@ -109,9 +111,9 @@ class Renderer {
109
111
 
110
112
  // Transition CSS (generated from ~ hints collected during element render)
111
113
  this._transitionNames = new Set();
112
- this._componentDefs = doc.components || {};
113
- this._slots = doc.slots || {};
114
- this._langDefs = doc.langs || {};
114
+ this._componentDefs = doc.components || {};
115
+ this._slots = doc.slots || {};
116
+ this._langDefs = doc.langs || {};
115
117
 
116
118
  // Render body first to collect transition names and component templates
117
119
  this._componentTemplates = {};
@@ -139,9 +141,9 @@ class Renderer {
139
141
  }
140
142
 
141
143
  // ── <body> ──────────────────────────────────────────────────────
142
- const lang = config.lang || 'en';
144
+ const lang = config.lang || 'en';
143
145
  const bodyClass = config.bodyClass ? ` class="${escHtml(config.bodyClass)}"` : '';
144
- const bodyId = config.bodyId ? ` id="${escHtml(config.bodyId)}"` : '';
146
+ const bodyId = config.bodyId ? ` id="${escHtml(config.bodyId)}"` : '';
145
147
  const bodyStyle = config.bodyStyle ? ` style="${escHtml(config.bodyStyle)}"` : '';
146
148
 
147
149
  return [
@@ -161,15 +163,27 @@ class Renderer {
161
163
  // ── Reactive runtime script ──────────────────────────────────────
162
164
 
163
165
  _renderRuntime(doc) {
164
- const stateJson = JSON.stringify(doc.state || {});
166
+ const stateJson = JSON.stringify(doc.state || {});
165
167
  const computedJson = JSON.stringify((doc.computed || []).map(c => ({ name: c.name, initial: c.initialValue })));
166
- const effectsJson = JSON.stringify((doc.effects || []).map(e => ({ deps: e.deps, code: e.code })));
168
+ // Effects: register named action at compile time, embed action name only
169
+ const effectsJson = JSON.stringify((doc.effects || []).map((e, i) => {
170
+ if (this.registerAction && e.code && e.code.trim()) {
171
+ const name = `effect_${i}`;
172
+ this.registerAction(name, e.code, 'nova');
173
+ return { deps: e.deps, action: name };
174
+ }
175
+ return { deps: e.deps, action: null }; // no-op if no server
176
+ }));
177
+ const csrf = this._csrfToken;
167
178
 
168
179
  return ` <script>
169
180
  // ── NVML Reactive Runtime ─────────────────────────────────────────────
170
181
  (function(){
171
182
  'use strict';
172
183
 
184
+ // ── CSRF token (embedded at compile time) ─────────────────────────────
185
+ const __csrf = '${csrf}';
186
+
173
187
  // ── Signal store ──────────────────────────────────────────────────────
174
188
  const _state = ${stateJson};
175
189
  const _computed = ${computedJson};
@@ -214,12 +228,11 @@ function _subscribe(name, fn) {
214
228
 
215
229
  // ── Effects ────────────────────────────────────────────────────────────
216
230
  function _runEffect(effect) {
217
- if (!effect.code || !effect.code.trim()) return;
218
- // Effects with Nova code run via /_nvml/run server round-trip
219
- fetch('/_nvml/run', {
231
+ if (!effect.action) return;
232
+ fetch('/_nvml/action/' + effect.action, {
220
233
  method: 'POST',
221
- headers: { 'Content-Type': 'application/json' },
222
- body: JSON.stringify({ code: effect.code, live: _liveSnapshot(), state: _state })
234
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': __csrf },
235
+ body: JSON.stringify({ live: _liveSnapshot() })
223
236
  }).then(r => r.json()).then(({ mutations, error }) => {
224
237
  if (error) { console.error('[nvml effect]', error); return; }
225
238
  _applyMutations(mutations || []);
@@ -434,11 +447,11 @@ function _liveSnapshot() {
434
447
  }
435
448
 
436
449
  // ── Triggered server script handler ─────────────────────────────────────
437
- window.__nvmlRun = function(code) {
438
- return fetch('/_nvml/run', {
450
+ window.__nvmlRun = function(actionName) {
451
+ return fetch('/_nvml/action/' + actionName, {
439
452
  method: 'POST',
440
- headers: { 'Content-Type': 'application/json' },
441
- body: JSON.stringify({ code, live: _liveSnapshot() })
453
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': __csrf },
454
+ body: JSON.stringify({ live: _liveSnapshot() })
442
455
  }).then(r => r.json()).then(({ mutations, error }) => {
443
456
  if (error) { console.error('[nvml]', error); return; }
444
457
  _applyMutations(mutations || []);
@@ -522,11 +535,11 @@ window.__nvml_bf_make = _makeBf;
522
535
  window.__nvml_bf = _makeBf();
523
536
 
524
537
  // ── __nvmlRunNode — triggered nodejs server script runner ────────────
525
- window.__nvmlRunNode = function(code) {
526
- return fetch('/_nvml/run-node', {
538
+ window.__nvmlRunNode = function(actionName) {
539
+ return fetch('/_nvml/action/' + actionName, {
527
540
  method: 'POST',
528
- headers: { 'Content-Type': 'application/json' },
529
- body: JSON.stringify({ code, live: window.__nvml ? { state: window.__nvml.state } : {} })
541
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': __csrf },
542
+ body: JSON.stringify({ live: window.__nvml ? { state: window.__nvml.state } : {} })
530
543
  }).then(r => r.json()).then(({ mutations, error }) => {
531
544
  if (error) { console.error('[nvml nodejs]', error); return; }
532
545
  if (window.__nvml) window.__nvml.applyMutations(mutations || []);
@@ -540,8 +553,8 @@ window.__nvmlRunNode = function(code) {
540
553
 
541
554
  _renderRoutingScript(doc) {
542
555
  const routes = (doc.routes || []).map(r => ({
543
- path: typeof r.path === 'object' ? (r.path.value || r.path) : r.path,
544
- html: r.body.map(n => this.renderElement(this._bodyToEl(n), 1, doc)).join(''),
556
+ path: typeof r.path === 'object' ? (r.path.value || r.path) : r.path,
557
+ html: r.body.map(n => this.renderElement(this._bodyToEl(n), 1, doc)).join(''),
545
558
  }));
546
559
  const routesJson = JSON.stringify(routes);
547
560
  return `<script>
@@ -626,7 +639,7 @@ document.addEventListener('DOMContentLoaded', () => _render(location.pathname));
626
639
 
627
640
  // conditional render: data-nvml-if
628
641
  if (el.cond) {
629
- attrs['data-nvml-if'] = el.cond;
642
+ attrs['data-nvml-if'] = el.cond;
630
643
  attrs['data-nvml-display'] = el.props.style?.includes('inline') ? 'inline' : 'block';
631
644
  }
632
645
 
@@ -676,7 +689,7 @@ document.addEventListener('DOMContentLoaded', () => _render(location.pathname));
676
689
  childLines.push(this.renderElement(child, depth + 1, doc));
677
690
  }
678
691
 
679
- const inner = childLines.length ? '\n' + childLines.filter(Boolean).join('\n') + '\n' + ind : '';
692
+ const inner = childLines.length ? '\n' + childLines.filter(Boolean).join('\n') + '\n' + ind : '';
680
693
  const closeTag = `</${tag}>`;
681
694
 
682
695
  return scopedStyleBlock + ind + openTag + inner + closeTag;
@@ -688,7 +701,7 @@ document.addEventListener('DOMContentLoaded', () => _render(location.pathname));
688
701
  // Generate a template string and a container div.
689
702
  // The reactive runtime will clone the template per item.
690
703
  // For SSR: render with the initial state value if available.
691
- const signal = el.eachSignal;
704
+ const signal = el.eachSignal;
692
705
  const itemVar = el.eachItemVar || 'item';
693
706
 
694
707
  // Build the per-item template as an HTML string with {{item}} placeholder
@@ -710,7 +723,7 @@ document.addEventListener('DOMContentLoaded', () => _render(location.pathname));
710
723
 
711
724
  _renderSlotOutlet(el, depth, doc) {
712
725
  const slotName = el.slotName || 'default';
713
- const slotEls = (this._slots || {})[slotName] || [];
726
+ const slotEls = (this._slots || {})[slotName] || [];
714
727
  if (!slotEls.length) return '';
715
728
  return slotEls.map(s => this.renderElement(s, depth, doc)).join('\n');
716
729
  }
@@ -718,7 +731,7 @@ document.addEventListener('DOMContentLoaded', () => _render(location.pathname));
718
731
  // ── Component placeholder (external) ─────────────────────────────
719
732
 
720
733
  _renderComponentPlaceholder(el, ind) {
721
- const name = el.props['data-component'];
734
+ const name = el.props['data-component'];
722
735
  const attrs = this._attrsToString(this._buildAttrs(el, null));
723
736
  return `${ind}<div${attrs}></div>`;
724
737
  }
@@ -727,7 +740,7 @@ document.addEventListener('DOMContentLoaded', () => _render(location.pathname));
727
740
 
728
741
  _renderScript(el, ind) {
729
742
  const scope = el._scriptScope || el.props.scope || 'client';
730
- const lang = el._scriptLang || el.props.language || el.props.lang || 'js';
743
+ const lang = el._scriptLang || el.props.language || el.props.lang || 'js';
731
744
 
732
745
  // Nova server-side: already ran, leave a comment
733
746
  if ((lang === 'novac' || lang === 'nv') && scope === 'server') {
@@ -757,7 +770,7 @@ document.addEventListener('DOMContentLoaded', () => _render(location.pathname));
757
770
  }
758
771
 
759
772
  const extraAttrs = [];
760
- if (el.props.src) extraAttrs.push(`src="${escHtml(el.props.src)}"`);
773
+ if (el.props.src) extraAttrs.push(`src="${escHtml(el.props.src)}"`);
761
774
  if (el.props.defer) extraAttrs.push('defer');
762
775
  if (el.props.async) extraAttrs.push('async');
763
776
  if (el.props.type && lang !== 'novac' && lang !== 'nv') extraAttrs.push(`type="${escHtml(el.props.type)}"`);
@@ -771,15 +784,21 @@ document.addEventListener('DOMContentLoaded', () => _render(location.pathname));
771
784
  // Sends code to /_nvml/run-node endpoint; the server runs it in Node.js VM.
772
785
 
773
786
  _renderNodejsFetch(el, ind) {
774
- const code = (el._scriptCode || el.code || el.textValue || '').trim();
787
+ const code = (el._scriptCode || el.code || el.textValue || '').trim();
775
788
  const trigger = el.props.trigger || null;
776
- const target = el.props.target || null;
777
- const escaped = esc(code);
789
+ const target = el.props.target || null;
790
+ const actionName = el.props.action || el.props.id || null;
791
+
792
+ if (!actionName) {
793
+ process.stderr.write(`[nvml] Warning: nodejs script block missing 'action' prop — skipping. Add action="my-action-name".\n`);
794
+ return `${ind}<!-- nvml: nodejs script skipped — no action name -->`;
795
+ }
778
796
 
779
- const fetchCall = `window.__nvmlRunNode(\`${escaped}\`)`;
797
+ if (this.registerAction) this.registerAction(actionName, code, 'nodejs');
798
+ const fetchCall = `window.__nvmlRunNode('${actionName}')`;
780
799
 
781
800
  if (trigger && target) {
782
- const evList = trigger.split(',').map(t => t.trim());
801
+ const evList = trigger.split(',').map(t => t.trim());
783
802
  const handlers = evList.map(ev => `_t.addEventListener('${ev}', function(_e){ ${fetchCall}; });`).join('\n ');
784
803
  return `${ind}<script>
785
804
  ${ind}document.addEventListener('DOMContentLoaded', function() {
@@ -800,19 +819,24 @@ ${ind}</script>`;
800
819
  return `${ind}<!-- @lang ${langDef.name} (${langDef.runtimeLanguage}) script executed at render time -->`;
801
820
  }
802
821
 
803
- const code = el._scriptCode || el.code || el.textValue || '';
822
+ const code = el._scriptCode || el.code || el.textValue || '';
804
823
  const trigger = el.props.trigger || null;
805
- const target = el.props.target || null;
824
+ const target = el.props.target || null;
806
825
 
807
- // Server-side triggered custom lang: route through /_nvml/run (Nova) or /_nvml/run-node
826
+ // Server-side triggered custom lang: dispatch via /_nvml/action/:name
808
827
  if (langDef.scope === 'server-nova' || langDef.scope === 'server-node') {
809
- const endpoint = langDef.scope === 'server-node' ? '/_nvml/run-node' : '/_nvml/run';
810
- const wrapped = langDef.code ? langDef.code + '\n' + code : code;
811
- const escaped = esc(wrapped);
812
- const fetchCall = `fetch('${endpoint}', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: \`${escaped}\`, live: window.__nvml ? { state: window.__nvml.state } : {} }) }).then(r => r.json()).then(({ mutations }) => { if (window.__nvml) window.__nvml.applyMutations(mutations || []); })`;
828
+ const actionName = el.props.action || el.props.id || null;
829
+ if (!actionName) {
830
+ process.stderr.write(`[nvml] Warning: @lang server script missing 'action' prop — skipping.\n`);
831
+ return `${ind}<!-- nvml: @lang server script skipped no action name -->`;
832
+ }
833
+ const wrapped = langDef.code ? langDef.code + '\n' + code : code;
834
+ const type = langDef.scope === 'server-node' ? 'nodejs' : 'nova';
835
+ if (this.registerAction) this.registerAction(actionName, wrapped, type);
836
+ const fetchCall = `fetch('/_nvml/action/${actionName}', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': __csrf }, body: JSON.stringify({ live: window.__nvml ? { state: window.__nvml.state } : {} }) }).then(r => r.json()).then(({ mutations }) => { if (window.__nvml) window.__nvml.applyMutations(mutations || []); })`;
813
837
 
814
838
  if (trigger && target) {
815
- const evList = trigger.split(',').map(t => t.trim());
839
+ const evList = trigger.split(',').map(t => t.trim());
816
840
  const handlers = evList.map(ev => `_t.addEventListener('${ev}', function() { ${fetchCall}; });`).join('\n ');
817
841
  return `${ind}<script>
818
842
  ${ind}document.addEventListener('DOMContentLoaded', function() {
@@ -842,15 +866,21 @@ ${ind}</script>`;
842
866
  _renderNvFetch(el, ind) {
843
867
  if (el._ranOnServer) return `${ind}<!-- nv server script executed at render time -->`;
844
868
 
845
- const code = (el._scriptCode || el.code || el.textValue || '').trim();
869
+ const code = (el._scriptCode || el.code || el.textValue || '').trim();
846
870
  const trigger = el.props.trigger || null;
847
- const target = el.props.target || null;
848
- const escaped = esc(code);
871
+ const target = el.props.target || null;
872
+ const actionName = el.props.action || el.props.id || null;
873
+
874
+ if (!actionName) {
875
+ process.stderr.write(`[nvml] Warning: server-side script block missing 'action' prop — skipping. Add action="my-action-name".\n`);
876
+ return `${ind}<!-- nvml: server script skipped — no action name -->`;
877
+ }
849
878
 
850
- const fetchCall = `window.__nvmlRun(\`${escaped}\`)`;
879
+ if (this.registerAction) this.registerAction(actionName, code, 'nova');
880
+ const fetchCall = `window.__nvmlRun('${actionName}')`;
851
881
 
852
882
  if (trigger && target) {
853
- const evList = trigger.split(',').map(t => t.trim());
883
+ const evList = trigger.split(',').map(t => t.trim());
854
884
  const handlers = evList.map(ev => `_t.addEventListener('${ev}', function(_e){ ${fetchCall}; });`).join('\n ');
855
885
  return `${ind}<script>
856
886
  ${ind}document.addEventListener('DOMContentLoaded', function() {
@@ -870,8 +900,8 @@ ${ind}</script>`;
870
900
  const attrs = {};
871
901
 
872
902
  for (const [key, val] of Object.entries(el.props || {})) {
873
- if (el.tag === 'script' && ['language','lang','scope','code','ss','trigger','target'].includes(key)) continue;
874
- if (val === true) attrs[safeAttr(key)] = true;
903
+ if (el.tag === 'script' && ['language', 'lang', 'scope', 'code', 'ss', 'trigger', 'target'].includes(key)) continue;
904
+ if (val === true) attrs[safeAttr(key)] = true;
875
905
  else if (val === false || val === null || val === undefined) continue;
876
906
  else {
877
907
  // check for signal ref value (prefixed __sig: by executor)
@@ -921,4 +951,4 @@ ${ind}</script>`;
921
951
  }
922
952
  }
923
953
 
924
- module.exports = { Renderer };
954
+ module.exports = { Renderer };