ivue 2.5.0 → 2.6.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.
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { shallowReactive, shallowReadonly } from 'vue';
3
+ import { nestedProps, type NestedPartial } from '../extras';
4
+
5
+ type Knobs = {
6
+ wheel: { gain: number; follow: number };
7
+ touch: { gain: number; inertia: number; list: number[] };
8
+ label: string;
9
+ };
10
+
11
+ const defaults = (): Knobs => ({
12
+ wheel: { gain: 1, follow: 0.1 },
13
+ touch: { gain: 1.3, inertia: 30, list: [1, 2, 3] },
14
+ label: 'default'
15
+ });
16
+
17
+ describe('nestedProps', () => {
18
+ it('preserves null-prototype defaults, literal __proto__ keys and sparse arrays', () => {
19
+ const sparse = new Array<{ offset: number }>(2);
20
+ sparse[1] = { offset: 2 };
21
+ const bare = Object.assign(Object.create(null), { gain: 1 });
22
+ const literal = { ['__proto__']: { gain: 2 } };
23
+ const sharedDefaults = { scroll: { sparse, bare, literal } };
24
+ const filled = nestedProps({ scroll: {} }, sharedDefaults) as typeof sharedDefaults;
25
+
26
+ expect(Object.getPrototypeOf(filled.scroll.bare)).toBeNull();
27
+ expect(filled.scroll.bare).not.toBe(bare);
28
+ expect(Object.getPrototypeOf(filled.scroll.literal)).toBe(Object.prototype);
29
+ expect(Object.hasOwn(filled.scroll.literal, '__proto__')).toBe(true);
30
+ expect(filled.scroll.literal.__proto__).not.toBe(literal.__proto__);
31
+ expect(filled.scroll.sparse.length).toBe(2);
32
+ expect(0 in filled.scroll.sparse).toBe(false);
33
+ filled.scroll.sparse[1].offset = 9;
34
+ expect(sparse[1].offset).toBe(2);
35
+ });
36
+
37
+ it('copies missing default objects and arrays independently for each instance', () => {
38
+ const sharedDefaults = { scroll: { touch: { gain: 1, points: [{ offset: 2 }] }, steps: [1, 2] } };
39
+ const first = nestedProps({ scroll: {} }, sharedDefaults) as typeof sharedDefaults;
40
+ const second = nestedProps({ scroll: {} }, sharedDefaults) as typeof sharedDefaults;
41
+
42
+ first.scroll.touch.gain = 9;
43
+ first.scroll.touch.points[0].offset = 8;
44
+ first.scroll.steps.push(3);
45
+
46
+ expect(second).toEqual({ scroll: { touch: { gain: 1, points: [{ offset: 2 }] }, steps: [1, 2] } });
47
+ expect(sharedDefaults).toEqual(second);
48
+ expect(second.scroll.touch).not.toBe(sharedDefaults.scroll.touch);
49
+ expect(second.scroll.steps).not.toBe(sharedDefaults.scroll.steps);
50
+ });
51
+
52
+ it('preserves supplied containers and opaque default values when copying missing branches', () => {
53
+ class Tuning { gain = 9; }
54
+ const tuning = new Tuning();
55
+ const callback = () => 1;
56
+ const suppliedList = [7];
57
+ const supplied = { scroll: { list: suppliedList } };
58
+ const sharedDefaults = { scroll: { list: [1], touch: { tuning, callback, label: null } } };
59
+ const filled = nestedProps(supplied, sharedDefaults) as typeof sharedDefaults;
60
+
61
+ expect(filled).toBe(supplied);
62
+ expect(filled.scroll.list).toBe(suppliedList);
63
+ expect(filled.scroll.touch).not.toBe(sharedDefaults.scroll.touch);
64
+ expect(filled.scroll.touch.tuning).toBe(tuning);
65
+ expect(filled.scroll.touch.callback).toBe(callback);
66
+ expect(filled.scroll.touch.label).toBeNull();
67
+ });
68
+
69
+ it('fills a partial object from the defaults at every depth, in place, keeping every supplied leaf', () => {
70
+ const supplied: NestedPartial<Knobs> = { wheel: { gain: 2 }, label: 'mine' };
71
+ const props = nestedProps(supplied, defaults());
72
+ expect(props).toBe(supplied);
73
+ expect(props.wheel).toEqual({ gain: 2, follow: 0.1 });
74
+ expect(props.label).toBe('mine');
75
+ // A top-level prop is Vue's to default: never written here.
76
+ expect('touch' in props).toBe(false);
77
+ });
78
+
79
+ it('recurses through every plain-object level, filling only what is missing', () => {
80
+ const tree = { a: { b: { c: 9 }, e: 5 } };
81
+ const filled = nestedProps({ tree }, { tree: { a: { b: { c: 1, d: 2 }, e: 3, f: 4 } } });
82
+ expect(filled.tree).toBe(tree);
83
+ expect(filled.tree).toEqual({ a: { b: { c: 9, d: 2 }, e: 5, f: 4 } });
84
+ });
85
+
86
+ it('skips a top-level prop that is not a plain object on both sides, and fills a prototype-less default', () => {
87
+ const bareDefault = Object.create(null) as { z: number; y: number };
88
+ bareDefault.z = 1;
89
+ bareDefault.y = 2;
90
+ const props = { list: null as unknown as number[], label: 'mine', bare: { z: 5 }, fn: () => 1 };
91
+ const filled = nestedProps(props, { list: [1, 2], label: 'default', bare: bareDefault, fn: { a: 1 } });
92
+ expect(filled.list).toBeNull();
93
+ expect(filled.label).toBe('mine');
94
+ expect(filled.bare).toEqual({ z: 5, y: 2 });
95
+ expect(typeof filled.fn).toBe('function');
96
+ });
97
+
98
+ it('treats a nested undefined as absent and a nested null as a value', () => {
99
+ const props = nestedProps(
100
+ { wheel: { gain: undefined, follow: null } } as unknown as NestedPartial<Knobs>,
101
+ defaults()
102
+ );
103
+ expect(props.wheel.gain).toBe(1);
104
+ expect(props.wheel.follow).toBeNull();
105
+ });
106
+
107
+ it('takes arrays, class instances and functions whole, never merged', () => {
108
+ class Tuning {
109
+ gain = 9;
110
+ }
111
+ const tuning = new Tuning();
112
+ const props = nestedProps(
113
+ { touch: { list: [7] }, wheel: tuning } as unknown as NestedPartial<Knobs>,
114
+ defaults()
115
+ );
116
+ expect(props.touch.list).toEqual([7]);
117
+ expect(props.touch.inertia).toBe(30);
118
+ expect(props.wheel).toBe(tuning);
119
+ expect((props.wheel as unknown as Tuning).gain).toBe(9);
120
+ // An object supplied where the default is an array is kept as is.
121
+ const odd = nestedProps({ touch: { list: { 0: 7 } } } as unknown as NestedPartial<Knobs>, defaults());
122
+ expect(odd.touch.list).toEqual({ 0: 7 });
123
+ // A prototype-less object is plain too.
124
+ const bare = Object.create(null) as { gain: number };
125
+ bare.gain = 4;
126
+ const filled = nestedProps({ wheel: bare } as unknown as NestedPartial<Knobs>, defaults());
127
+ expect(filled.wheel).toBe(bare);
128
+ expect(filled.wheel.follow).toBe(0.1);
129
+ });
130
+
131
+ it('works through Vue’s shallow props proxy: the nested object is the parent’s and is filled directly', () => {
132
+ const wheel = { gain: 2 };
133
+ const props = shallowReadonly(shallowReactive({ wheel } as NestedPartial<Knobs>));
134
+ const filled = nestedProps(props, defaults());
135
+ expect(filled.wheel.follow).toBe(0.1);
136
+ expect(wheel).toEqual({ gain: 2, follow: 0.1 });
137
+ });
138
+
139
+ it('customCloner is the copy policy for default branches: identity shares them, structuredClone copies built-ins', () => {
140
+ const wheelDefault = { gain: 1, follow: 0.1 };
141
+ const first = nestedProps({} as NestedPartial<Knobs>, { wheel: wheelDefault } as Knobs);
142
+ expect(first.wheel).not.toBe(wheelDefault);
143
+ // The caller owns a fresh tree per instance and says so: no copy.
144
+ const owned = { list: [7] };
145
+ const identity = nestedProps({ touch: {} } as NestedPartial<Knobs>, { touch: owned } as unknown as Knobs, (value) => value);
146
+ expect(identity.touch.list).toBe(owned.list);
147
+ // structuredClone gives Date defaults their own copies.
148
+ const stamp = new Date(0);
149
+ const copied = nestedProps({ meta: {} } as { meta: { stamp?: Date } }, { meta: { stamp } }, structuredClone);
150
+ expect(copied.meta.stamp).not.toBe(stamp);
151
+ expect(copied.meta.stamp?.getTime()).toBe(0);
152
+ });
153
+ });
package/lib/clone.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** Copy plain configuration containers; retain callbacks, constructors and
2
+ * opaque objects by reference. Container trees must be acyclic. */
3
+ export function clone<T>(value: T): T {
4
+ if (Array.isArray(value)) {
5
+ const copy = new Array(value.length);
6
+ for (let index = 0; index < value.length; index++) {
7
+ if (index in value) copy[index] = clone(value[index]);
8
+ }
9
+ return copy as T;
10
+ }
11
+ if (
12
+ value === null ||
13
+ typeof value !== 'object' ||
14
+ (value.constructor !== Object && value.constructor !== undefined)
15
+ ) return value;
16
+
17
+ // Preserve null prototypes and literal __proto__ keys without entry arrays.
18
+ const copy: Record<string, unknown> = { __proto__: Object.getPrototypeOf(value), ...value };
19
+ for (const key in copy) copy[key] = clone(copy[key]);
20
+ return copy as T;
21
+ }
package/lib/extras.ts CHANGED
@@ -8,3 +8,4 @@
8
8
  */
