From 188da0f8fb0442b720f656cab13e7fd54adfb5fb Mon Sep 17 00:00:00 2001 From: Chronicle_A <19917752669@163.com> Date: Sun, 23 Aug 2026 15:56:33 +0800 Subject: [PATCH 1/2] fix(compiler): restore Windows path portability Restore compiler filesystem path handling that was lost during later compiler refactors. Use fileURLToPath for compiler-owned filesystem paths while keeping URL pathname semantics where required. Normalize Windows separators in path matching logic and preserve JSXText CRLF behavior without weakening entity checks. --- framework/compiler/jsx-plugin.ts | 66 +++++++++++++++++--------------- tools/build.ts | 2 +- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/framework/compiler/jsx-plugin.ts b/framework/compiler/jsx-plugin.ts index b7c786d7..b9208968 100644 --- a/framework/compiler/jsx-plugin.ts +++ b/framework/compiler/jsx-plugin.ts @@ -6,6 +6,7 @@ import solidPreset from "babel-preset-solid"; import tsPreset from "@babel/preset-typescript"; // untyped - see framework/compiler/ambient.d.ts import { transformVueJsxVapor } from "vue-jsx-vapor/api"; import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { compileVueSfc } from "./vue-sfc-compile.ts"; import { propsHelperCode, @@ -31,10 +32,10 @@ import { POCKET_FRAMEWORKS, SUBPATHS } from "./subpaths.ts"; export type { PocketFramework }; -export const RENDERER_PATH = new URL("../src/renderer.ts", import.meta.url).pathname; -export const RENDERER_SOLID_PATH = new URL("../src/renderer-solid.ts", import.meta.url).pathname; -export const RENDERER_VUE_VAPOR_PATH = new URL("../src/renderer-vue-vapor.ts", import.meta.url).pathname; -export const RENDERER_OCTANE_PATH = new URL("../src/renderer-octane.ts", import.meta.url).pathname; +export const RENDERER_PATH = fileURLToPath(new URL("../src/renderer.ts", import.meta.url)); +export const RENDERER_SOLID_PATH = fileURLToPath(new URL("../src/renderer-solid.ts", import.meta.url)); +export const RENDERER_VUE_VAPOR_PATH = fileURLToPath(new URL("../src/renderer-vue-vapor.ts", import.meta.url)); +export const RENDERER_OCTANE_PATH = fileURLToPath(new URL("../src/renderer-octane.ts", import.meta.url)); /** * subpath -> absolute module file, per framework — derived once from the @@ -53,34 +54,29 @@ const RESOLVED: Record> = (() => { for (const [name, decl] of Object.entries(SUBPATHS)) { for (const fw of POCKET_FRAMEWORKS) { const rel = typeof decl.file === "string" ? decl.file : decl.file[fw]; - if (rel) out[fw][name] = new URL(rel, root).pathname; + if (rel) out[fw][name] = fileURLToPath(new URL(rel, root)); } } return out; })(); -const OCTANE_PROFILING_STUB_PATH = new URL( - "../src/octane-profiling-stub.ts", - import.meta.url, -).pathname; -const GENERATED_STYLES_PATH = new URL( - "../src/styles.generated.ts", - import.meta.url, -).pathname; -const VUE_VAPOR_RUNTIME_PATH = new URL( - "../../node_modules/vue/dist/vue.runtime-with-vapor.esm-browser.prod.js", - import.meta.url, -).pathname; -const SOLID_RUNTIME_PATH = new URL( - "../../node_modules/solid-js/dist/solid.js", - import.meta.url, -).pathname; -const SOLID_UNIVERSAL_RUNTIME_PATH = new URL( - "../../node_modules/solid-js/universal/dist/universal.js", - import.meta.url, -).pathname; +const OCTANE_PROFILING_STUB_PATH = fileURLToPath( + new URL("../src/octane-profiling-stub.ts", import.meta.url), +); +const GENERATED_STYLES_PATH = fileURLToPath( + new URL("../src/styles.generated.ts", import.meta.url), +); +const VUE_VAPOR_RUNTIME_PATH = fileURLToPath( + new URL("../../node_modules/vue/dist/vue.runtime-with-vapor.esm-browser.prod.js", import.meta.url), +); +const SOLID_RUNTIME_PATH = fileURLToPath( + new URL("../../node_modules/solid-js/dist/solid.js", import.meta.url), +); +const SOLID_UNIVERSAL_RUNTIME_PATH = fileURLToPath( + new URL("../../node_modules/solid-js/universal/dist/universal.js", import.meta.url), +); const PACKAGE_NAME = "@pocketjs/framework"; -const CACHE_DIR = new URL("../../.cache/transforms/", import.meta.url).pathname; +const CACHE_DIR = fileURLToPath(new URL("../../.cache/transforms/", import.meta.url)); const CACHE_VERSION = "2"; // manual backstop; compiler sources are hashed in below const COMPILER_DIR = new URL("./", import.meta.url).pathname; @@ -275,7 +271,11 @@ function makeCollector(out: Collected, framework: PocketFramework): PluginObj { }, JSXText(path) { const raw = path.node.extra?.raw; - if (typeof raw === "string" && raw !== path.node.value) { + // The parser normalizes CRLF to LF in node.value while + // extra.raw keeps the source bytes — line endings are not + // entities, so compare like-for-like (CRLF checkouts must + // build exactly like LF ones). + if (typeof raw === "string" && raw.replace(/\r\n/g, "\n") !== path.node.value) { throw path.buildCodeFrameError( "PocketJS: HTML entities in JSX text are not decoded by the JSX renderer - " + 'write the literal character (é, ♥) or a string expression {"\\u00e9"} instead.', @@ -403,8 +403,14 @@ export function packagePath(spec: string, framework: PocketFramework): string | return RESOLVED[framework][subpath] ?? null; } +/** Bun reports native paths (`\` separators on Windows); normalize before + * matching the `/node_modules/` prefix written in source specifiers. */ +function isNodeModuleFile(path: string): boolean { + return path.replace(/\\/g, "/").includes("/node_modules/"); +} + export function frameworkVariantPath(path: string, framework: PocketFramework): string { - if (framework === "solid" || path.includes("/node_modules/") || path.endsWith(".d.ts")) return path; + if (framework === "solid" || isNodeModuleFile(path) || path.endsWith(".d.ts")) return path; const variant = path.replace(/(\.tsx?)$/, `${FRAMEWORKS[framework].outputSuffix}$1`); return variant !== path && existsSync(variant) ? variant : path; } @@ -607,7 +613,7 @@ export function jsxPlugin( path: OCTANE_PROFILING_STUB_PATH, })); build.onResolve({ filter: /^\.\/profiling\.js$/ }, (args) => - args.importer.includes("/node_modules/octane/dist/") + args.importer.replace(/\\/g, "/").includes("/node_modules/octane/dist/") ? { path: OCTANE_PROFILING_STUB_PATH } : undefined, ); @@ -625,7 +631,7 @@ export function jsxPlugin( }); } build.onLoad({ filter: /\.tsx?$/ }, async (args) => { - if (args.path.includes("/node_modules/") || args.path.endsWith(".d.ts")) return undefined; + if (isNodeModuleFile(args.path) || args.path.endsWith(".d.ts")) return undefined; let src = args.path === GENERATED_STYLES_PATH && opts.generatedStyles !== undefined ? opts.generatedStyles diff --git a/tools/build.ts b/tools/build.ts index 58ed61d8..fbb7c160 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -265,7 +265,7 @@ async function walk(file: string): Promise { // output [R]. Other generated modules (e.g. the launcher's registry) are // ordinary app data whose literals — cover asset paths, title glyphs — // pass 1 must see like any hand-written module's. - if (file.endsWith("/styles.generated.ts")) return; + if (file.replace(/\\/g, "/").endsWith("/styles.generated.ts")) return; const src = await Bun.file(file).text(); // Throws with a code frame on lint errors. const res = await transformFile(file, src, framework, { features: buildPlan?.features }); From 4d0c138ea894e8c7a914012b68f8d9d6ccbf37bd Mon Sep 17 00:00:00 2001 From: Chronicle_A <19917752669@163.com> Date: Sun, 23 Aug 2026 15:57:07 +0800 Subject: [PATCH 2/2] test(compiler): add Windows portability regression coverage Add regression coverage for compiler filesystem paths, Windows separator handling, and JSXText line-ending behavior. Ensure future compiler changes do not reintroduce unsafe URL pathname assumptions on Windows. --- tests/compiler-portability.test.ts | 134 +++++++++++++++++++++++++++++ tools/test.ts | 1 + 2 files changed, 135 insertions(+) create mode 100644 tests/compiler-portability.test.ts diff --git a/tests/compiler-portability.test.ts b/tests/compiler-portability.test.ts new file mode 100644 index 00000000..65a38283 --- /dev/null +++ b/tests/compiler-portability.test.ts @@ -0,0 +1,134 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + FRAMEWORKS, + RENDERER_OCTANE_PATH, + RENDERER_PATH, + RENDERER_SOLID_PATH, + RENDERER_VUE_VAPOR_PATH, + frameworkVariantPath, + jsxPlugin, + packagePath, + transformFile, +} from "../framework/compiler/jsx-plugin.ts"; + +const directories: string[] = []; + +afterAll(async () => { + await Promise.all(directories.map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function temporaryDirectory(prefix: string): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)); + directories.push(directory); + return directory; +} + +function expectNativeFile(path: string): void { + if (process.platform === "win32") { + expect(path).toMatch(/^(?:[A-Za-z]:\\|\\\\)/); + expect(path).not.toMatch(/^\/[A-Za-z]:\//); + } else { + expect(path.startsWith("/")).toBe(true); + } + expect(existsSync(path), path).toBe(true); +} + +describe("compiler filesystem portability", () => { + test("compiler-owned renderer and package paths are native files", () => { + for (const path of [ + RENDERER_PATH, + RENDERER_SOLID_PATH, + RENDERER_VUE_VAPOR_PATH, + RENDERER_OCTANE_PATH, + ]) { + expectNativeFile(path); + } + + for (const framework of ["solid", "vue-vapor", "octane"] as const) { + expectNativeFile(FRAMEWORKS[framework].rootPath); + expectNativeFile(FRAMEWORKS[framework].rendererPath); + const components = packagePath("@pocketjs/framework/components", framework); + expect(components).not.toBeNull(); + expectNativeFile(components!); + } + }); + + test("the transform cache accepts compiler output on the native filesystem", async () => { + const source = "export const view = Cache probe;"; + const first = await transformFile("/virtual/compiler-cache-probe.tsx", source, "solid"); + const second = await transformFile("/virtual/compiler-cache-probe.tsx", source, "solid"); + + expect(second.code).toBe(first.code); + expect(second.classStrings).toEqual(first.classStrings); + expect(second.textCodepoints).toEqual(first.textCodepoints); + }); + + test.each([ + ["solid", 'import { createSignal } from "solid-js"; export const value = createSignal(1);'], + ["vue-vapor", 'import { ref } from "vue"; export const value = ref(1);'], + ] as const)("%s runtime alias resolves to a bundleable native file", async (framework, source) => { + const directory = await temporaryDirectory(`pocketjs-${framework}-runtime-`); + const entry = join(directory, "entry.js"); + await Bun.write(entry, source); + + const result = await Bun.build({ + entrypoints: [entry], + format: "esm", + target: "browser", + conditions: ["browser"], + define: { + "process.env.NODE_ENV": '"production"', + document: "globalThis.__pocketDocument", + __POCKET_TARGET__: '""', + __POCKET_HOST_ABI__: "0", + __POCKET_FEATURES__: "{}", + __POCKET_PIXEL_RATIO__: "1", + }, + plugins: [jsxPlugin(framework, { entry })], + }); + + expect(result.success).toBe(true); + expect((await result.outputs[0]!.text()).length).toBeGreaterThan(0); + }); + + test("LF and CRLF JSXText produce equivalent compiler output", async () => { + const lf = "export const view = \n Hello\n;\n"; + const crlf = lf.replace(/\n/g, "\r\n"); + const fromLf = await transformFile("/virtual/line-endings.tsx", lf, "solid"); + const fromCrlf = await transformFile("/virtual/line-endings.tsx", crlf, "solid"); + + expect(fromCrlf.code).toBe(fromLf.code); + expect(fromCrlf.classStrings).toEqual(fromLf.classStrings); + expect(fromCrlf.textCodepoints).toEqual(fromLf.textCodepoints); + }); + + test.each([ + ["named", "é"], + ["numeric LF", " "], + ["numeric CR", " "], + ])("rejects %s entities in JSXText", async (name, entity) => { + await expect( + transformFile(`/virtual/entity-${name}.tsx`, `${entity}`, "solid"), + ).rejects.toThrow("HTML entities in JSX text are not decoded"); + }); + + test("native node_modules separators do not select framework variants", async () => { + const directory = await temporaryDirectory("pocketjs-node-modules-path-"); + const moduleDirectory = process.platform === "win32" + ? join(directory, "node_modules", "fixture") + : directory; + await mkdir(moduleDirectory, { recursive: true }); + const source = process.platform === "win32" + ? join(moduleDirectory, "entry.ts") + : join(moduleDirectory, String.raw`C:\repo\node_modules\fixture\entry.ts`); + const variant = source.replace(/\.ts$/, ".octane.ts"); + await Bun.write(source, "export const selected = 'package';\n"); + await Bun.write(variant, "export const selected = 'variant';\n"); + + expect(frameworkVariantPath(source, "octane")).toBe(source); + }); +}); diff --git a/tools/test.ts b/tools/test.ts index cdfe2d1c..0074a168 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -57,6 +57,7 @@ const SUITE: readonly Stage[] = [ "tests/iphone2g-device-contract.test.ts", "tests/iphone2g-toolchain.test.ts", "tests/iphone2g-device-transaction.test.ts", + "tests/compiler-portability.test.ts", "tests/platform-runtime.test.ts", "tests/app-check.test.ts", "tests/vue-sfc.test.ts",