@foldkit/vite-plugin 0.10.0 → 0.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @foldkit/vite-plugin
2
2
 
3
- Vite plugin for Foldkit that enables hot module reloading with model preservation.
3
+ Vite plugin for Foldkit: view identity branding for the differ, plus hot module reloading with model preservation.
4
4
 
5
5
  ## Installation
6
6
 
@@ -26,7 +26,15 @@ export default defineConfig({
26
26
  })
27
27
  ```
28
28
 
29
- ## What it does
29
+ ## View identity
30
+
31
+ Foldkit's differ tracks two independent kinds of identity: user keys, which match siblings in dynamic lists, and a framework-managed identity, which decides whether a matched position is still the same thing. When the producing view function changes, the differ replaces the node instead of patching it, so DOM state cannot bleed across an identity change. Branches rendered inline by one view function share that function's identity and patch in place, exactly as same-type elements do in React; extracting the branches into named view functions makes them identity boundaries.
32
+
33
+ This plugin supplies that identity. At build time, in dev and production alike, it wraps every function return in your application modules with a branding call that stamps returned vnodes with the function's id (module path plus function name), set-if-absent. Identity therefore attaches at view-function boundaries, and any branching syntax behaves the same: if/else, ternaries, Effect Match, switch statements, and pattern-matching libraries are all equivalent, because identity belongs to the function that produced the subtree, not to the branch that selected it.
34
+
35
+ Foldkit core modules are never instrumented, and functions that never return vnodes are wrapped inertly. Builds without this plugin fall back to positional matching plus keys, where branch points need hand-written keys.
36
+
37
+ ## Hot module reloading
30
38
 
31
39
  When you save a file during development, the plugin:
32
40
 
@@ -0,0 +1,19 @@
1
+ /** Result of {@link brandDistDirectory}: per-file branding counts. */
2
+ export type BrandDistResult = Readonly<{
3
+ brandedCount: number;
4
+ skippedCount: number;
5
+ }>;
6
+ /**
7
+ * Brands every compiled `.js` file under `<packageRoot>/dist` in place by
8
+ * applying {@link transformViewIdentity} to each one, so packages such as
9
+ * `@foldkit/ui` and `@foldkit/devtools` ship dist output whose view functions
10
+ * carry identities without requiring consumers to run the Vite plugin over
11
+ * `node_modules`.
12
+ *
13
+ * Module ids follow the `/virtual/<packageName>/<relativePath>` scheme
14
+ * against a `/virtual` synthetic root, so identities are stable across
15
+ * machines and never leak build-host paths. Files the transform returns
16
+ * `null` for (no functions, or already branded) are counted as skipped.
17
+ */
18
+ export declare const brandDistDirectory: (packageRoot: string, packageName: string) => BrandDistResult;
19
+ //# sourceMappingURL=brandDist.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brandDist.d.ts","sourceRoot":"","sources":["../src/brandDist.ts"],"names":[],"mappings":"AAQA,sEAAsE;AACtE,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC,YAAY,EAAE,MAAM,CAAA;IACpB,YAAY,EAAE,MAAM,CAAA;CACrB,CAAC,CAAA;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,GAC7B,aAAa,MAAM,EACnB,aAAa,MAAM,KAClB,eAyBF,CAAA"}
@@ -0,0 +1,40 @@
1
+ import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { join, sep } from 'node:path';
3
+ import { transformViewIdentity } from './viewIdentity.js';
4
+ const SYNTHETIC_ROOT = '/virtual';
5
+ const SCRIPT_FILE_EXTENSION = '.js';
6
+ /**
7
+ * Brands every compiled `.js` file under `<packageRoot>/dist` in place by
8
+ * applying {@link transformViewIdentity} to each one, so packages such as
9
+ * `@foldkit/ui` and `@foldkit/devtools` ship dist output whose view functions
10
+ * carry identities without requiring consumers to run the Vite plugin over
11
+ * `node_modules`.
12
+ *
13
+ * Module ids follow the `/virtual/<packageName>/<relativePath>` scheme
14
+ * against a `/virtual` synthetic root, so identities are stable across
15
+ * machines and never leak build-host paths. Files the transform returns
16
+ * `null` for (no functions, or already branded) are counted as skipped.
17
+ */
18
+ export const brandDistDirectory = (packageRoot, packageName) => {
19
+ const distDirectory = join(packageRoot, 'dist');
20
+ const relativeScriptPaths = readdirSync(distDirectory, {
21
+ recursive: true,
22
+ encoding: 'utf8',
23
+ })
24
+ .filter(relativePath => relativePath.endsWith(SCRIPT_FILE_EXTENSION))
25
+ .sort();
26
+ const brandFlags = relativeScriptPaths.map(relativePath => {
27
+ const absolutePath = join(distDirectory, relativePath);
28
+ const posixRelativePath = relativePath.split(sep).join('/');
29
+ const syntheticId = `${SYNTHETIC_ROOT}/${packageName}/${posixRelativePath}`;
30
+ const code = readFileSync(absolutePath, 'utf8');
31
+ const result = transformViewIdentity(code, syntheticId, SYNTHETIC_ROOT);
32
+ if (result === null) {
33
+ return false;
34
+ }
35
+ writeFileSync(absolutePath, result.code);
36
+ return true;
37
+ });
38
+ const brandedCount = brandFlags.filter(isBranded => isBranded).length;
39
+ return { brandedCount, skippedCount: brandFlags.length - brandedCount };
40
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Plugin } from 'vite';
2
+ export { type BrandDistResult, brandDistDirectory } from './brandDist.js';
3
+ export { type ViewIdentityTransformResult, foldkitViewIdentity, transformViewIdentity, } from './viewIdentity.js';
2
4
  /** Options for the `foldkit` Vite plugin. */
3
5
  export type FoldkitPluginOptions = Readonly<{
4
6
  /**
@@ -9,5 +11,11 @@ export type FoldkitPluginOptions = Readonly<{
9
11
  */
10
12
  devToolsMcpPort?: number;
11
13
  }>;
12
- export declare const foldkit: (options?: FoldkitPluginOptions) => Plugin;
14
+ /**
15
+ * Foldkit's Vite plugin set: the view-identity branding transform (dev and
16
+ * build) plus the HMR bridge with state preservation and the optional
17
+ * DevTools MCP relay (dev only). Returned as an array; Vite flattens nested
18
+ * plugin arrays, so `plugins: [foldkit()]` keeps working.
19
+ */
20
+ export declare const foldkit: (options?: FoldkitPluginOptions) => Array<Plugin>;
13
21
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiCA,OAAO,KAAK,EAAE,MAAM,EAAkC,MAAM,MAAM,CAAA;AAGlE,6CAA6C;AAC7C,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,CAAC,CAAA;AAgiBF,eAAO,MAAM,OAAO,GAAI,UAAS,oBAAyB,KAAG,MAqC5D,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiCA,OAAO,KAAK,EAAE,MAAM,EAAkC,MAAM,MAAM,CAAA;AAKlE,OAAO,EAAE,KAAK,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACzE,OAAO,EACL,KAAK,2BAA2B,EAChC,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,mBAAmB,CAAA;AAE1B,6CAA6C;AAC7C,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,CAAC,CAAA;AAgiBF;;;;;GAKG;AACH,eAAO,MAAM,OAAO,GAAI,UAAS,oBAAyB,KAAG,KAAK,CAAC,MAAM,CAuCxE,CAAA"}
package/dist/index.js CHANGED
@@ -4,6 +4,9 @@ import { PreserveModelMessage, RequestModelMessage, RestoreModelMessage, } from
4
4
  import { createRequire } from 'node:module';
5
5
  import { resolve } from 'node:path';
6
6
  import { WebSocketServer } from 'ws';
7
+ import { foldkitViewIdentity } from './viewIdentity.js';
8
+ export { brandDistDirectory } from './brandDist.js';
9
+ export { foldkitViewIdentity, transformViewIdentity, } from './viewIdentity.js';
7
10
  // NOTE: Vite's dep optimizer scans the consumer's source for `effect`
8
11
  // imports and pre-bundles only those exports into a single `effect.js`
9
12
  // blob. It does not follow imports through workspace/node_modules
@@ -279,9 +282,15 @@ const main = (server, events, options) => Effect.gen(function* () {
279
282
  yield* Stream.fromQueue(events).pipe(Stream.runForEach(event => dispatchEvent(server, state, event)));
280
283
  });
281
284
  // PLUGIN ENTRY
285
+ /**
286
+ * Foldkit's Vite plugin set: the view-identity branding transform (dev and
287
+ * build) plus the HMR bridge with state preservation and the optional
288
+ * DevTools MCP relay (dev only). Returned as an array; Vite flattens nested
289
+ * plugin arrays, so `plugins: [foldkit()]` keeps working.
290
+ */
282
291
  export const foldkit = (options = {}) => {
283
292
  const events = Effect.runSync(Queue.unbounded());
284
- return {
293
+ const hmrPlugin = {
285
294
  name: 'foldkit-hmr',
286
295
  apply: 'serve',
287
296
  config: userConfig => ({
@@ -307,4 +316,5 @@ export const foldkit = (options = {}) => {
307
316
  return [];
308
317
  },
309
318
  };
319
+ return [foldkitViewIdentity(), hmrPlugin];
310
320
  };
@@ -0,0 +1,63 @@
1
+ import { type SourceMap } from 'magic-string';
2
+ import { type Plugin } from 'vite';
3
+ /** Result of {@link transformViewIdentity}: rewritten code plus a source map. */
4
+ export type ViewIdentityTransformResult = Readonly<{
5
+ code: string;
6
+ map: SourceMap;
7
+ }>;
8
+ /**
9
+ * Brands the return value of every function in a module with that function's
10
+ * identity via `brandViewResult` from `foldkit/brand`.
11
+ *
12
+ * Identities are `${posixRelativePathFromRoot}#${functionName}`, with the
13
+ * function name taken from, in order: the function's own id (declarations and
14
+ * named function expressions), the variable a function expression is bound
15
+ * to, the non-computed key of an
16
+ * object property, class method, or class field, `default` for an
17
+ * export-default function, the member name of an `x.y = fn` assignment, and
18
+ * `anonymous` otherwise. Duplicate names within a module are disambiguated in
19
+ * source order with `~2`, `~3`, and so on, so ids are fully deterministic.
20
+ *
21
+ * Expression-body arrows have their body wrapped in the branding call; block
22
+ * bodies have every `return` argument belonging directly to the function
23
+ * wrapped (nested functions instrument their own returns, and bare `return`
24
+ * is untouched). Branding is set-if-absent on vnodes at runtime, so wrapping
25
+ * every function, including async and generator functions and functions that
26
+ * never produce a vnode, is inert outside view results.
27
+ *
28
+ * Skips foldkit core modules: if the element factory's own returns were
29
+ * branded, every vnode would carry an identity at construction and
30
+ * set-if-absent would neutralize the feature. Core under `node_modules` is
31
+ * always skipped. The plugin resolves the installed foldkit package and passes
32
+ * `isFoldkitCoreResolved`; when it resolved, the plugin's precise package-root
33
+ * gate is authoritative and the coarse `packages/foldkit/` path fragment is
34
+ * left to the resolution-failed fallback, so a consumer whose own path merely
35
+ * contains that segment is still branded.
36
+ *
37
+ * Skips modules whose parsed program imports or re-exports the
38
+ * `foldkit/brand` specifier: packages such as `@foldkit/ui` and
39
+ * `@foldkit/devtools` brand their dist at package build time, and pnpm
40
+ * symlink realpaths can surface that dist outside `node_modules`, where the
41
+ * module-id checks alone would let a build wrap the same returns twice. A
42
+ * module that merely mentions the specifier in a comment or string is still
43
+ * branded; the raw-text check is only a fast negative prefilter.
44
+ *
45
+ * Returns `null` when the module needs no changes.
46
+ */
47
+ export declare const transformViewIdentity: (code: string, id: string, root: string, options?: Readonly<{
48
+ isFoldkitCoreResolved?: boolean;
49
+ }>) => ViewIdentityTransformResult | null;
50
+ /**
51
+ * Vite plugin that applies {@link transformViewIdentity} to application
52
+ * modules in both dev and build. Skips `node_modules`, virtual modules,
53
+ * non-script files, and foldkit core itself, resolved from the config root so
54
+ * that configs aliasing `foldkit` straight into `packages/foldkit/src` are
55
+ * excluded too. `@foldkit/ui` and `@foldkit/devtools` modules are branded on
56
+ * purpose.
57
+ *
58
+ * Also pins `foldkit/brand` to the installed package's brand module via a
59
+ * `resolve.alias` entry, so the injected import keeps resolving in configs
60
+ * whose own `foldkit` alias would otherwise swallow the subpath.
61
+ */
62
+ export declare const foldkitViewIdentity: () => Plugin;
63
+ //# sourceMappingURL=viewIdentity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewIdentity.d.ts","sourceRoot":"","sources":["../src/viewIdentity.ts"],"names":[],"mappings":"AAAA,OAAoB,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAI1D,OAAO,EAAE,KAAK,MAAM,EAAY,MAAM,MAAM,CAAA;AAwZ5C,iFAAiF;AACjF,MAAM,MAAM,2BAA2B,GAAG,QAAQ,CAAC;IACjD,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,SAAS,CAAA;CACf,CAAC,CAAA;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,eAAO,MAAM,qBAAqB,GAChC,MAAM,MAAM,EACZ,IAAI,MAAM,EACV,MAAM,MAAM,EACZ,UAAU,QAAQ,CAAC;IAAE,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,KACtD,2BAA2B,GAAG,IAyChC,CAAA;AA8DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB,QAAO,MA0CtC,CAAA"}
@@ -0,0 +1,443 @@
1
+ import MagicString from 'magic-string';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { join, relative, resolve, sep } from 'node:path';
5
+ import { parseAst } from 'vite';
6
+ const BRAND_MODULE_SPECIFIER = 'foldkit/brand';
7
+ const BRAND_EXPORT_NAME = 'brandViewResult';
8
+ const BRAND_IMPORT_ALIAS = '__foldkitBrandViewResult';
9
+ const FIRST_ALIAS_SUFFIX = 2;
10
+ const FIRST_DUPLICATE_NAME_SUFFIX = 2;
11
+ const DEFAULT_EXPORT_FUNCTION_NAME = 'default';
12
+ const ANONYMOUS_FUNCTION_NAME = 'anonymous';
13
+ const FOLDKIT_PACKAGE_NAME = 'foldkit';
14
+ const FOLDKIT_CORE_PATH_FRAGMENT = 'packages/foldkit/';
15
+ const VIRTUAL_MODULE_PREFIX = '\0';
16
+ const SCRIPT_FILE_PATTERN = /\.(?:ts|tsx|js|jsx|mts|mjs)$/;
17
+ const isAstNode = (value) => typeof value === 'object' &&
18
+ value !== null &&
19
+ 'type' in value &&
20
+ typeof value.type === 'string';
21
+ const isIdentifier = (node) => node.type === 'Identifier';
22
+ const isPrivateIdentifier = (node) => node.type === 'PrivateIdentifier';
23
+ const isLiteral = (node) => node.type === 'Literal';
24
+ const isMemberExpression = (node) => node.type === 'MemberExpression';
25
+ const isAssignmentExpression = (node) => node.type === 'AssignmentExpression';
26
+ const FUNCTION_NODE_TYPES = new Set([
27
+ 'ArrowFunctionExpression',
28
+ 'FunctionExpression',
29
+ 'FunctionDeclaration',
30
+ ]);
31
+ const isFunctionNode = (node) => FUNCTION_NODE_TYPES.has(node.type);
32
+ const isReturnStatement = (node) => node.type === 'ReturnStatement';
33
+ const isVariableDeclarator = (node) => node.type === 'VariableDeclarator';
34
+ const NAMED_VALUE_NODE_TYPES = new Set([
35
+ 'Property',
36
+ 'MethodDefinition',
37
+ 'PropertyDefinition',
38
+ ]);
39
+ const isNamedValueNode = (node) => NAMED_VALUE_NODE_TYPES.has(node.type) &&
40
+ isAstNode(node['key']) &&
41
+ isAstNode(node['value']);
42
+ const walkChildNodes = (node, visitChild) => {
43
+ for (const [fieldName, fieldValue] of Object.entries(node)) {
44
+ if (fieldName === 'parent') {
45
+ continue;
46
+ }
47
+ if (isAstNode(fieldValue)) {
48
+ visitChild(fieldValue);
49
+ }
50
+ else if (Array.isArray(fieldValue)) {
51
+ for (const element of fieldValue) {
52
+ if (isAstNode(element)) {
53
+ visitChild(element);
54
+ }
55
+ }
56
+ }
57
+ }
58
+ };
59
+ const parseProgram = (code) => {
60
+ try {
61
+ const program = parseAst(code);
62
+ if (isAstNode(program)) {
63
+ return program;
64
+ }
65
+ return null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ };
71
+ // ELIGIBILITY
72
+ const stripQuery = (id) => {
73
+ const [pathWithoutQuery] = id.split('?');
74
+ return pathWithoutQuery ?? id;
75
+ };
76
+ const toPosixPath = (filePath) => filePath.split(sep).join('/');
77
+ const NODE_MODULES_SEGMENT_PATTERN = /(?:^|\/)node_modules(?:\/|$)/;
78
+ const BRAND_MODULE_STATEMENT_TYPES = new Set([
79
+ 'ImportDeclaration',
80
+ 'ExportNamedDeclaration',
81
+ 'ExportAllDeclaration',
82
+ ]);
83
+ const isBrandModuleStatement = (statement) => {
84
+ if (!BRAND_MODULE_STATEMENT_TYPES.has(statement.type)) {
85
+ return false;
86
+ }
87
+ const source = statement['source'];
88
+ return (isAstNode(source) &&
89
+ isLiteral(source) &&
90
+ source.value === BRAND_MODULE_SPECIFIER);
91
+ };
92
+ const isAlreadyBranded = (program) => {
93
+ const body = program['body'];
94
+ if (!Array.isArray(body)) {
95
+ return false;
96
+ }
97
+ return body.some(statement => isAstNode(statement) && isBrandModuleStatement(statement));
98
+ };
99
+ const isEligibleModuleId = (id, isFoldkitCoreResolved) => {
100
+ if (id.startsWith(VIRTUAL_MODULE_PREFIX)) {
101
+ return false;
102
+ }
103
+ const strippedId = stripQuery(id);
104
+ const normalizedId = toPosixPath(strippedId);
105
+ if (NODE_MODULES_SEGMENT_PATTERN.test(normalizedId)) {
106
+ return false;
107
+ }
108
+ if (!SCRIPT_FILE_PATTERN.test(strippedId)) {
109
+ return false;
110
+ }
111
+ // NOTE: the unanchored `packages/foldkit/` fragment is a best-effort
112
+ // fallback, applied only when the installed foldkit package could not be
113
+ // resolved. When it was resolved, the plugin's precise `foldkitPackageRoot`
114
+ // gate already excluded core, so re-applying the fragment here would wrongly
115
+ // un-brand a consumer whose own app path merely contains the segment (a
116
+ // workspace named `foldkit`, a vendored fork holding app code).
117
+ if (isFoldkitCoreResolved) {
118
+ return true;
119
+ }
120
+ return !normalizedId.includes(FOLDKIT_CORE_PATH_FRAGMENT);
121
+ };
122
+ const collectFunctionNodes = (program) => {
123
+ const functionNodes = [];
124
+ const parentByNode = new Map();
125
+ const visitNode = (node) => {
126
+ if (isFunctionNode(node)) {
127
+ functionNodes.push(node);
128
+ }
129
+ walkChildNodes(node, child => {
130
+ parentByNode.set(child, node);
131
+ visitNode(child);
132
+ });
133
+ };
134
+ visitNode(program);
135
+ const orderedFunctionNodes = [...functionNodes].sort((first, second) => first.start - second.start);
136
+ return { functionNodes: orderedFunctionNodes, parentByNode };
137
+ };
138
+ const namedKeyText = (key) => {
139
+ if (isIdentifier(key) || isPrivateIdentifier(key)) {
140
+ return key.name;
141
+ }
142
+ if (isLiteral(key) && typeof key.value === 'string') {
143
+ return key.value;
144
+ }
145
+ return undefined;
146
+ };
147
+ const rawFunctionName = (functionNode, parentByNode) => {
148
+ if (functionNode.id !== null &&
149
+ functionNode.id !== undefined &&
150
+ isIdentifier(functionNode.id)) {
151
+ return functionNode.id.name;
152
+ }
153
+ const parent = parentByNode.get(functionNode);
154
+ if (parent === undefined) {
155
+ return ANONYMOUS_FUNCTION_NAME;
156
+ }
157
+ if (isVariableDeclarator(parent) &&
158
+ parent.init === functionNode &&
159
+ isIdentifier(parent.id)) {
160
+ return parent.id.name;
161
+ }
162
+ if (isNamedValueNode(parent) &&
163
+ parent.value === functionNode &&
164
+ !parent.computed) {
165
+ return namedKeyText(parent.key) ?? ANONYMOUS_FUNCTION_NAME;
166
+ }
167
+ if (parent.type === 'ExportDefaultDeclaration') {
168
+ return DEFAULT_EXPORT_FUNCTION_NAME;
169
+ }
170
+ if (isAssignmentExpression(parent) &&
171
+ parent.right === functionNode &&
172
+ isMemberExpression(parent.left) &&
173
+ !parent.left.computed &&
174
+ isIdentifier(parent.left.property)) {
175
+ return parent.left.property.name;
176
+ }
177
+ return ANONYMOUS_FUNCTION_NAME;
178
+ };
179
+ const assignFunctionIds = (functionNodes, parentByNode, modulePath) => {
180
+ const functionIds = new Map();
181
+ const occurrenceCountsByName = new Map();
182
+ for (const functionNode of functionNodes) {
183
+ const functionName = rawFunctionName(functionNode, parentByNode);
184
+ const occurrenceCount = (occurrenceCountsByName.get(functionName) ?? 0) + 1;
185
+ occurrenceCountsByName.set(functionName, occurrenceCount);
186
+ const uniqueFunctionName = occurrenceCount < FIRST_DUPLICATE_NAME_SUFFIX
187
+ ? functionName
188
+ : `${functionName}~${occurrenceCount}`;
189
+ functionIds.set(functionNode, `${modulePath}#${uniqueFunctionName}`);
190
+ }
191
+ return functionIds;
192
+ };
193
+ const ownReturnArguments = (functionBody) => {
194
+ const returnArguments = [];
195
+ const collectFromNode = (node) => {
196
+ if (isFunctionNode(node)) {
197
+ return;
198
+ }
199
+ if (isReturnStatement(node)) {
200
+ if (node.argument !== null && node.argument !== undefined) {
201
+ returnArguments.push(node.argument);
202
+ }
203
+ return;
204
+ }
205
+ walkChildNodes(node, collectFromNode);
206
+ };
207
+ walkChildNodes(functionBody, collectFromNode);
208
+ return returnArguments;
209
+ };
210
+ const wrapTargets = (functionNode) => {
211
+ if (functionNode.body.type === 'BlockStatement') {
212
+ return ownReturnArguments(functionNode.body);
213
+ }
214
+ return [functionNode.body];
215
+ };
216
+ const collectWraps = (functionNodes, functionIds) => {
217
+ const wraps = [];
218
+ for (const functionNode of functionNodes) {
219
+ const functionId = functionIds.get(functionNode);
220
+ if (functionId === undefined) {
221
+ continue;
222
+ }
223
+ for (const target of wrapTargets(functionNode)) {
224
+ wraps.push({ target, functionId });
225
+ }
226
+ }
227
+ return wraps;
228
+ };
229
+ const isDirectiveStatement = (node) => node.type === 'ExpressionStatement' && typeof node['directive'] === 'string';
230
+ const hashbangEndOffset = (program) => {
231
+ const hashbang = program['hashbang'];
232
+ if (isAstNode(hashbang)) {
233
+ return hashbang.end;
234
+ }
235
+ return 0;
236
+ };
237
+ const importInsertionOffset = (program) => {
238
+ const body = program['body'];
239
+ if (!Array.isArray(body)) {
240
+ return hashbangEndOffset(program);
241
+ }
242
+ let offset = hashbangEndOffset(program);
243
+ for (const statement of body) {
244
+ if (!isAstNode(statement) || !isDirectiveStatement(statement)) {
245
+ break;
246
+ }
247
+ offset = statement.end;
248
+ }
249
+ return offset;
250
+ };
251
+ const uniqueBrandAlias = (code) => {
252
+ if (!code.includes(BRAND_IMPORT_ALIAS)) {
253
+ return BRAND_IMPORT_ALIAS;
254
+ }
255
+ for (let suffix = FIRST_ALIAS_SUFFIX;; suffix += 1) {
256
+ const candidate = `${BRAND_IMPORT_ALIAS}${suffix}`;
257
+ if (!code.includes(candidate)) {
258
+ return candidate;
259
+ }
260
+ }
261
+ };
262
+ /**
263
+ * Brands the return value of every function in a module with that function's
264
+ * identity via `brandViewResult` from `foldkit/brand`.
265
+ *
266
+ * Identities are `${posixRelativePathFromRoot}#${functionName}`, with the
267
+ * function name taken from, in order: the function's own id (declarations and
268
+ * named function expressions), the variable a function expression is bound
269
+ * to, the non-computed key of an
270
+ * object property, class method, or class field, `default` for an
271
+ * export-default function, the member name of an `x.y = fn` assignment, and
272
+ * `anonymous` otherwise. Duplicate names within a module are disambiguated in
273
+ * source order with `~2`, `~3`, and so on, so ids are fully deterministic.
274
+ *
275
+ * Expression-body arrows have their body wrapped in the branding call; block
276
+ * bodies have every `return` argument belonging directly to the function
277
+ * wrapped (nested functions instrument their own returns, and bare `return`
278
+ * is untouched). Branding is set-if-absent on vnodes at runtime, so wrapping
279
+ * every function, including async and generator functions and functions that
280
+ * never produce a vnode, is inert outside view results.
281
+ *
282
+ * Skips foldkit core modules: if the element factory's own returns were
283
+ * branded, every vnode would carry an identity at construction and
284
+ * set-if-absent would neutralize the feature. Core under `node_modules` is
285
+ * always skipped. The plugin resolves the installed foldkit package and passes
286
+ * `isFoldkitCoreResolved`; when it resolved, the plugin's precise package-root
287
+ * gate is authoritative and the coarse `packages/foldkit/` path fragment is
288
+ * left to the resolution-failed fallback, so a consumer whose own path merely
289
+ * contains that segment is still branded.
290
+ *
291
+ * Skips modules whose parsed program imports or re-exports the
292
+ * `foldkit/brand` specifier: packages such as `@foldkit/ui` and
293
+ * `@foldkit/devtools` brand their dist at package build time, and pnpm
294
+ * symlink realpaths can surface that dist outside `node_modules`, where the
295
+ * module-id checks alone would let a build wrap the same returns twice. A
296
+ * module that merely mentions the specifier in a comment or string is still
297
+ * branded; the raw-text check is only a fast negative prefilter.
298
+ *
299
+ * Returns `null` when the module needs no changes.
300
+ */
301
+ export const transformViewIdentity = (code, id, root, options) => {
302
+ if (!isEligibleModuleId(id, options?.isFoldkitCoreResolved ?? false)) {
303
+ return null;
304
+ }
305
+ const program = parseProgram(code);
306
+ if (program === null) {
307
+ return null;
308
+ }
309
+ if (code.includes(BRAND_MODULE_SPECIFIER) && isAlreadyBranded(program)) {
310
+ return null;
311
+ }
312
+ const { functionNodes, parentByNode } = collectFunctionNodes(program);
313
+ const modulePath = toPosixPath(relative(root, stripQuery(id)));
314
+ const functionIds = assignFunctionIds(functionNodes, parentByNode, modulePath);
315
+ const wraps = collectWraps(functionNodes, functionIds);
316
+ if (wraps.length === 0) {
317
+ return null;
318
+ }
319
+ const brandAlias = uniqueBrandAlias(code);
320
+ const sourceText = new MagicString(code);
321
+ // NOTE: applied in descending target order so that when a nested wrap ends
322
+ // exactly where its enclosing wrap ends (`return () => x`), the inner
323
+ // closer is appended first and the calls nest correctly.
324
+ const orderedWraps = [...wraps].sort((first, second) => second.target.start - first.target.start);
325
+ for (const { target, functionId } of orderedWraps) {
326
+ sourceText.appendLeft(target.start, `${brandAlias}((`);
327
+ sourceText.appendRight(target.end, `), ${JSON.stringify(functionId)})`);
328
+ }
329
+ const importStatement = `import { ${BRAND_EXPORT_NAME} as ${brandAlias} } from '${BRAND_MODULE_SPECIFIER}'\n`;
330
+ const insertionOffset = importInsertionOffset(program);
331
+ if (insertionOffset === 0) {
332
+ sourceText.prepend(importStatement);
333
+ }
334
+ else {
335
+ sourceText.appendLeft(insertionOffset, `\n${importStatement}`);
336
+ }
337
+ return {
338
+ code: sourceText.toString(),
339
+ map: sourceText.generateMap({ hires: 'boundary' }),
340
+ };
341
+ };
342
+ // RESOLUTION
343
+ // NOTE: `requireFromRoot.resolve('foldkit')` cannot produce a path here:
344
+ // foldkit's export map is ESM-only, so resolving it under require conditions
345
+ // throws ERR_PACKAGE_PATH_NOT_EXPORTED. Walking the require resolution
346
+ // candidate directories locates the package without touching the export map.
347
+ const resolveFoldkitPackageRoot = (root) => {
348
+ const requireFromRoot = createRequire(resolve(root, 'noop.js'));
349
+ const candidateDirectories = requireFromRoot.resolve.paths(FOLDKIT_PACKAGE_NAME) ?? [];
350
+ for (const candidateDirectory of candidateDirectories) {
351
+ const packageDirectory = join(candidateDirectory, FOLDKIT_PACKAGE_NAME);
352
+ if (existsSync(join(packageDirectory, 'package.json'))) {
353
+ try {
354
+ return realpathSync(packageDirectory);
355
+ }
356
+ catch {
357
+ return packageDirectory;
358
+ }
359
+ }
360
+ }
361
+ return undefined;
362
+ };
363
+ const isRecord = (value) => typeof value === 'object' && value !== null;
364
+ const resolveBrandModulePath = (root) => {
365
+ const packageRoot = resolveFoldkitPackageRoot(root);
366
+ if (packageRoot === undefined) {
367
+ return undefined;
368
+ }
369
+ try {
370
+ const packageJson = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
371
+ if (!isRecord(packageJson) || !isRecord(packageJson['exports'])) {
372
+ return undefined;
373
+ }
374
+ const brandExportEntry = packageJson['exports']['./brand'];
375
+ const importTarget = isRecord(brandExportEntry)
376
+ ? brandExportEntry['import']
377
+ : brandExportEntry;
378
+ if (typeof importTarget !== 'string') {
379
+ return undefined;
380
+ }
381
+ const brandModulePath = join(packageRoot, importTarget);
382
+ if (existsSync(brandModulePath)) {
383
+ return brandModulePath;
384
+ }
385
+ return undefined;
386
+ }
387
+ catch {
388
+ return undefined;
389
+ }
390
+ };
391
+ const isUnderDirectory = (posixPath, posixDirectory) => posixPath === posixDirectory || posixPath.startsWith(`${posixDirectory}/`);
392
+ // PLUGIN
393
+ /**
394
+ * Vite plugin that applies {@link transformViewIdentity} to application
395
+ * modules in both dev and build. Skips `node_modules`, virtual modules,
396
+ * non-script files, and foldkit core itself, resolved from the config root so
397
+ * that configs aliasing `foldkit` straight into `packages/foldkit/src` are
398
+ * excluded too. `@foldkit/ui` and `@foldkit/devtools` modules are branded on
399
+ * purpose.
400
+ *
401
+ * Also pins `foldkit/brand` to the installed package's brand module via a
402
+ * `resolve.alias` entry, so the injected import keeps resolving in configs
403
+ * whose own `foldkit` alias would otherwise swallow the subpath.
404
+ */
405
+ export const foldkitViewIdentity = () => {
406
+ let resolvedRoot = process.cwd();
407
+ let foldkitPackageRoot;
408
+ return {
409
+ name: 'foldkit:view-identity',
410
+ config: userConfig => {
411
+ const brandModulePath = resolveBrandModulePath(userConfig.root ?? process.cwd());
412
+ if (brandModulePath === undefined) {
413
+ return undefined;
414
+ }
415
+ // NOTE: returned as an array because Vite's mergeAlias puts array
416
+ // entries from a plugin ahead of the user's aliases; object-form
417
+ // entries would land after a user's bare `foldkit` alias and the
418
+ // injected specifier would be rewritten into a nonexistent path.
419
+ return {
420
+ resolve: {
421
+ alias: [
422
+ { find: BRAND_MODULE_SPECIFIER, replacement: brandModulePath },
423
+ ],
424
+ },
425
+ };
426
+ },
427
+ configResolved: config => {
428
+ resolvedRoot = config.root;
429
+ const packageRoot = resolveFoldkitPackageRoot(config.root);
430
+ foldkitPackageRoot =
431
+ packageRoot === undefined ? undefined : toPosixPath(packageRoot);
432
+ },
433
+ transform: (code, id) => {
434
+ if (foldkitPackageRoot !== undefined &&
435
+ isUnderDirectory(toPosixPath(stripQuery(id)), foldkitPackageRoot)) {
436
+ return null;
437
+ }
438
+ return transformViewIdentity(code, id, resolvedRoot, {
439
+ isFoldkitCoreResolved: foldkitPackageRoot !== undefined,
440
+ });
441
+ },
442
+ };
443
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldkit/vite-plugin",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Vite plugin for Foldkit hot module reloading with state preservation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,20 +16,24 @@
16
16
  "dist"
17
17
  ],
18
18
  "peerDependencies": {
19
- "effect": "4.0.0-beta.88",
19
+ "effect": "4.0.0-beta.97",
20
20
  "foldkit": "^0",
21
21
  "vite": "^7.0.0 || ^8.0.0"
22
22
  },
23
23
  "dependencies": {
24
+ "magic-string": "^0.30.21",
24
25
  "ws": "^8.21.0"
25
26
  },
26
27
  "devDependencies": {
28
+ "@types/node": "^25.9.3",
27
29
  "@types/ws": "^8.18.1",
28
- "effect": "4.0.0-beta.88",
30
+ "effect": "4.0.0-beta.97",
31
+ "happy-dom": "^20.10.4",
29
32
  "rimraf": "^6.1.3",
30
33
  "typescript": "^6.0.3",
31
34
  "vite": "^8.0.16",
32
- "foldkit": "0.117.0"
35
+ "vitest": "^4.1.9",
36
+ "foldkit": "0.130.0"
33
37
  },
34
38
  "keywords": [
35
39
  "vite",
@@ -55,6 +59,7 @@
55
59
  "clean": "rimraf dist *.tsbuildinfo",
56
60
  "build": "pnpm run clean && tsc -b",
57
61
  "watch": "tsc -b --watch",
58
- "typecheck": "tsc -b --noEmit"
62
+ "typecheck": "tsc -b --noEmit && tsc -p tsconfig.test.json",
63
+ "test": "vitest run"
59
64
  }
60
65
  }