@vmz/core 0.1.16 → 0.1.18

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.
@@ -153,14 +153,14 @@ export declare const directApi: {
153
153
  */
154
154
  projectDefaultSlot(hostEl: any, node: any): void;
155
155
  /**
156
- * Direct if/else — no blueprint `kind: "if"` dispatch.
156
+ * Direct if/else — comment anchors (no empty `span[data-vmz-if]` layout box).
157
157
  * @param {object} inst
158
158
  * @param {number|string|null} bindingId
159
159
  * @param {string[]} deps
160
160
  * @param {Array<{ cond?: => any, create: (api: typeof directApi) => Node }>} branches
161
161
  * @param {number|string|null} [regionId]
162
162
  */
163
- ifBlock(inst: any, bindingId: any, deps: any, branches: any, regionId?: any): HTMLSpanElement;
163
+ ifBlock(inst: any, bindingId: any, deps: any, branches: any, regionId?: any): DocumentFragment;
164
164
  /**
165
165
  * Direct keyed each — no blueprint `kind: "each"` dispatch.
166
166
  * /: Set/Map + Fragment batch insert; item-local binds; host field dispatch; event delegate.
@@ -172,6 +172,13 @@ export declare const directApi: {
172
172
  */
173
173
  eachBlock(inst: any, bindingId: any, deps: any, spec: any, regionId?: any): DocumentFragment;
174
174
  };
175
+ /**
176
+ * Vue-familiar `@click` / legacy `onClick` → canonical `onXxx` prop name.
177
+ * Function props that are not events are treated as getters (`v.call(host)`).
178
+ * @param {unknown} name
179
+ * @returns {string | null}
180
+ */
181
+ export declare function eventPropHandlerName(name: any): string;
175
182
  export declare function isEventPropName(name: any): boolean;
176
183
  /** HTML boolean attributes: presence means true; `false`/`null` must remove the attr. */
177
184
  export declare const BOOLEAN_HTML_ATTRS: Set<string>;
package/dist/dom-core.js CHANGED
@@ -9,6 +9,12 @@
9
9
  */
10
10
  /** @type {Record<string, new (props?: object) => any>} */
11
11
  const components = Object.create(null);
12
+ /**
13
+ * Inline chip hosts that opt into `display: contents` (`ui-direct-host-box`).
14
+ * Block surfaces (DataTable / shells / overlays) must keep a real layout box.
15
+ * @type {Set<string>}
16
+ */
17
+ const INLINE_HOST_CONTENTS = new Set(['Button', 'Badge', 'Link', 'Tag', 'Icon']);
12
18
  /**
13
19
  * Precision lab counters (test / MCP / benchmarks — not a user API).
14
20
  * Primary keys: BindingId (IR). `*ByDep` is transitional stable-string adapter.
@@ -428,8 +434,9 @@ export const directApi = {
428
434
  /** @type {Record<string, any>} */
429
435
  const resolved = {};
