@ashley-shrok/viewmodel-shell 3.8.0 → 3.11.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.
- package/dist/browser.d.ts +10 -0
- package/dist/browser.js +63 -14
- package/dist/index.d.ts +9 -2
- package/dist/vite.d.ts +68 -0
- package/dist/vite.js +116 -0
- package/package.json +14 -1
package/dist/browser.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
3
3
|
private container;
|
|
4
4
|
private fileRegistry;
|
|
5
5
|
private sa;
|
|
6
|
+
private diagWarned;
|
|
6
7
|
private detailsOpenSnapshot;
|
|
7
8
|
private sectionKeyCounter;
|
|
8
9
|
private fitsObservers;
|
|
@@ -77,6 +78,15 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
77
78
|
/** FieldNode — reads value from `sa.read(bind)`; writes back on input/change.
|
|
78
79
|
* When `action` is set, it fires on Enter (text-like) or change (select) —
|
|
79
80
|
* the new value is already in state by that point. */
|
|
81
|
+
/** Warn to the dev console at most once per key (deduped over this adapter's
|
|
82
|
+
* lifetime). Fires in dev AND prod — the client bundle can't tell them apart,
|
|
83
|
+
* and prod telemetry that captures console.warn should see these too. */
|
|
84
|
+
private warnOnce;
|
|
85
|
+
/** Read a bind path, tolerating a bind-less field (file inputs) — null bind
|
|
86
|
+
* reads nothing. */
|
|
87
|
+
private readBind;
|
|
88
|
+
/** Write to a bind path, no-op when the field has no bind (file inputs). */
|
|
89
|
+
private writeBind;
|
|
80
90
|
private field;
|
|
81
91
|
/** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
|
|
82
92
|
* render help + error text below it, wiring aria-describedby / aria-invalid.
|
package/dist/browser.js
CHANGED
|
@@ -46,6 +46,11 @@ export class BrowserAdapter {
|
|
|
46
46
|
container;
|
|
47
47
|
fileRegistry = new Map();
|
|
48
48
|
sa = noopStateAccess;
|
|
49
|
+
// Dev-console diagnostics dedup (3.9.0). Both [vms:no-bind] and
|
|
50
|
+
// [vms:type-mismatch] warn at most once per key over this adapter's lifetime.
|
|
51
|
+
// The client bundle can't distinguish dev/prod, so these fire in both — which
|
|
52
|
+
// is intentional: prod telemetry that captures console.warn sees them too.
|
|
53
|
+
diagWarned = new Set();
|
|
49
54
|
// 1.2.0 — open-state snapshot for SectionNode.collapsible. Captured by
|
|
50
55
|
// render() BEFORE this.container.innerHTML = "" by walking
|
|
51
56
|
// [data-section-key] details elements; consumed by render() AFTER node()
|
|
@@ -675,8 +680,35 @@ export class BrowserAdapter {
|
|
|
675
680
|
/** FieldNode — reads value from `sa.read(bind)`; writes back on input/change.
|
|
676
681
|
* When `action` is set, it fires on Enter (text-like) or change (select) —
|
|
677
682
|
* the new value is already in state by that point. */
|
|
683
|
+
/** Warn to the dev console at most once per key (deduped over this adapter's
|
|
684
|
+
* lifetime). Fires in dev AND prod — the client bundle can't tell them apart,
|
|
685
|
+
* and prod telemetry that captures console.warn should see these too. */
|
|
686
|
+
warnOnce(key, msg) {
|
|
687
|
+
if (!this.diagWarned.has(key)) {
|
|
688
|
+
this.diagWarned.add(key);
|
|
689
|
+
console.warn(msg);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/** Read a bind path, tolerating a bind-less field (file inputs) — null bind
|
|
693
|
+
* reads nothing. */
|
|
694
|
+
readBind(bind) {
|
|
695
|
+
return bind == null ? undefined : this.sa.read(bind);
|
|
696
|
+
}
|
|
697
|
+
/** Write to a bind path, no-op when the field has no bind (file inputs). */
|
|
698
|
+
writeBind(bind, value) {
|
|
699
|
+
if (bind != null)
|
|
700
|
+
this.sa.write(bind, value);
|
|
701
|
+
}
|
|
678
702
|
field(n, parent, on) {
|
|
679
|
-
const stateValue = this.
|
|
703
|
+
const stateValue = this.readBind(n.bind);
|
|
704
|
+
// [vms:no-bind] — a value-bearing input with no bind renders but silently
|
|
705
|
+
// drops user input (nothing to persist to). Exclude `file` (bind is
|
|
706
|
+
// legitimately optional — the binary rides multipart) and `hidden`
|
|
707
|
+
// (server-authoritative, no user input).
|
|
708
|
+
if (n.inputType !== "file" && n.inputType !== "hidden" && n.bind == null) {
|
|
709
|
+
this.warnOnce("no-bind:" + n.name, "[vms:no-bind] FieldNode '" + n.name + "' (inputType=" + n.inputType +
|
|
710
|
+
") has no bind — value-bearing inputs need a bind path to persist; the field renders but user input is dropped.");
|
|
711
|
+
}
|
|
680
712
|
if (n.inputType === "hidden") {
|
|
681
713
|
// Hidden fields don't write back — server is authoritative for hidden.
|
|
682
714
|
const inp = document.createElement("input");
|
|
@@ -699,7 +731,7 @@ export class BrowserAdapter {
|
|
|
699
731
|
inp.name = n.name;
|
|
700
732
|
inp.checked = Boolean(stateValue);
|
|
701
733
|
inp.addEventListener("change", () => {
|
|
702
|
-
this.
|
|
734
|
+
this.writeBind(n.bind, inp.checked);
|
|
703
735
|
});
|
|
704
736
|
wrapper.appendChild(inp);
|
|
705
737
|
if (n.label) {
|
|
@@ -752,19 +784,19 @@ export class BrowserAdapter {
|
|
|
752
784
|
// explicit empty the server can reject, never a silently-missing key.
|
|
753
785
|
if (isMulti) {
|
|
754
786
|
if (!Array.isArray(stateValue)) {
|
|
755
|
-
this.
|
|
787
|
+
this.writeBind(n.bind, Array.from(sel.selectedOptions, o => o.value));
|
|
756
788
|
}
|
|
757
789
|
}
|
|
758
790
|
else if (stateValue === undefined || String(stateValue) !== sel.value) {
|
|
759
|
-
this.
|
|
791
|
+
this.writeBind(n.bind, sel.value);
|
|
760
792
|
}
|
|
761
793
|
sel.addEventListener("change", () => {
|
|
762
794
|
if (isMulti) {
|
|
763
795
|
const arr = Array.from(sel.selectedOptions).map(o => o.value);
|
|
764
|
-
this.
|
|
796
|
+
this.writeBind(n.bind, arr);
|
|
765
797
|
}
|
|
766
798
|
else {
|
|
767
|
-
this.
|
|
799
|
+
this.writeBind(n.bind, sel.value);
|
|
768
800
|
}
|
|
769
801
|
if (n.action)
|
|
770
802
|
on({ name: n.action.name });
|
|
@@ -791,14 +823,31 @@ export class BrowserAdapter {
|
|
|
791
823
|
const file = inp.files?.[0];
|
|
792
824
|
if (file) {
|
|
793
825
|
this.fileRegistry.set(n.name, file);
|
|
826
|
+
// [vms:type-mismatch] — OBSERVABLE-SUBSET diagnostic. The client is
|
|
827
|
+
// untyped JS: it CANNOT know a state slot's *declared* server type, so
|
|
828
|
+
// it only catches the observable case where a file object overwrites a
|
|
829
|
+
// slot that already holds a scalar. It does NOT catch an empty/null slot
|
|
830
|
+
// typed string-map server-side — certain detection of that is a
|
|
831
|
+
// server-side `_state` deserialize diagnostic (a separate follow-up).
|
|
832
|
+
if (n.bind != null) {
|
|
833
|
+
const existing = this.readBind(n.bind);
|
|
834
|
+
if (existing != null && typeof existing !== "object") {
|
|
835
|
+
this.warnOnce("type-mismatch:" + n.name + ":" + n.bind, "[vms:type-mismatch] file FieldNode '" + n.name +
|
|
836
|
+
"' writes a {filename,size} object into bind '" + n.bind +
|
|
837
|
+
"', whose current state value is a " + (typeof existing) +
|
|
838
|
+
" — if that slot is typed string/string-map the _state round-trip will FAIL (cannot convert object to String). Give the file field an object-typed slot, or omit bind (the file rides multipart regardless).");
|
|
839
|
+
}
|
|
840
|
+
}
|
|
794
841
|
// Per Phase-6 decision: the picked file is visible in state as a
|
|
795
842
|
// serialization-safe placeholder; the binary travels on the
|
|
796
|
-
// multipart side channel.
|
|
797
|
-
|
|
843
|
+
// multipart side channel. Backward-compat: apps binding a file field
|
|
844
|
+
// to an object slot still get the placeholder. A bind-less file input
|
|
845
|
+
// writes nothing (writeBind no-ops) — the binary rides multipart.
|
|
846
|
+
this.writeBind(n.bind, { filename: file.name, size: file.size });
|
|
798
847
|
}
|
|
799
848
|
else {
|
|
800
849
|
this.fileRegistry.delete(n.name);
|
|
801
|
-
this.
|
|
850
|
+
this.writeBind(n.bind, null);
|
|
802
851
|
}
|
|
803
852
|
});
|
|
804
853
|
wrapper.appendChild(inp);
|
|
@@ -813,7 +862,7 @@ export class BrowserAdapter {
|
|
|
813
862
|
ta.value = stateValue == null ? "" : String(stateValue);
|
|
814
863
|
if (n.required)
|
|
815
864
|
ta.required = true;
|
|
816
|
-
ta.addEventListener("input", () => { this.
|
|
865
|
+
ta.addEventListener("input", () => { this.writeBind(n.bind, ta.value); });
|
|
817
866
|
wrapper.appendChild(ta);
|
|
818
867
|
}
|
|
819
868
|
else if (n.inputType === "code") {
|
|
@@ -836,7 +885,7 @@ export class BrowserAdapter {
|
|
|
836
885
|
ta.value = stateValue == null ? "" : String(stateValue);
|
|
837
886
|
if (n.required)
|
|
838
887
|
ta.required = true;
|
|
839
|
-
ta.addEventListener("input", () => { this.
|
|
888
|
+
ta.addEventListener("input", () => { this.writeBind(n.bind, ta.value); });
|
|
840
889
|
ta.addEventListener("keydown", (e) => {
|
|
841
890
|
if (e.key === "Tab") {
|
|
842
891
|
e.preventDefault();
|
|
@@ -844,7 +893,7 @@ export class BrowserAdapter {
|
|
|
844
893
|
const end = ta.selectionEnd ?? 0;
|
|
845
894
|
ta.value = ta.value.slice(0, start) + "\t" + ta.value.slice(end);
|
|
846
895
|
ta.selectionStart = ta.selectionEnd = start + 1;
|
|
847
|
-
this.
|
|
896
|
+
this.writeBind(n.bind, ta.value);
|
|
848
897
|
}
|
|
849
898
|
});
|
|
850
899
|
wrapper.appendChild(ta);
|
|
@@ -860,7 +909,7 @@ export class BrowserAdapter {
|
|
|
860
909
|
inp.value = stateValue == null ? "" : String(stateValue);
|
|
861
910
|
if (n.required)
|
|
862
911
|
inp.required = true;
|
|
863
|
-
inp.addEventListener("input", () => { this.
|
|
912
|
+
inp.addEventListener("input", () => { this.writeBind(n.bind, inp.value); });
|
|
864
913
|
if (n.action) {
|
|
865
914
|
const action = n.action;
|
|
866
915
|
inp.addEventListener("keydown", (e) => {
|
|
@@ -869,7 +918,7 @@ export class BrowserAdapter {
|
|
|
869
918
|
// Belt-and-suspenders: flush the latest value to state before
|
|
870
919
|
// dispatching, in case the browser hasn't fired `input` yet
|
|
871
920
|
// (e.g. an autofill that lands then submits).
|
|
872
|
-
this.
|
|
921
|
+
this.writeBind(n.bind, inp.value);
|
|
873
922
|
on({ name: action.name });
|
|
874
923
|
}
|
|
875
924
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -267,8 +267,15 @@ export interface FieldNode {
|
|
|
267
267
|
type: "field";
|
|
268
268
|
name: string;
|
|
269
269
|
inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "code";
|
|
270
|
-
/** Path into state where this input reads its current value and writes user
|
|
271
|
-
|
|
270
|
+
/** Path into state where this input reads its current value and writes user
|
|
271
|
+
* changes (e.g. `fields.title`). REQUIRED for value-bearing inputs
|
|
272
|
+
* (text/email/password/number/date/time/datetime-local/textarea/select/
|
|
273
|
+
* select-multiple/checkbox/code) and OPTIONAL for `file` inputs — a file
|
|
274
|
+
* input's binary rides the multipart side channel (fileRegistry keyed on
|
|
275
|
+
* `name`), so omit `bind` on a file input to avoid writing a
|
|
276
|
+
* `{filename,size}` placeholder object into state (which breaks a
|
|
277
|
+
* string/string-map state slot on round-trip). */
|
|
278
|
+
bind?: string;
|
|
272
279
|
label?: string;
|
|
273
280
|
placeholder?: string;
|
|
274
281
|
required?: boolean;
|
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
/**
|
|
3
|
+
* Hash the raw bytes of a Vite `manifest.json` into a VMS client build-id.
|
|
4
|
+
*
|
|
5
|
+
* The LOCKED cross-backend contract: SHA-256 of the **raw file bytes on disk**
|
|
6
|
+
* → the **first `hashLength` hex chars, LOWERCASE**. No re-serialize, no JSON
|
|
7
|
+
* normalization, no BOM handling — both the npm plugin and the .NET
|
|
8
|
+
* `VmsManifestBuildId.Compute` hash the exact same bytes so a client's
|
|
9
|
+
* compiled-in id matches the server-computed id byte-for-byte across the fleet.
|
|
10
|
+
*
|
|
11
|
+
* Exported so the cross-backend hash-lock test can call it directly against the
|
|
12
|
+
* shared fixture bytes.
|
|
13
|
+
*
|
|
14
|
+
* @param bytes Raw `manifest.json` file bytes.
|
|
15
|
+
* @param hashLength Number of leading hex chars to keep (default 12).
|
|
16
|
+
*/
|
|
17
|
+
export declare function vmsHashManifestBytes(bytes: Uint8Array, hashLength?: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Options for {@link vmsBuildIdPlugin}.
|
|
20
|
+
*/
|
|
21
|
+
export interface VmsBuildIdPluginOptions {
|
|
22
|
+
/**
|
|
23
|
+
* File extensions of emitted chunks whose source should have the build-id
|
|
24
|
+
* placeholder substituted. Default `[".js"]` — the shell bundle is JS, so CSS
|
|
25
|
+
* and other assets are left untouched (and never carry the placeholder).
|
|
26
|
+
*/
|
|
27
|
+
extensions?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* Number of leading hex chars of the SHA-256 to use as the build id.
|
|
30
|
+
* Default 12 (the locked contract shared with the .NET side).
|
|
31
|
+
*/
|
|
32
|
+
hashLength?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Vite plugin that stamps a content-hash of the built `manifest.json` into the
|
|
36
|
+
* client bundle so `ShellOptions.clientBuildId` matches the server's
|
|
37
|
+
* `AddVmsShellVersioning()` hash for version-skew detection.
|
|
38
|
+
*
|
|
39
|
+
* ## How it works (the chicken-and-egg fix)
|
|
40
|
+
* Vite writes chunk content-hashes into `manifest.json` during the bundle, so
|
|
41
|
+
* the final manifest hash isn't known until after the bundle is written. The
|
|
42
|
+
* plugin:
|
|
43
|
+
*
|
|
44
|
+
* 1. `config` hook — injects `import.meta.env.VITE_VMS_BUILD` as a unique
|
|
45
|
+
* internal placeholder token via `define`, and (if unset) turns on
|
|
46
|
+
* `build.manifest = "manifest.json"` at the server-read path.
|
|
47
|
+
* 2. `writeBundle` hook — reads the emitted `manifest.json` raw bytes, computes
|
|
48
|
+
* the build id via {@link vmsHashManifestBytes}, and string-replaces the
|
|
49
|
+
* placeholder in every emitted chunk whose filename ends in one of
|
|
50
|
+
* `extensions` **that actually contains the placeholder** (the skip-guard —
|
|
51
|
+
* so non-shell chunks are never rewritten).
|
|
52
|
+
*
|
|
53
|
+
* ## The `build.manifest` gotcha it kills
|
|
54
|
+
* Vite 5+ defaults the manifest to `.vite/manifest.json`, but the .NET server
|
|
55
|
+
* reads `wwwroot/manifest.json`. If those diverge the two sides hash different
|
|
56
|
+
* inputs and skew detection silently breaks. This plugin sets
|
|
57
|
+
* `build.manifest = "manifest.json"` when unset. If the app has ALREADY set a
|
|
58
|
+
* DIFFERENT manifest path, the plugin does NOT override it — it emits a `[vms]`
|
|
59
|
+
* warning so the divergence is loud, not silent.
|
|
60
|
+
*
|
|
61
|
+
* ## Fleet constraint — no post-build modification of `manifest.json`
|
|
62
|
+
* The server hashes `manifest.json` at startup; the client compiled-in id is
|
|
63
|
+
* hashed at build time. A deploy-pipeline step that minifies / prettifies /
|
|
64
|
+
* re-formats `manifest.json` between Vite emit and .NET startup changes the raw
|
|
65
|
+
* bytes and diverges the two hashes. Ship the manifest byte-for-byte as Vite
|
|
66
|
+
* wrote it.
|
|
67
|
+
*/
|
|
68
|
+
export declare function vmsBuildIdPlugin(options?: VmsBuildIdPluginOptions): Plugin;
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// ─── ViewModel Shell — vite subpath (3.11.0) ─────────────────────────────────
|
|
2
|
+
// A Vite plugin that packages the version-skew (3.8.0) client build-id contract
|
|
3
|
+
// so adopters stop hand-rolling the ~40-line placeholder/writeBundle glue.
|
|
4
|
+
//
|
|
5
|
+
// Adoption drops to two lines:
|
|
6
|
+
// // vite.config.ts
|
|
7
|
+
// import { vmsBuildIdPlugin } from "@ashley-shrok/viewmodel-shell/vite";
|
|
8
|
+
// export default { plugins: [vmsBuildIdPlugin()] };
|
|
9
|
+
// // shell init
|
|
10
|
+
// new ViewModelShell({ …, clientBuildId: import.meta.env.VITE_VMS_BUILD });
|
|
11
|
+
//
|
|
12
|
+
// The `vite` import is TYPE-ONLY (`import type`), so the built `dist/vite.js`
|
|
13
|
+
// carries NO runtime `require("vite")` — non-Vite consumers of the root package
|
|
14
|
+
// never pull vite in. Node builtins (`fs`/`crypto`/`path`) are fine here: the
|
|
15
|
+
// core-globals CI guard only scopes `src/index.ts`.
|
|
16
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
17
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
/**
|
|
20
|
+
* Hash the raw bytes of a Vite `manifest.json` into a VMS client build-id.
|
|
21
|
+
*
|
|
22
|
+
* The LOCKED cross-backend contract: SHA-256 of the **raw file bytes on disk**
|
|
23
|
+
* → the **first `hashLength` hex chars, LOWERCASE**. No re-serialize, no JSON
|
|
24
|
+
* normalization, no BOM handling — both the npm plugin and the .NET
|
|
25
|
+
* `VmsManifestBuildId.Compute` hash the exact same bytes so a client's
|
|
26
|
+
* compiled-in id matches the server-computed id byte-for-byte across the fleet.
|
|
27
|
+
*
|
|
28
|
+
* Exported so the cross-backend hash-lock test can call it directly against the
|
|
29
|
+
* shared fixture bytes.
|
|
30
|
+
*
|
|
31
|
+
* @param bytes Raw `manifest.json` file bytes.
|
|
32
|
+
* @param hashLength Number of leading hex chars to keep (default 12).
|
|
33
|
+
*/
|
|
34
|
+
export function vmsHashManifestBytes(bytes, hashLength = 12) {
|
|
35
|
+
return createHash("sha256").update(bytes).digest("hex").slice(0, hashLength);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Vite plugin that stamps a content-hash of the built `manifest.json` into the
|
|
39
|
+
* client bundle so `ShellOptions.clientBuildId` matches the server's
|
|
40
|
+
* `AddVmsShellVersioning()` hash for version-skew detection.
|
|
41
|
+
*
|
|
42
|
+
* ## How it works (the chicken-and-egg fix)
|
|
43
|
+
* Vite writes chunk content-hashes into `manifest.json` during the bundle, so
|
|
44
|
+
* the final manifest hash isn't known until after the bundle is written. The
|
|
45
|
+
* plugin:
|
|
46
|
+
*
|
|
47
|
+
* 1. `config` hook — injects `import.meta.env.VITE_VMS_BUILD` as a unique
|
|
48
|
+
* internal placeholder token via `define`, and (if unset) turns on
|
|
49
|
+
* `build.manifest = "manifest.json"` at the server-read path.
|
|
50
|
+
* 2. `writeBundle` hook — reads the emitted `manifest.json` raw bytes, computes
|
|
51
|
+
* the build id via {@link vmsHashManifestBytes}, and string-replaces the
|
|
52
|
+
* placeholder in every emitted chunk whose filename ends in one of
|
|
53
|
+
* `extensions` **that actually contains the placeholder** (the skip-guard —
|
|
54
|
+
* so non-shell chunks are never rewritten).
|
|
55
|
+
*
|
|
56
|
+
* ## The `build.manifest` gotcha it kills
|
|
57
|
+
* Vite 5+ defaults the manifest to `.vite/manifest.json`, but the .NET server
|
|
58
|
+
* reads `wwwroot/manifest.json`. If those diverge the two sides hash different
|
|
59
|
+
* inputs and skew detection silently breaks. This plugin sets
|
|
60
|
+
* `build.manifest = "manifest.json"` when unset. If the app has ALREADY set a
|
|
61
|
+
* DIFFERENT manifest path, the plugin does NOT override it — it emits a `[vms]`
|
|
62
|
+
* warning so the divergence is loud, not silent.
|
|
63
|
+
*
|
|
64
|
+
* ## Fleet constraint — no post-build modification of `manifest.json`
|
|
65
|
+
* The server hashes `manifest.json` at startup; the client compiled-in id is
|
|
66
|
+
* hashed at build time. A deploy-pipeline step that minifies / prettifies /
|
|
67
|
+
* re-formats `manifest.json` between Vite emit and .NET startup changes the raw
|
|
68
|
+
* bytes and diverges the two hashes. Ship the manifest byte-for-byte as Vite
|
|
69
|
+
* wrote it.
|
|
70
|
+
*/
|
|
71
|
+
export function vmsBuildIdPlugin(options = {}) {
|
|
72
|
+
const extensions = options.extensions ?? [".js"];
|
|
73
|
+
const hashLength = options.hashLength ?? 12;
|
|
74
|
+
// Unique per plugin instance — an internal implementation detail, NOT an
|
|
75
|
+
// option, so two co-configured tools can't collide on the token text.
|
|
76
|
+
const placeholder = "__VMS_BUILD_" + randomBytes(4).toString("hex") + "__";
|
|
77
|
+
return {
|
|
78
|
+
name: "vms-build-id",
|
|
79
|
+
apply: "build",
|
|
80
|
+
config(userConfig) {
|
|
81
|
+
const existing = userConfig.build?.manifest;
|
|
82
|
+
if (existing !== undefined && existing !== "manifest.json") {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.warn(`[vms] build.manifest is set to ${JSON.stringify(existing)}, but the .NET ` +
|
|
85
|
+
`server reads "manifest.json" (wwwroot/manifest.json). The client and server ` +
|
|
86
|
+
`will hash different files and version-skew detection will not work. ` +
|
|
87
|
+
`Set build.manifest to "manifest.json" (or remove it and let vmsBuildIdPlugin set it).`);
|
|
88
|
+
}
|
|
89
|
+
// Return a partial (merge-friendly) config per Vite's `config` hook contract.
|
|
90
|
+
return {
|
|
91
|
+
define: {
|
|
92
|
+
"import.meta.env.VITE_VMS_BUILD": JSON.stringify(placeholder),
|
|
93
|
+
},
|
|
94
|
+
...(existing === undefined
|
|
95
|
+
? { build: { manifest: "manifest.json" } }
|
|
96
|
+
: {}),
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
writeBundle(outputOptions, bundle) {
|
|
100
|
+
const dir = outputOptions.dir ?? "";
|
|
101
|
+
const manifestBytes = readFileSync(join(dir, "manifest.json"));
|
|
102
|
+
const buildId = vmsHashManifestBytes(manifestBytes, hashLength);
|
|
103
|
+
for (const fileName of Object.keys(bundle)) {
|
|
104
|
+
if (!extensions.some((ext) => fileName.endsWith(ext)))
|
|
105
|
+
continue;
|
|
106
|
+
const p = join(dir, fileName);
|
|
107
|
+
const src = readFileSync(p, "utf-8");
|
|
108
|
+
// Skip-guard: only rewrite chunks that actually carry the placeholder,
|
|
109
|
+
// so non-shell chunks are never touched (no double-writes).
|
|
110
|
+
if (src.includes(placeholder)) {
|
|
111
|
+
writeFileSync(p, src.replaceAll(placeholder, buildId));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"jsdom": "^25.0.1",
|
|
48
48
|
"react": "^19.2.0",
|
|
49
49
|
"typescript": "^5.4.0",
|
|
50
|
+
"vite": ">=5",
|
|
50
51
|
"vitest": "^2.1.4"
|
|
51
52
|
},
|
|
52
53
|
"optionalDependencies": {
|
|
@@ -54,6 +55,14 @@
|
|
|
54
55
|
"@opentui/react": "^0.2.14",
|
|
55
56
|
"react": "^19.2.0"
|
|
56
57
|
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"vite": ">=5"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"vite": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
57
66
|
"exports": {
|
|
58
67
|
".": {
|
|
59
68
|
"types": "./dist/index.d.ts",
|
|
@@ -71,6 +80,10 @@
|
|
|
71
80
|
"types": "./dist/tui.d.ts",
|
|
72
81
|
"default": "./dist/tui.js"
|
|
73
82
|
},
|
|
83
|
+
"./vite": {
|
|
84
|
+
"types": "./dist/vite.d.ts",
|
|
85
|
+
"default": "./dist/vite.js"
|
|
86
|
+
},
|
|
74
87
|
"./styles.css": "./styles/default.css",
|
|
75
88
|
"./themes/dark-blue.css": "./styles/themes/dark-blue.css",
|
|
76
89
|
"./themes/dark-green.css": "./styles/themes/dark-green.css",
|