9
9
  export { Static, type ClassConstructor } from './Static';
10
10
  export { LazyShared } from './LazyShared';
11
+ export { nestedProps, type NestedPartial, type NestedProps } from './nestedProps';
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `nestedProps(props, defaults)` — fill a nested object prop from its
3
+ * defaults, in place, so the class reads complete props at every depth.
4
+ *
5
+ * Vue resolves a prop's default only when the prop is ABSENT: pass
6
+ * `{ wheel: { gain: 2 } }` for a prop whose default is
7
+ * `{ wheel: { gain: 1, follow: 0.1 }, touch: { … } }` and the component
8
+ * receives exactly the object it was passed — `follow` and `touch` are
9
+ * gone. `propsWithDefaults` decides what the default IS and clones it per
10
+ * instance; it cannot reach inside a supplied object. The fill is possible
11
+ * at all because the defaults are a value the class owns — a compiler-only
12
+ * default has nothing to fill from.
13
+ *
14
+ * The semantics are lodash's `defaultsDeep` with arrays taken whole: for
15
+ * every prop whose value and default are both plain objects, each leaf the
16
+ * supplied object lacks is written into it from the default, recursively;
17
+ * a leaf it has is kept. Missing plain-object and array defaults are copied
18
+ * recursively so instances never share those mutable containers. Arrays
19
+ * are never merged; class instances and functions retain their identity. Vue's
20
+ * props proxy is shallow, so the nested objects are the parent's own and
21
+ * are written directly; the props object itself is untouched and returned.
22
+ *
23
+ * Call it once, at the seam where props enter the class:
24
+ *
25
+ * constructor(props: Scroller.Props, public emit: Scroller.Emits) {
26
+ * this.props = nestedProps(props, this.self.propsDefaults);
27
+ * }
28
+ *
29
+ * The parent passes a stable object: a constant inline literal (Vue hoists
30
+ * it), a `ref`'s value, a store field. An object built anew on every
31
+ * parent render is a new, unfilled object each time.
32
+ *
33
+ * `customCloner` is the same policy knob `propsWithDefaults` has: it copies
34
+ * each default branch written into the props. The default, `clone`, copies
35
+ * plain containers and keeps callbacks, constructors and opaque objects by
36
+ * reference, so no two instances share a mutable default. Pass
37
+ * `structuredClone` when `Date`, `Map` or `Set` defaults need their own
38
+ * copies; pass the identity, `value => value`, only when the caller owns
39
+ * every default it passes — a fresh tree per instance, never a shared one.
40
+ *
41
+ * `NestedPartial<T>` is the matching declaration for the prop's type — the
42
+ * shape an author may pass — and `NestedProps<P, D>` the type of the
43
+ * filled props, where every key both sides carry as an object is complete.
44
+ *
45
+ * Ships from `ivue/extras` (not the reactive core) so the primary `ivue`
46
+ * entry stays minimal.
47
+ */
48
+
49
+ import { clone } from './clone';
50
+
51
+ /** Every key optional at every plain-object depth; arrays stay whole. */
52
+ export type NestedPartial<T> = T extends readonly unknown[]
53
+ ? T
54
+ : T extends object
55
+ ? { [K in keyof T]?: NestedPartial<T[K]> }
56
+ : T;
57
+
58
+ /** The filled props' type: a key both sides carry as a plain object is complete. */
59
+ export type NestedProps<P, D> = {
60
+ [K in keyof P as K extends keyof D ? K : never]-?: NestedLeaf<
61
+ NonNullable<P[K]>,
62
+ D[K & keyof D]
63
+ >;
64
+ } & {
65
+ [K in keyof P as K extends keyof D ? never : K]: P[K];
66
+ };
67
+
68
+ /** A value the fill takes whole: not a plain container. */
69
+ type Opaque = Date | RegExp | Map<unknown, unknown> | Set<unknown> | ((...args: never[]) => unknown);
70
+
71
+ type NestedLeaf<V, D> = V extends readonly unknown[]
72
+ ? V
73
+ : V extends Opaque
74
+ ? V
75
+ : V extends object
76
+ ? D extends readonly unknown[]
77
+ ? V
78
+ : D extends object
79
+ ? NestedProps<V, D>
80
+ : V
81
+ : V;
82
+
83
+ type PlainObject = Record<PropertyKey, unknown>;
84
+
85
+ /** A plain object — an object literal, `Object.create(null)`, or a reactive
86
+ * proxy over either; not an array, a class instance or a function. A
87
+ * `constructor` check, no prototype walk. */
88
+ function isPlain(value: unknown): value is PlainObject {
89
+ return (
90
+ value !== null &&
91
+ typeof value === 'object' &&
92
+ ((value as PlainObject).constructor === Object ||
93
+ (value as PlainObject).constructor === undefined)
94
+ );
95
+ }
96
+
97
+ /** Write every leaf `target` lacks from `defaults`, recursively; keep what
98
+ * it has. `for…in` over the defaults: plain objects have no enumerable
99
+ * prototype keys. */
100
+ function fill(target: PlainObject, defaults: PlainObject, copy: (value: unknown) => unknown) {
101
+ for (const key in defaults) {
102
+ const value = target[key];
103
+ const fallback = defaults[key];
104
+ if (value === undefined) target[key] = copy(fallback);
105
+ else if (isPlain(value) && isPlain(fallback)) fill(value, fallback, copy);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Fill every nested object prop from its default, in place, and return
111
+ * the props typed as complete. Top-level props are Vue's to default and
112
+ * are never written.
113
+ */
114
+ export function nestedProps<P extends object, D extends object>(
115
+ props: P,
116
+ defaults: D,
117
+ // Optional: the copy policy for default branches written into the props.
118
+ customCloner: (value: unknown) => unknown = clone
119
+ ): NestedProps<P, D> {
120
+ for (const key in defaults) {
121
+ const value = (props as PlainObject)[key];
122
+ const fallback = (defaults as PlainObject)[key];
123
+ if (isPlain(value) && isPlain(fallback)) fill(value, fallback, customCloner);
124
+ }
125
+ return props as unknown as NestedProps<P, D>;
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ivue",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Infinite Vue – Class Based Architecture for Vue 3",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,6 +22,8 @@
22
22
  "types": "./dist/index.d.ts",
23
23
  "scripts": {
24
24
  "gate": "vite-node skills/ivue/ivue-standards-check.ts --",
25
+ "gate:docs": "node scripts/gate-docs.mjs",
26
+ "sweep:components": "NODE_PATH=$PWD/node_modules node docs_v2/scripts/component-sweep.cjs",
25
27
  "gate:house": "vite-node skills/ivue/ivue-house-gate.ts --",
26
28
  "gate:newsletter": "vite-node skills/ivue/ivue-generator-standard.ts -- --source-root newsletter --skip-list skills/ivue/ivue-standards-skip.json --test-glob 'newsletter/src/**/*.test.ts'",
27
29
  "dev": "vite demo --host",
@@ -41,9 +43,9 @@
41
43
  "bench:node-namespace": "vite-node experiments/node-namespace/benchmark.ts",
42
44
  "cypress": "cypress run --component",
43
45
  "cypress:headed": "cypress run --component --browser chrome --headed --no-exit",
44
- "build": "tsc --version;tsc --p ./tsconfig.json && vite build",
46
+ "build": "tsc --version;tsc --p ./tsconfig.json && node scripts/build-lib.mjs",
45
47
  "build:demo": "tsc --p ./tsconfig.json && vite build demo",
46
- "build:docs": "npm run sync:examples && npm run sync:releases && npm run sync:blog-index && node docs_v2/scripts/rss-generator.mjs && npm --prefix docs_v2 run build && npm run check:links && node docs_v2/scripts/check-related-posts.mjs",
48
+ "build:docs": "npm run gate:docs && npm run sync:examples && npm run sync:releases && npm run sync:blog-index && node docs_v2/scripts/rss-generator.mjs && npm --prefix docs_v2 run build && npm run check:links && node docs_v2/scripts/check-related-posts.mjs && node docs_v2/scripts/check-private-ban.mjs",
47
49
  "sync:releases": "node docs_v2/scripts/releases-page-generator.mjs",
48
50
  "sync:blog-index": "node docs_v2/scripts/blog-index-generator.mjs",
49
51
  "check:links": "node docs_v2/scripts/check-links.mjs",
@@ -60,7 +62,7 @@
60
62
  "release": "npm run build && npm publish",
61
63
  "sync:skill": "node -e \"const fs=require('fs');fs.mkdirSync('skills/ivue',{recursive:true});fs.copyFileSync('.claude/skills/ivue/SKILL.md','skills/ivue/SKILL.md')\"",
62
64
  "prepack": "npm run sync:skill",
63
- "sync:examples": "node -e \"require('fs').copyFileSync('lib/Reactive.ts','examples/playground/src/ivue.ts')\"",
65
+ "sync:examples": "node scripts/sync-examples.mjs",
64
66
  "dev:playground": "vite examples/playground --host",
65
67
  "build:playground": "vite build examples/playground",
66
68
  "preview:playground": "vite preview examples/playground --host",
@@ -106,7 +108,9 @@
106
108
  "rollup-plugin-terser": "^7.0.2",
107
109
  "typescript": "^5.9.3",
108
110
  "vite": "^4.0.4",
111
+ "vite-node": "^2.0.5",
109
112
  "vite-plugin-dts": "^1.7.1",
113
+ "@microsoft/api-extractor": "7.33.8",
110
114
  "vite-plugin-dynamic-import": "^1.5.0",
111
115
  "vite-tsconfig-paths": "^4.3.2",
112
116
  "vitest": "^2.0.5",