@foldkit/vite-plugin 0.10.1 → 0.11.1
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 +10 -2
- package/dist/brandDist.d.ts +19 -0
- package/dist/brandDist.d.ts.map +1 -0
- package/dist/brandDist.js +40 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/viewIdentity.d.ts +63 -0
- package/dist/viewIdentity.d.ts.map +1 -0
- package/dist/viewIdentity.js +443 -0
- package/package.json +10 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @foldkit/vite-plugin
|
|
2
2
|
|
|
3
|
-
Vite plugin for Foldkit
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.11.1",
|
|
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.
|
|
19
|
+
"effect": "4.0.0-beta.101",
|
|
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.
|
|
30
|
+
"effect": "4.0.0-beta.101",
|
|
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
|
-
"
|
|
35
|
+
"vitest": "^4.1.9",
|
|
36
|
+
"foldkit": "0.132.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
|
}
|