430
436
  for (const [k, v] of Object.entries(props || {})) {
431
- if (typeof v === 'function' && isEventPropName(k))
432
- resolved[k] = v;
437
+ const onKey = typeof v === 'function' ? eventPropHandlerName(k) : null;
438
+ if (onKey)
439
+ resolved[onKey] = v;
433
440
  else if (typeof v === 'function')
434
441
  resolved[k] = v.call(hostInst);
435
442
  else
@@ -454,6 +461,13 @@ export const directApi = {
454
461
  const Ctor = components[name];
455
462
  if (!Ctor)
456
463
  throw new Error(`vmz:dom unknown component <${name} />`);
464
+ // Inline chips opt into no-box host (`ui-direct-host-box`). Block surfaces
465
+ // (tables/shells/overlays) keep a real box — default `contents` breaks
466
+ // hit-testing / slot hosts (DataTable select timed out in ui-automation).
467
+ const hostBox = Ctor.__vmzHostBox;
468
+ if (hostBox === 'contents' || (hostBox == null && INLINE_HOST_CONTENTS.has(String(name)))) {
469
+ host.style.display = 'contents';
470
+ }
457
471
  const child = createInstance(Ctor, resolved);
458
472
  if (!(Ctor.__vmzDirect && typeof Ctor.__vmzCreate === 'function')) {
459
473
  throw new Error(`vmz:dom direct component <${name}> requires __vmzCreate (rebuild child with Direct)`);
@@ -539,7 +553,7 @@ export const directApi = {
539
553
  hostEl.appendChild(node);
540
554
  },
541
555
  /**
542
- * Direct if/else — no blueprint `kind: "if"` dispatch.
556
+ * Direct if/else — comment anchors (no empty `span[data-vmz-if]` layout box).
543
557
  * @param {object} inst
544
558
  * @param {number|string|null} bindingId
545
559
  * @param {string[]} deps
@@ -548,10 +562,22 @@ export const directApi = {
548
562
  */
549
563
  ifBlock(inst, bindingId, deps, branches, regionId = null) {
550
564
  noteDomCreate();
551
- const host = document.createElement('span');
552
- host.setAttribute('data-vmz-if', '');
565
+ const start = document.createComment('vmz-if');
566
+ const end = document.createComment('/vmz-if');
553
567
  if (regionId != null)
554
- host.setAttribute('data-vmz-region', String(regionId));
568
+ start.__vmzRegion = regionId;
569
+ const frag = document.createDocumentFragment();
570
+ frag.appendChild(start);
571
+ /** @type {HTMLElement | null} */
572
+ let regionHost = null;
573
+ if (regionId != null) {
574
+ // Queryable region marker without a layout box (`display: contents`).
575
+ regionHost = document.createElement('span');
576
+ regionHost.style.display = 'contents';
577
+ regionHost.setAttribute('data-vmz-region', String(regionId));
578
+ frag.appendChild(regionHost);
579
+ }
580
+ frag.appendChild(end);
555
581
  /** @type {Array<Node | null>} */
556
582
  const cached = branches.map(() => null);
557
583
  /** @type {Array<Array<{ deps: string[], fn: => any, bindingId?: number|string|null }>>} */
@@ -634,14 +660,18 @@ export const directApi = {
634
660
  if (next < 0)
635
661
  return;
636
662
  wireBranch(next);
637
- if (cached[next])
638
- host.appendChild(cached[next]);
663
+ if (cached[next] && end.parentNode) {
664
+ if (regionHost)
665
+ regionHost.appendChild(cached[next]);
666
+ else
667
+ end.parentNode.insertBefore(cached[next], end);
668
+ }
639
669
  };
640
670
  registerBind(inst, deps || [], apply, bindingId);
641
671
  if (directApi._itemPatches)
642
672
  directApi._itemPatches.push(apply);
643
673
  // parent destroy disposes all cached branch trees (pause ≠ destroy on switch).
644
- host.__vmzDispose = () => {
674
+ start.__vmzDispose = () => {
645
675
  for (let i = 0; i < cached.length; i++) {
646
676
  unwireBranch(i);
647
677
  if (cached[i])
@@ -651,7 +681,7 @@ export const directApi = {
651
681
  active = -1;
652
682
  };
653
683
  apply();
654
- return host;
684
+ return frag;
655
685
  },
656
686
  /**
657
687
  * Direct keyed each — no blueprint `kind: "each"` dispatch.
@@ -2616,8 +2646,28 @@ function wireDirectBind(inst, bindingId, deps, get, write, cf) {
2616
2646
  apply();
2617
2647
  trackDirectBind(inst, liveDeps, apply, bindingId);
2618
2648
  }
2649
+ /**
2650
+ * Vue-familiar `@click` / legacy `onClick` → canonical `onXxx` prop name.
2651
+ * Function props that are not events are treated as getters (`v.call(host)`).
2652
+ * @param {unknown} name
2653
+ * @returns {string | null}
2654
+ */
2655
+ export function eventPropHandlerName(name) {
2656
+ if (typeof name !== 'string' || !name)
2657
+ return null;
2658
+ if (/^on[A-Z]/.test(name))
2659
+ return name;
2660
+ // `@input` → `onInput`; reject `@update:modelValue` until that surface exists.
2661
+ if (name.charAt(0) === '@' && name.length > 1 && !name.includes(':')) {
2662
+ const ev = name.slice(1);
2663
+ if (!ev || !/^[A-Za-z]/.test(ev))
2664
+ return null;
2665
+ return `on${ev.charAt(0).toUpperCase()}${ev.slice(1)}`;
2666
+ }
2667
+ return null;
2668
+ }
2619
2669
  export function isEventPropName(name) {
2620
- return typeof name === 'string' && /^on[A-Z]/.test(name);
2670
+ return eventPropHandlerName(name) != null;
2621
2671
  }
2622
2672
  /** Monotonic id for `bindComponentProp` BindingIds (per process). */
2623
2673
  let directPropBindSeq = 0;
package/dist/dom-ssr.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * VMZ DOM SSR / hydrate / resume — precise patches, no VDOM diff.
4
4
  * Imports client DOM primitives from ./dom-core.js for tree-shakeable browser entry.
5
5
  */
6
- import { applyDomAttr, applyPreservedState, BOOLEAN_HTML_ATTRS, createInstance, destroy, directApi, getRegisteredComponent, hasMeaningfulChild, isEventEntryStrategy, isEventPropName, mount, noteDomCreate, resolveComponent, runDirectCreate, scheduleClientOn, settlePendingChildMounts, snapshotInstanceState, stripFns, } from './dom-core.js';
6
+ import { applyDomAttr, applyPreservedState, BOOLEAN_HTML_ATTRS, createInstance, destroy, directApi, eventPropHandlerName, getRegisteredComponent, hasMeaningfulChild, isEventEntryStrategy, mount, noteDomCreate, resolveComponent, runDirectCreate, scheduleClientOn, settlePendingChildMounts, snapshotInstanceState, stripFns, } from './dom-core.js';
7
7
  /** @type {Error | null} last linkedom resolve failure (for clear SSR errors) */
8
8
  let _ssrDocumentLastError = null;
9
9
  /**
@@ -671,16 +671,8 @@ const serializeApi = {
671
671
  el.children = [];
672
672
  },
673
673
  ifBlock(inst, bindingId, deps, branches) {
674
- const host = {
675
- __kind: 'el',
676
- tag: 'span',
677
- attrs: { 'data-vmz-if': '' },
678
- children: [],
679
- appendChild(c) {
680
- if (c != null)
681
- this.children.push(c);
682
- },
683
- };
674
+ // No empty `span[data-vmz-if]` box (`ui-vif-dom`): false → empty frag.
675
+ const frag = serializeApi.frag();
684
676
  let idx = -1;
685
677
  for (let i = 0; i < branches.length; i++) {
686
678
  const b = branches[i];
@@ -701,9 +693,9 @@ const serializeApi = {
701
693
  if (idx >= 0 && branches[idx].create) {
702
694
  const created = branches[idx].create.call(inst, serializeApi);
703
695
  if (created)
704
- host.children.push(created);
696
+ frag.appendChild(created);
705
697
  }
706
- return host;
698
+ return frag;
707
699
  },
708
700
  eachBlock(inst, bindingId, deps, spec) {
709
701
  const frag = serializeApi.frag();
@@ -755,7 +747,8 @@ const serializeApi = {
755
747
  /** @type {Record<string, any>} */
756
748
  const resolved = {};
757
749
  for (const [k, v] of Object.entries(props || {})) {
758
- if (typeof v === 'function' && isEventPropName(k))
750
+ const onKey = typeof v === 'function' ? eventPropHandlerName(k) : null;
751
+ if (onKey)
759
752
  continue;
760
753
  else if (typeof v === 'function')
761
754
  resolved[k] = v.call(hostInst);
@@ -815,10 +808,17 @@ const serializeApi = {
815
808
  serializeApi._inst = child;
816
809
  try {
817
810
  const node = Ctor.__vmzCreate.call(child, serializeApi);
811
+ /** @type {Record<string, string>} */
812
+ const attrs = { 'data-vmz': name };
813
+ const hostBox = Ctor.__vmzHostBox;
814
+ if (hostBox === 'contents' ||
815
+ (hostBox == null && (name === 'Button' || name === 'Badge' || name === 'Link' || name === 'Tag' || name === 'Icon'))) {
816
+ attrs.style = 'display:contents';
817
+ }
818
818
  return {
819
819
  __kind: 'el',
820
820
  tag: 'div',
821
- attrs: { 'data-vmz': name },
821
+ attrs,
822
822
  children: node ? [node] : [],
823
823
  appendChild(c) {
824
824
  if (c != null)
@@ -211,6 +211,22 @@ async function renderPage(pathname, opts = {}) {
211
211
  * @returns {Promise<{ status: number, stream?: AsyncGenerator<string, void, void>, redirect?: string, headers?: Record<string, string> } | null>}
212
212
  */
213
213
  async function renderPageStream(pathname, opts = {}) {
214
+ try {
215
+ return await renderPageStreamInner(pathname, opts);
216
+ }
217
+ catch (err) {
218
+ const normalized = normalizeDevError(err);
219
+ lastDevError = normalized;
220
+ console.error('vmz serve: renderPageStream failed', normalized.message);
221
+ return { status: 500, stream: emitDevErrorHtml(normalized) };
222
+ }
223
+ }
224
+ /**
225
+ * @param {string} pathname
226
+ * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string, method?: string, body?: unknown }} [opts]
227
+ * @returns {Promise<{ status: number, stream?: AsyncGenerator<string, void, void>, redirect?: string, headers?: Record<string, string> } | null>}
228
+ */
229
+ async function renderPageStreamInner(pathname, opts = {}) {
214
230
  if (isDev && lastDevError && pageCtors.size === 0) {
215
231
  return { status: 500, stream: emitDevErrorHtml(lastDevError) };
216
232
  }
@@ -945,7 +961,7 @@ async function* emitDevErrorHtml(err) {
945
961
  .hint{opacity:.65;font-size:12px}
946
962
  </style>`;
947
963
  const body = `${style}
948
- <main>
964
+ <main data-vmz-error="500">
949
965
  <h1>Dev Error</h1>
950
966
  <pre>${msg}</pre>
951
967
  ${stack ? `<pre style="opacity:.7;font-size:12px">${stack}</pre>` : ''}
@@ -961,17 +977,23 @@ async function* emitDevErrorHtml(err) {
961
977
  };
962
978
  })();
963
979
  </script>`;
964
- const native = loadNativeAddon();
965
- if (typeof native.generateHtmlShell !== 'function') {
966
- throw new Error('vmz native addon missing generateHtmlShell rebuild with `pnpm napi:build`');
980
+ try {
981
+ const native = loadNativeAddon();
982
+ if (typeof native.generateHtmlShell === 'function') {
983
+ yield native.generateHtmlShell({
984
+ title: 'Dev Error',
985
+ lang: 'en',
986
+ cssHrefs: [],
987
+ bodyHtml: body,
988
+ bodyAttrs: [],
989
+ });
990
+ return;
991
+ }
967
992
  }
968
- yield native.generateHtmlShell({
969
- title: 'Dev Error',
970
- lang: 'en',
971
- cssHrefs: [],
972
- bodyHtml: body,
973
- bodyAttrs: [],
974
- });
993
+ catch {
994
+ /* fall through to plain HTML — never throw from the error page itself */
995
+ }
996
+ yield `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8" /><title>Dev Error</title></head><body>${body}</body></html>`;
975
997
  }
976
998
  /** @param {string} s */
977
999
  function escapeHtml(s) {
package/dist/server.js CHANGED
@@ -328,7 +328,12 @@ export async function handleNodeRequest(req, res, opts = {}) {
328
328
  }
329
329
  }
330
330
  if (!res.headersSent) {
331
- sendJson(res, 404, { error: 'not found', path: url.pathname });
331
+ if (wantsBrowserHtml(req)) {
332
+ sendHtml(res, 404, browserErrorHtml(404, 'Not Found', `No page matched ${url.pathname}`));
333
+ }
334
+ else {
335
+ sendJson(res, 404, { error: 'not found', path: url.pathname });
336
+ }
332
337
  }
333
338
  }
334
339
  catch (err) {
@@ -342,9 +347,14 @@ export async function handleNodeRequest(req, res, opts = {}) {
342
347
  }
343
348
  return;
344
349
  }
345
- sendJson(res, 500, {
346
- error: err instanceof Error ? err.message : String(err),
347
- });
350
+ const message = err instanceof Error ? err.message : String(err);
351
+ const stack = err instanceof Error ? err.stack : undefined;
352
+ if (wantsBrowserHtml(req)) {
353
+ sendHtml(res, 500, browserErrorHtml(500, 'Dev Error', message, stack));
354
+ }
355
+ else {
356
+ sendJson(res, 500, { error: message });
357
+ }
348
358
  }
349
359
  }
350
360
  /**
@@ -585,7 +595,7 @@ function parseMultipartBuffer(buf, contentType) {
585
595
  const splitAt = indexOfBuffer(part, Buffer.from('\r\n\r\n'), 0);
586
596
  if (splitAt >= 0) {
587
597
  const headerText = part.subarray(0, splitAt).toString('utf8');
588
- let body = part.subarray(splitAt + 4);
598
+ const body = part.subarray(splitAt + 4);
589
599
  const nameM = /content-disposition:[^\r\n]*;\s*name="([^"]*)"/i.exec(headerText);
590
600
  const fileM = /content-disposition:[^\r\n]*;\s*filename="([^"]*)"/i.exec(headerText);
591
601
  const typeM = /content-type:\s*([^\r\n]+)/i.exec(headerText);
@@ -657,6 +667,73 @@ function parseFormBody(raw, contentType) {
657
667
  return { raw };
658
668
  }
659
669
  }
670
+ /**
671
+ * @param {import('node:http').IncomingMessage} req
672
+ * @returns {boolean}
673
+ */
674
+ function wantsBrowserHtml(req) {
675
+ const accept = String(req.headers?.accept || '');
676
+ const method = String(req.method || 'GET').toUpperCase();
677
+ if (method !== 'GET' && method !== 'HEAD')
678
+ return false;
679
+ // Document navigations: browsers send text/html and/or Sec-Fetch-Dest: document.
680
+ // Bare `*/*` / empty Accept (Node fetch, many API clients) must stay JSON.
681
+ const dest = String(req.headers?.['sec-fetch-dest'] || '');
682
+ const mode = String(req.headers?.['sec-fetch-mode'] || '');
683
+ if (dest === 'document' || mode === 'navigate')
684
+ return true;
685
+ if (!accept || accept === '*/*')
686
+ return false;
687
+ const html = accept.includes('text/html');
688
+ const json = accept.includes('application/json');
689
+ if (html && !json)
690
+ return true;
691
+ if (html && json) {
692
+ const hi = accept.indexOf('text/html');
693
+ const ji = accept.indexOf('application/json');
694
+ return hi <= ji;
695
+ }
696
+ return false;
697
+ }
698
+ /**
699
+ * Browser-facing error document (no N-API). Used when SSR returns null or throws
700
+ * on a document navigation — must not fall back to API-shaped JSON.
701
+ * @param {number} status
702
+ * @param {string} title
703
+ * @param {string} message
704
+ * @param {string} [stack]
705
+ */
706
+ function browserErrorHtml(status, title, message, stack) {
707
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
708
+ const stackBlock = stack ? `<pre class="stack">${esc(stack)}</pre>` : '';
709
+ return `<!DOCTYPE html>
710
+ <html lang="en">
711
+ <head>
712
+ <meta charset="utf-8" />
713
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
714
+ <title>${esc(title)}</title>
715
+ <style>
716
+ body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
717
+ main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
718
+ h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
719
+ .code{opacity:.65;font-size:12px;margin-bottom:.75rem}
720
+ pre{white-space:pre-wrap;margin:0 0 1rem}
721
+ .stack{opacity:.7;font-size:12px}
722
+ .hint{opacity:.65;font-size:12px}
723
+ </style>
724
+ </head>
725
+ <body data-vmz-error="${status}">
726
+ <main>
727
+ <p class="code">HTTP ${status}</p>
728
+ <h1>${esc(title)}</h1>
729
+ <pre>${esc(message)}</pre>
730
+ ${stackBlock}
731
+ <p class="hint">Document navigation returns HTML errors — not API JSON.</p>
732
+ </main>
733
+ </body>
734
+ </html>
735
+ `;
736
+ }
660
737
  /**
661
738
  * @param {import('node:http').ServerResponse} res
662
739
  * @param {number} status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/core",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "type": "module",
5
5
  "description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
6
6
  "exports": {
@@ -58,12 +58,12 @@
58
58
  "linkedom": "^0.18.13"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@vmz/vmz-win32-x64": "0.1.16",
62
- "@vmz/vmz-win32-arm64": "0.1.16",
63
- "@vmz/vmz-darwin-x64": "0.1.16",
64
- "@vmz/vmz-darwin-arm64": "0.1.16",
65
- "@vmz/vmz-linux-x64": "0.1.16",
66
- "@vmz/vmz-linux-arm64": "0.1.16"
61
+ "@vmz/vmz-win32-x64": "0.1.18",
62
+ "@vmz/vmz-win32-arm64": "0.1.18",
63
+ "@vmz/vmz-darwin-x64": "0.1.18",
64
+ "@vmz/vmz-darwin-arm64": "0.1.18",
65
+ "@vmz/vmz-linux-x64": "0.1.18",
66
+ "@vmz/vmz-linux-arm64": "0.1.18"
67
67
  },
68
68
  "publishConfig": {
69
69
  "access": "public"