Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 36 additions & 30 deletions framework/compiler/jsx-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -53,34 +54,29 @@ const RESOLVED: Record<PocketFramework, Record<string, string>> = (() => {
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;

Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
);
Expand All @@ -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
Expand Down
134 changes: 134 additions & 0 deletions tests/compiler-portability.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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 = <View>Cache probe</View>;";
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 = <View>\n Hello\n</View>;\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", "&eacute;"],
["numeric LF", "&#10;"],
["numeric CR", "&#13;"],
])("rejects %s entities in JSXText", async (name, entity) => {
await expect(
transformFile(`/virtual/entity-${name}.tsx`, `<View>${entity}</View>`, "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);
});
});
2 changes: 1 addition & 1 deletion tools/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ async function walk(file: string): Promise<void> {
// 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 });
Expand Down
1 change: 1 addition & 0 deletions tools/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down