babel-plugin-nativedesktop 0.1.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/bun-plugin.js +48 -0
- package/hooks.js +21 -0
- package/index.js +52 -0
- package/package.json +22 -0
- package/rewrite.js +51 -0
package/bun-plugin.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Bun plugin — the `bun --hot` dev-path twin of the babel plugin. Loaded via
|
|
2
|
+
// a `bunfig.toml` `preload` so it registers once per process (surviving hot
|
|
3
|
+
// re-evals) and rewrites shared `.ts` source as Bun loads it.
|
|
4
|
+
//
|
|
5
|
+
// This is NOT the module-aliasing mechanism the docs reject: onLoad here never
|
|
6
|
+
// touches the "react" module (Bun's resolver loads bare specifiers directly,
|
|
7
|
+
// so onLoad does not fire for them). It fires for the IMPORTING source files
|
|
8
|
+
// and edits the import specifier STRING inside them — a plain source
|
|
9
|
+
// transform, so no ESM/CJS `provideFetch` conflict arises. See
|
|
10
|
+
// docs/agents/README.md "HMR: what actually preserves state" and
|
|
11
|
+
// packages/react/src/dev-react.ts.
|
|
12
|
+
//
|
|
13
|
+
// FILTER IS `.ts` ONLY — deliberately excludes `.tsx`/`.desktop.tsx`. Verified
|
|
14
|
+
// empirically (M8-D8): Bun's runtime onLoad has no fall-through — a matched
|
|
15
|
+
// file MUST return contents (returning undefined or a contents-less object
|
|
16
|
+
// both error), and once a plugin returns contents for a file, `bun --hot`
|
|
17
|
+
// drops that file from its watch set, so the file no longer hot-reloads.
|
|
18
|
+
// Intercepting component `.tsx` files would therefore break their HMR. So we
|
|
19
|
+
// intercept only shared, non-component `.ts` modules — the natural home of
|
|
20
|
+
// cross-platform hooks (`import { useState } from "react"`). Such a `.ts` file
|
|
21
|
+
// is pinned at first eval: editing it requires a restart to take effect, but a
|
|
22
|
+
// `.tsx`/`.desktop.tsx` component that consumes it hot-reloads normally, and a
|
|
23
|
+
// component re-eval never resurfaces the dispatcher crash because the shared
|
|
24
|
+
// module's hooks stay bound to `@nativedesktop/react`. Desktop `.tsx`
|
|
25
|
+
// components import hooks from `@nativedesktop/react` (or from shared `.ts`
|
|
26
|
+
// hooks), never raw "react". The babel plugin (compiled path) has no watcher
|
|
27
|
+
// to preserve, so it rewrites every extension.
|
|
28
|
+
const { rewriteReactHookImports } = require("./rewrite.js");
|
|
29
|
+
|
|
30
|
+
const DEBUG = process.env.ND_HOOK_REWRITE_DEBUG === "1";
|
|
31
|
+
|
|
32
|
+
Bun.plugin({
|
|
33
|
+
name: "nativedesktop-hook-imports",
|
|
34
|
+
setup(build) {
|
|
35
|
+
build.onLoad({ filter: /\.ts$/ }, async (args) => {
|
|
36
|
+
const source = await Bun.file(args.path).text();
|
|
37
|
+
// Dependency source is returned verbatim (never skipped — onLoad cannot
|
|
38
|
+
// decline). Deps don't hot-reload anyway, so freezing them is harmless.
|
|
39
|
+
const skip = args.path.includes("/node_modules/") || args.path.includes("/dist/");
|
|
40
|
+
const contents = skip ? source : rewriteReactHookImports(source);
|
|
41
|
+
if (DEBUG) {
|
|
42
|
+
const state = skip ? "skip" : contents === source ? "nochange" : "rewrote";
|
|
43
|
+
console.error(`ND_HOOK_REWRITE ${state} path=${args.path}`);
|
|
44
|
+
}
|
|
45
|
+
return { contents, loader: args.loader };
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
});
|
package/hooks.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The React hook subset that `@nativedesktop/react` pins across `bun --hot`
|
|
2
|
+
// re-evals (packages/react/src/dev-react.ts). A hook imported from raw "react"
|
|
3
|
+
// resolves to a re-evaluated react instance whose dispatcher no reconciler
|
|
4
|
+
// ever attached to -> "Invalid hook call". These names, and only these, get
|
|
5
|
+
// their `from "react"` import redirected to `@nativedesktop/react`.
|
|
6
|
+
module.exports.PINNED_HOOKS = [
|
|
7
|
+
"useState",
|
|
8
|
+
"useEffect",
|
|
9
|
+
"useLayoutEffect",
|
|
10
|
+
"useMemo",
|
|
11
|
+
"useCallback",
|
|
12
|
+
"useRef",
|
|
13
|
+
"useContext",
|
|
14
|
+
"useReducer",
|
|
15
|
+
"useTransition",
|
|
16
|
+
"useDeferredValue",
|
|
17
|
+
"useSyncExternalStore",
|
|
18
|
+
"useId",
|
|
19
|
+
"use",
|
|
20
|
+
"startTransition",
|
|
21
|
+
];
|
package/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// babel-plugin-nativedesktop — compiled/production path (M8-D8).
|
|
2
|
+
//
|
|
3
|
+
// Rewrites named React-hook imports `from "react"` to `from
|
|
4
|
+
// "@nativedesktop/react"` so shared, platform-agnostic hooks (authored the
|
|
5
|
+
// normal way, `import { useState } from "react"`, because web/React-Native
|
|
6
|
+
// also consume them) resolve to the HMR-pinned, reconciler-attached react
|
|
7
|
+
// instance when compiled into a NativeDesktop app. Only the hook subset
|
|
8
|
+
// dev-react.ts pins is moved; default/namespace/type-only specifiers stay on
|
|
9
|
+
// "react". See hooks.js for the list; bun-plugin.js is the `bun --hot`
|
|
10
|
+
// counterpart for the dev path (babel does not run under `bun --hot`).
|
|
11
|
+
const { PINNED_HOOKS } = require("./hooks.js");
|
|
12
|
+
|
|
13
|
+
const HOOK_SET = new Set(PINNED_HOOKS);
|
|
14
|
+
const ND_SOURCE = "@nativedesktop/react";
|
|
15
|
+
|
|
16
|
+
module.exports = function babelPluginNativedesktop({ types: t }) {
|
|
17
|
+
return {
|
|
18
|
+
name: "nativedesktop-hook-imports",
|
|
19
|
+
visitor: {
|
|
20
|
+
ImportDeclaration(path) {
|
|
21
|
+
const node = path.node;
|
|
22
|
+
if (node.source.value !== "react") return;
|
|
23
|
+
// `import type { … } from "react"` — leave type-only imports alone.
|
|
24
|
+
if (node.importKind === "type") return;
|
|
25
|
+
|
|
26
|
+
const hookSpecifiers = [];
|
|
27
|
+
const restSpecifiers = [];
|
|
28
|
+
for (const spec of node.specifiers) {
|
|
29
|
+
if (
|
|
30
|
+
t.isImportSpecifier(spec) &&
|
|
31
|
+
spec.importKind !== "type" &&
|
|
32
|
+
t.isIdentifier(spec.imported) &&
|
|
33
|
+
HOOK_SET.has(spec.imported.name)
|
|
34
|
+
) {
|
|
35
|
+
hookSpecifiers.push(spec);
|
|
36
|
+
} else {
|
|
37
|
+
restSpecifiers.push(spec);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (hookSpecifiers.length === 0) return;
|
|
41
|
+
|
|
42
|
+
const ndImport = t.importDeclaration(hookSpecifiers, t.stringLiteral(ND_SOURCE));
|
|
43
|
+
if (restSpecifiers.length > 0) {
|
|
44
|
+
node.specifiers = restSpecifiers;
|
|
45
|
+
path.insertAfter(ndImport);
|
|
46
|
+
} else {
|
|
47
|
+
path.replaceWith(ndImport);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "babel-plugin-nativedesktop",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Babel plugin rewriting react hook imports to @nativedesktop/react for shared-logic packages",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/FormalSnake/NativeDesktop.git",
|
|
9
|
+
"directory": "packages/babel-plugin-nativedesktop"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
|
|
12
|
+
"bugs": "https://github.com/FormalSnake/NativeDesktop/issues",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"main": "index.js",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./index.js",
|
|
19
|
+
"./bun-plugin": "./bun-plugin.js"
|
|
20
|
+
},
|
|
21
|
+
"files": ["index.js", "bun-plugin.js", "rewrite.js", "hooks.js"]
|
|
22
|
+
}
|
package/rewrite.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// String-level twin of the babel plugin's ImportDeclaration transform, for
|
|
2
|
+
// the `bun --hot` dev path (bun-plugin.js) where babel never runs. Bun's
|
|
3
|
+
// onLoad hands us raw source text, not an AST, so this rewrites the import
|
|
4
|
+
// clause textually. Scoped to well-formed TS/TSX import statements: only
|
|
5
|
+
// `import [Default,] { … } from "react"` shapes are touched, never
|
|
6
|
+
// `import type …`, `import React from "react"`, or `import * as React`.
|
|
7
|
+
const { PINNED_HOOKS } = require("./hooks.js");
|
|
8
|
+
|
|
9
|
+
const HOOK_SET = new Set(PINNED_HOOKS);
|
|
10
|
+
const ND_SOURCE = "@nativedesktop/react";
|
|
11
|
+
|
|
12
|
+
// `[^{}]*` cannot cross a brace, so it can never swallow a neighbouring
|
|
13
|
+
// import statement's clause even when semicolons are elided (ASI). The
|
|
14
|
+
// `(?!type\b)` guard skips `import type { … } from "react"`.
|
|
15
|
+
const REACT_NAMED_IMPORT = /import\s+(?!type\b)(?:([A-Za-z_$][\w$]*)\s*,\s*)?\{([^{}]*)\}\s*from\s*(['"])react\3\s*;?/g;
|
|
16
|
+
|
|
17
|
+
function rewriteReactHookImports(code) {
|
|
18
|
+
if (!code.includes('"react"') && !code.includes("'react'")) return code;
|
|
19
|
+
return code.replace(REACT_NAMED_IMPORT, (match, defaultImport, named, quote) => {
|
|
20
|
+
const specs = named
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((s) => s.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
|
|
25
|
+
const hookSpecs = [];
|
|
26
|
+
const restSpecs = [];
|
|
27
|
+
for (const spec of specs) {
|
|
28
|
+
if (/^type\s/.test(spec)) {
|
|
29
|
+
restSpecs.push(spec);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const imported = spec.split(/\s+as\s+/)[0].trim();
|
|
33
|
+
if (HOOK_SET.has(imported)) hookSpecs.push(spec);
|
|
34
|
+
else restSpecs.push(spec);
|
|
35
|
+
}
|
|
36
|
+
if (hookSpecs.length === 0) return match;
|
|
37
|
+
|
|
38
|
+
const out = [];
|
|
39
|
+
if (defaultImport && restSpecs.length) {
|
|
40
|
+
out.push(`import ${defaultImport}, { ${restSpecs.join(", ")} } from ${quote}react${quote};`);
|
|
41
|
+
} else if (defaultImport) {
|
|
42
|
+
out.push(`import ${defaultImport} from ${quote}react${quote};`);
|
|
43
|
+
} else if (restSpecs.length) {
|
|
44
|
+
out.push(`import { ${restSpecs.join(", ")} } from ${quote}react${quote};`);
|
|
45
|
+
}
|
|
46
|
+
out.push(`import { ${hookSpecs.join(", ")} } from ${quote}${ND_SOURCE}${quote};`);
|
|
47
|
+
return out.join(" ");
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { rewriteReactHookImports };
|