1import * as Bundle from "@/Bundle/Bundle";
2import { RAW_RE, rawPlugin, splitFileAndPostfix } from "@/Bundle/RawPlugin";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, describe, expect, it, layer } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Predicate from "effect/Predicate";
9import * as NodeFs from "node:fs/promises";
10
11layer(NodeServices.layer)("Bundle.build with rawPlugin", (it) => {
12 it.effect(
13 "inlines a sibling file imported with ?raw",
14 () =>
15 Effect.gen(function* () {
16 const fs = yield* FileSystem.FileSystem;
17 const path = yield* Path.Path;
18 const root = yield* fs.makeTempDirectory({
19 prefix: "alchemy-raw-bundle-",
20 });
21 yield* fs.writeFileString(
22 path.join(root, "hello.txt"),
23 "HELLO_RAW_MARKER",
24 );
25 const entry = path.join(root, "entry.ts");
26 yield* fs.writeFileString(
27 entry,
28 `import txt from "./hello.txt?raw";\nconsole.log(txt);\n`,
29 );
30
31 const result = yield* Bundle.build({
32 input: entry,
33 cwd: root,
34 });
35
36 const code = result.files
37 .filter((f) => typeof f.content === "string")
38 .map((f) => f.content as string)
39 .join("\n");
40 expect(code).toContain(`"HELLO_RAW_MARKER"`);
41 // The bundle should not emit hello.txt as a separate asset.
42 expect(result.files.every((f) => !f.path.endsWith("hello.txt"))).toBe(
43 true,
44 );
45
46 yield* fs.remove(root, { recursive: true });
47 }),
48 { tags: ["unit", "local"] },
49 );
50
51 it.effect(
52 "resolves ?raw imports through subdirectories",
53 () =>
54 Effect.gen(function* () {
55 const fs = yield* FileSystem.FileSystem;
56 const path = yield* Path.Path;
57 const root = yield* fs.makeTempDirectory({
58 prefix: "alchemy-raw-subdir-",
59 });
60 yield* fs.makeDirectory(path.join(root, "sub"), { recursive: true });
61 yield* fs.writeFileString(
62 path.join(root, "sub", "foo.json"),
63 `{"marker":"SUBDIR_RAW_MARKER"}`,
64 );
65 const entry = path.join(root, "entry.ts");
66 yield* fs.writeFileString(
67 entry,
68 `import foo from "./sub/foo.json?raw";\nconsole.log(foo);\n`,
69 );
70
71 const result = yield* Bundle.build({
72 input: entry,
73 cwd: root,
74 });
75
76 const code = result.files
77 .filter((f) => typeof f.content === "string")
78 .map((f) => f.content as string)
79 .join("\n");
80 expect(code).toContain("SUBDIR_RAW_MARKER");
81
82 yield* fs.remove(root, { recursive: true });
83 }),
84 { tags: ["unit", "local"] },
85 );
86});
87
88layer(NodeServices.layer)("rawPlugin load hook", (it) => {
89 /**
90 * Invokes a plugin's `load` hook directly with a stubbed plugin
91 * context. The handler reads through `this.fs` (rolldown's
92 * plugin-context filesystem); we stub it with `node:fs/promises` so the
93 * disk read works without booting a full rolldown build.
94 */
95 const load = (id: string) =>
96 Effect.promise(async () => {
97 const plugin = rawPlugin();
98 assert(
99 Predicate.hasProperty(plugin, "load") &&
100 Predicate.hasProperty(plugin.load, "handler") &&
101 typeof plugin.load.handler === "function",
102 "plugin has no load hook",
103 );
104 const result = await plugin.load.handler.call({ fs: NodeFs } as any, id);
105 assert(Predicate.isObject(result), "load hook returned non-object");
106 return result;
107 });
108
109 it.effect(
110 "inlines a .txt file as a JSON-encoded default export",
111 () =>
112 Effect.gen(function* () {
113 const fs = yield* FileSystem.FileSystem;
114 const path = yield* Path.Path;
115 const root = yield* fs.makeTempDirectory({
116 prefix: "alchemy-raw-load-",
117 });
118 const file = path.join(root, "hello.txt");
119 yield* fs.writeFileString(file, "Hello, World!\n");
120
121 const result = yield* load(file);
122
123 expect(result.code).toBe(`export default "Hello, World!\\n";`);
124 expect(result.moduleType).toBe("js");
125
126 yield* fs.remove(root, { recursive: true });
127 }),
128 { tags: ["unit", "local"] },
129 );
130
131 it.effect(
132 "inlines a .json file verbatim (no parsing)",
133 () =>
134 Effect.gen(function* () {
135 const fs = yield* FileSystem.FileSystem;
136 const path = yield* Path.Path;
137 const root = yield* fs.makeTempDirectory({
138 prefix: "alchemy-raw-json-",
139 });
140 const file = path.join(root, "data.json");
141 const raw = `{"a": 1, "b": "two"}`;
142 yield* fs.writeFileString(file, raw);
143
144 const result = yield* load(`${file}?raw`);
145
146 expect(result.code).toBe(`export default ${JSON.stringify(raw)};`);
147
148 yield* fs.remove(root, { recursive: true });
149 }),
150 { tags: ["unit", "local"] },
151 );
152
153 it.effect(
154 "strips additional query params before reading the file",
155 () =>
156 Effect.gen(function* () {
157 const fs = yield* FileSystem.FileSystem;
158 const path = yield* Path.Path;
159 const root = yield* fs.makeTempDirectory({ prefix: "alchemy-raw-q-" });
160 const file = path.join(root, "page.html");
161 yield* fs.writeFileString(file, "<h1>hi</h1>");
162
163 const result = yield* load(`${file}?raw&t=12345`);
164
165 expect(result.code).toBe(`export default "<h1>hi</h1>";`);
166
167 yield* fs.remove(root, { recursive: true });
168 }),
169 { tags: ["unit", "local"] },
170 );
171});
172
173describe("RAW_RE", { tags: ["unit", "local"] }, () => {
174 it("matches `?raw`", () => {
175 expect(RAW_RE.test("/foo/bar.txt?raw")).toBe(true);
176 });
177
178 it("matches `?raw&foo`", () => {
179 expect(RAW_RE.test("/foo/bar.txt?raw&foo")).toBe(true);
180 });
181
182 it("matches `?other&raw`", () => {
183 expect(RAW_RE.test("/foo/bar.txt?other&raw")).toBe(true);
184 });
185
186 it("does NOT match ids without ?raw", () => {
187 expect(RAW_RE.test("/foo/bar.txt")).toBe(false);
188 expect(RAW_RE.test("/foo/bar.txt?url")).toBe(false);
189 expect(RAW_RE.test("/foo/bar.txt?rawish")).toBe(false);
190 });
191});
192
193describe("splitFileAndPostfix", { tags: ["unit", "local"] }, () => {
194 it("splits at the first `?`", () => {
195 expect(splitFileAndPostfix("./foo.txt?raw")).toEqual(["./foo.txt", "?raw"]);
196 });
197
198 it("splits at the first `#`", () => {
199 expect(splitFileAndPostfix("./foo.txt#frag")).toEqual([
200 "./foo.txt",
201 "#frag",
202 ]);
203 });
204
205 it("returns empty postfix when no query/hash", () => {
206 expect(splitFileAndPostfix("./foo.txt")).toEqual(["./foo.txt", ""]);
207 });
208
209 it("splits at whichever of `?` / `#` comes first", () => {
210 expect(splitFileAndPostfix("./foo.txt#frag?raw")).toEqual([
211 "./foo.txt",
212 "#frag?raw",
213 ]);
214 });
215});