@praxisjs/vite-plugin 0.1.0 → 1.0.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,143 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ import { praxisjs } from "../index";
4
+
5
+ describe("praxisjs() plugin factory", () => {
6
+ it("returns two plugins by default (core + hmr)", () => {
7
+ const plugins = praxisjs();
8
+ expect(plugins).toHaveLength(2);
9
+ });
10
+
11
+ it("returns one plugin when hmr is disabled", () => {
12
+ const plugins = praxisjs({ hmr: false });
13
+ expect(plugins).toHaveLength(1);
14
+ });
15
+
16
+ it("core plugin has the correct name", () => {
17
+ const [core] = praxisjs();
18
+ expect(core.name).toBe("praxisjs:core");
19
+ });
20
+
21
+ it("core plugin enforces 'pre'", () => {
22
+ const [core] = praxisjs();
23
+ expect(core.enforce).toBe("pre");
24
+ });
25
+
26
+ it("hmr plugin has the correct name", () => {
27
+ const [, hmr] = praxisjs();
28
+ expect(hmr.name).toBe("praxisjs:hmr");
29
+ });
30
+
31
+ it("hmr plugin enforces 'post'", () => {
32
+ const [, hmr] = praxisjs();
33
+ expect(hmr.enforce).toBe("post");
34
+ });
35
+ });
36
+
37
+ describe("praxisjs:core config()", () => {
38
+ it("sets esbuild target to es2022", () => {
39
+ const [core] = praxisjs();
40
+ const config = (core as unknown as { config: () => unknown }).config();
41
+ expect(config).toEqual({ esbuild: { target: "es2022" } });
42
+ });
43
+ });
44
+
45
+ describe("praxisjs:core transform()", () => {
46
+ type TransformFn = (code: string, id: string) => null | undefined;
47
+
48
+ it("returns null for .tsx files when autoImport is enabled", () => {
49
+ const [core] = praxisjs({ autoImport: true });
50
+ const transform = (core as { transform: TransformFn }).transform;
51
+ const result = transform("const x = 1;", "component.tsx");
52
+ expect(result).toBeNull();
53
+ });
54
+
55
+ it("returns null for .jsx files when autoImport is enabled", () => {
56
+ const [core] = praxisjs({ autoImport: true });
57
+ const transform = (core as { transform: TransformFn }).transform;
58
+ const result = transform("const x = 1;", "component.jsx");
59
+ expect(result).toBeNull();
60
+ });
61
+
62
+ it("returns undefined (no-op) for non-.tsx/.jsx files", () => {
63
+ const [core] = praxisjs({ autoImport: true });
64
+ const transform = (core as { transform: TransformFn }).transform;
65
+ const result = transform("const x = 1;", "module.ts");
66
+ expect(result).toBeUndefined();
67
+ });
68
+
69
+ it("returns undefined when autoImport is false", () => {
70
+ const [core] = praxisjs({ autoImport: false });
71
+ const transform = (core as { transform: TransformFn }).transform;
72
+ const result = transform("const x = 1;", "component.tsx");
73
+ expect(result).toBeUndefined();
74
+ });
75
+
76
+ it("returns undefined when code already imports @praxisjs/jsx/jsx-runtime", () => {
77
+ const [core] = praxisjs({ autoImport: true });
78
+ const transform = (core as { transform: TransformFn }).transform;
79
+ const code = 'import "@praxisjs/jsx/jsx-runtime"; const x = 1;';
80
+ const result = transform(code, "component.tsx");
81
+ expect(result).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ describe("praxisjs:hmr handleHotUpdate()", () => {
86
+ type HandleHotUpdate = (ctx: { file: string; server: unknown }) => void;
87
+
88
+ function makeMockServer() {
89
+ return {
90
+ ws: {
91
+ send: vi.fn(),
92
+ },
93
+ };
94
+ }
95
+
96
+ it("sends custom event for .ts files", () => {
97
+ const [, hmr] = praxisjs({ hmr: true });
98
+ const server = makeMockServer();
99
+ (hmr as unknown as { handleHotUpdate: HandleHotUpdate }).handleHotUpdate({
100
+ file: "src/app.ts",
101
+ server,
102
+ });
103
+ expect(server.ws.send).toHaveBeenCalledWith({
104
+ type: "custom",
105
+ event: "praxisjs:component-update",
106
+ data: { file: "src/app.ts" },
107
+ });
108
+ });
109
+
110
+ it("sends custom event for .tsx files", () => {
111
+ const [, hmr] = praxisjs({ hmr: true });
112
+ const server = makeMockServer();
113
+ (hmr as unknown as { handleHotUpdate: HandleHotUpdate }).handleHotUpdate({
114
+ file: "src/component.tsx",
115
+ server,
116
+ });
117
+ expect(server.ws.send).toHaveBeenCalledWith({
118
+ type: "custom",
119
+ event: "praxisjs:component-update",
120
+ data: { file: "src/component.tsx" },
121
+ });
122
+ });
123
+
124
+ it("does not send event for non-.ts/.tsx files", () => {
125
+ const [, hmr] = praxisjs({ hmr: true });
126
+ const server = makeMockServer();
127
+ (hmr as unknown as { handleHotUpdate: HandleHotUpdate }).handleHotUpdate({
128
+ file: "src/style.css",
129
+ server,
130
+ });
131
+ expect(server.ws.send).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it("does not send event for .js files", () => {
135
+ const [, hmr] = praxisjs({ hmr: true });
136
+ const server = makeMockServer();
137
+ (hmr as unknown as { handleHotUpdate: HandleHotUpdate }).handleHotUpdate({
138
+ file: "src/utils.js",
139
+ server,
140
+ });
141
+ expect(server.ws.send).not.toHaveBeenCalled();
142
+ });
143
+ });
package/src/css.ts ADDED
@@ -0,0 +1,186 @@
1
+ import { existsSync, readFileSync, readdirSync, type Dirent } from "node:fs";
2
+ import path from "node:path";
3
+ import vm from "node:vm";
4
+
5
+ import { build as esbuildBuild } from "esbuild";
6
+
7
+ import { extractionModule } from "@praxisjs/css/extract";
8
+
9
+ import type { Plugin, ResolvedConfig } from "vite";
10
+
11
+ // ─── Constants ────────────────────────────────────────────────────────────────
12
+
13
+ // The .css extension is required so Vite recognises this as a CSS asset and
14
+ // emits it as a separate file rather than inlining it into a JS module.
15
+ const VIRTUAL_ID = "virtual:praxisjs/styles.css";
16
+ const RESOLVED_ID = "\0virtual:praxisjs/styles.css";
17
+
18
+ const HAS_CSS_CONTENT =
19
+ /extends\s+(Reactive)?Stylesheet\b|\bStyled\s*\(|\bkeyframes\s*\(|\bglobalStyle\s*\(|\bThemed\s*\(/;
20
+
21
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".cache", "out"]);
22
+
23
+ // ─── File scanner ─────────────────────────────────────────────────────────────
24
+
25
+ function findSourceFiles(dir: string): string[] {
26
+ const files: string[] = [];
27
+ let entries: Dirent[];
28
+ try {
29
+ entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
30
+ } catch {
31
+ return files;
32
+ }
33
+ for (const entry of entries) {
34
+ const name = entry.name;
35
+ if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
36
+ const full = path.join(dir, name);
37
+ if (entry.isDirectory()) files.push(...findSourceFiles(full));
38
+ else if (name.endsWith(".ts") || name.endsWith(".tsx")) files.push(full);
39
+ }
40
+ return files;
41
+ }
42
+
43
+ function findTsConfig(root: string): string | undefined {
44
+ const p = path.join(root, "tsconfig.json");
45
+ return existsSync(p) ? p : undefined;
46
+ }
47
+
48
+ // ─── Noop stub for @praxisjs/* packages (except @praxisjs/css) ───────────────
49
+
50
+ // Regular function (not arrow) so it can be used as a base class via `extends`.
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ function noop(this: any, ..._args: unknown[]): (...a: unknown[]) => unknown {
53
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
54
+ return () => {};
55
+ }
56
+
57
+ function makePraxisStub(): Record<string, unknown> {
58
+ return new Proxy({} as Record<string | symbol, unknown>, { get: () => noop });
59
+ }
60
+
61
+ // ─── CSS extractor ────────────────────────────────────────────────────────────
62
+
63
+ async function extractStaticCSS(root: string): Promise<string> {
64
+ const candidates = findSourceFiles(root)
65
+ .filter((f) => HAS_CSS_CONTENT.test(readFileSync(f, "utf-8")));
66
+ if (candidates.length === 0) return "";
67
+
68
+ // Each candidate is wrapped in try/catch so one bad file doesn't abort extraction.
69
+ const entry = candidates
70
+ .map((f) => `try { require(${JSON.stringify(f)}); } catch {}`)
71
+ .join("\n");
72
+
73
+ let bundleCode: string;
74
+ try {
75
+ const result = await esbuildBuild({
76
+ stdin: { contents: entry, loader: "js", resolveDir: root },
77
+ bundle: true,
78
+ write: false,
79
+ format: "cjs",
80
+ platform: "node",
81
+ target: "node20",
82
+ tsconfig: findTsConfig(root),
83
+ plugins: [
84
+ {
85
+ name: "praxisjs-css-extract",
86
+ setup(build) {
87
+ // @praxisjs/css is handled by the vm sandbox (extractionModule).
88
+ // Keep all @praxisjs/* external so esbuild emits plain require()
89
+ // calls — no __toESM wrapping that would break Proxy property access.
90
+ build.onResolve({ filter: /^@praxisjs\// }, () => ({ external: true }));
91
+ build.onResolve({ filter: /^[^./]/ }, () => ({ external: true }));
92
+ },
93
+ },
94
+ ],
95
+ logLevel: "silent",
96
+ });
97
+ bundleCode = result.outputFiles[0].text;
98
+ } catch {
99
+ return "";
100
+ }
101
+
102
+ const collected: string[] = [];
103
+ const cssMod = extractionModule((css: string, prepend?: boolean) => {
104
+ if (prepend) collected.unshift(css); else collected.push(css);
105
+ });
106
+ const praxisStub = makePraxisStub();
107
+
108
+ const sandbox: Record<string, unknown> = {
109
+ require: (id: string) => {
110
+ if (id === "@praxisjs/css") return cssMod;
111
+ if (id.startsWith("@praxisjs/")) return praxisStub;
112
+ return {};
113
+ },
114
+ console: { log: noop, warn: noop, error: noop },
115
+ process: { env: {} },
116
+ setTimeout: noop,
117
+ clearTimeout: noop,
118
+ };
119
+
120
+ try {
121
+ vm.runInNewContext(bundleCode, sandbox);
122
+ } catch {
123
+ return "";
124
+ }
125
+
126
+ return collected.join("\n");
127
+ }
128
+
129
+ // ─── Plugin ───────────────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Extracts all `@Styled()`, `keyframes()`, and `globalStyle()` CSS at build
133
+ * time and emits it as `virtual:praxisjs/styles.css`.
134
+ *
135
+ * In production:
136
+ * - CSS lives in a static asset with normal HTTP caching.
137
+ * - Runtime `<style>` injection is disabled (`__PRAXIS_CSS_STATIC__ = true`).
138
+ *
139
+ * In development:
140
+ * - The virtual module is empty; CSS is injected at runtime.
141
+ * - HMR works without extra configuration.
142
+ *
143
+ * @example
144
+ * // vite.config.ts
145
+ * import { praxisjs, praxisjsCSS } from '@praxisjs/vite-plugin'
146
+ * export default defineConfig({ plugins: [praxisjs(), praxisjsCSS()] })
147
+ */
148
+ export function praxisjsCSS(): Plugin {
149
+ let config: ResolvedConfig;
150
+ let staticCSS = "";
151
+
152
+ return {
153
+ name: "praxisjs:css",
154
+ enforce: "pre",
155
+
156
+ configResolved(c) {
157
+ config = c;
158
+ },
159
+
160
+ config(_, env) {
161
+ if (env.command === "build") {
162
+ return { define: { __PRAXIS_CSS_STATIC__: JSON.stringify(true) } };
163
+ }
164
+ },
165
+
166
+ resolveId(id) {
167
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
168
+ },
169
+
170
+ load(id) {
171
+ if (id !== RESOLVED_ID) return;
172
+ if (config.command === "serve") return "/* @praxisjs/css: runtime injection active */";
173
+ return staticCSS
174
+ ? `/* @praxisjs/css: static extraction */\n${staticCSS}`
175
+ : "/* @praxisjs/css: no static styles found */";
176
+ },
177
+
178
+ async buildStart() {
179
+ if (config.command !== "build") return;
180
+ staticCSS = await extractStaticCSS(config.root);
181
+ if (staticCSS) {
182
+ this.info(`@praxisjs/css: extracted ${String(staticCSS.split("\n").length)} CSS rules`);
183
+ }
184
+ },
185
+ };
186
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import { praxisjsCSS } from "./css.js";
2
+
1
3
  import type { Plugin, ViteDevServer } from "vite";
2
4
 
5
+ export { praxisjsCSS };
6
+
3
7
  export interface PraxisJSVitePluginOptions {
4
8
  hmr?: boolean;
5
9
  autoImport?: boolean;