1export default function loadMarko({ source, file }: Pick<incremental.LoaderInput, "source" | "file">) {
2 // transform markodown
3 if (file.endsWith(".mdo")) {
4 let transform;
5 try {
6 transform = markodown.transform({
7 source,
8 selfImport: `./${path.basename(file)}`,
9 });
10 } catch (err) {
11 throw new Error(`Failed ${file}: Markodown Compiler Panic`, {
12 cause: err,
13 });
14 }
15 if (!transform.success) {
16 throw compileError(
17 transform.errors.map(({ message, line, column, notes }) =>
18 locate(
19 new Error(message),
20 file,
21 { line, col: column },
22 notes.map((note) => ({
23 text: note.message ?? "",
24 file,
25 line: note.line,
26 col: note.column,
27 width: note.width,
28 })),
29 )
30 ),
31 );
32 }
33 source = transform.text;
34 }
35
36 const resolved = scanMarkoClientImports(source, file);
37 let compiled;
38 try {
39 compiled = marko.compileSync(resolved.src, file, { sourceMaps: true });
40 } catch (error) {
41 // the message is a code frame colored for a terminal, and `loc` is where it points
42 type Loc = { line: number; column: number };
43 type CompileError = { name: string; label?: string; loc?: { start: Loc; end?: Loc } };
44 const errors = (error as { errors?: CompileError[] }).errors ?? [error as CompileError];
45 if (!errors.every((each) => each.label !== undefined)) throw error;
46 throw compileError(errors.map(({ name, label, loc }) => {
47 const error = Object.assign(new Error(label), { name });
48 // what markodown wrote has no source map, so its lines are not the file's
49 if (!loc || file.endsWith(".mdo")) return locate(error, file, {});
50 const { start, end } = loc;
51 const width = end?.line === start.line ? end.column - start.column : undefined;
52 return locate(error, file, { line: start.line, col: start.column + 1, width });
53 }));
54 }
55 // workaround an esbuild bug?
56 (compiled.map as unknown as { sourceRoot: string }).sourceRoot = "/";
57 compiled.map.sources[0] = file;
58
59 return {
60 format: "js",
61 content: compiled.code
62 + "\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,"
63 + Buffer.from(JSON.stringify(compiled.map), "utf-8").toString("base64"),
64 data: {
65 scannedClientRefs: resolved.scannedClientRefs,
66 },
67 };
68}
69
70type Place = { line?: number; col?: number; width?: number };
71
72/** an error about a place in a source file, as `stack.describe` lays one out, without the compiler's own frames */
73function locate(
74 error: Error,
75 file: string,
76 place: Place,
77 notes: Array<Place & { text: string; file: string }> = [],
78): Error {
79 Object.assign(error, { file, ...place, notes });
80 error.stack = String(error);
81 return error;
82}
83
84function compileError(errors: Error[]): Error {
85 if (errors.length === 1) return errors[0]!;
86 const error = new AggregateError(errors, `${errors.length} errors`);
87 error.stack = String(error);
88 return error;
89}
90
91export function scanMarkoClientImports(src: string, filepath: string) {
92 // Clover Sitegen uses `client import` as a convenience shorthand
93 // and rewrites it to the runtime script include component.
94 const scannedClientRefs = new Set<string>();
95 let found = false;
96 src = src.replace(
97 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]*$/gm,
98 (_, rawRef) => {
99 found = true;
100 const ref = JSON.parse(`"${rawRef.slice(1, -1)}"`);
101 const resolved = path.resolve(path.dirname(filepath), ref);
102 if (!resolved.endsWith(".client.ts")) throw new Error("client import must take a .client.ts");
103 const id = getScriptId(resolved);
104 scannedClientRefs.add(id);
105 return `<CloverScriptInclude=${JSON.stringify(id)} />`;
106 },
107 );
108 if (found) {
109 src += "\nimport { CloverScriptInclude } from \"#sitegen\";\n";
110 }
111 return {
112 src,
113 scannedClientRefs: Array.from(scannedClientRefs),
114 };
115}
116
117export const projectSrc = path.resolve(import.meta.dirname, "../src");
118
119/** how pages and the client bundle name a script: its path within `src` */
120export function getScriptId(file: string) {
121 return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file).replaceAll("\\", "/");
122}
123
124import * as incremental from "@clo/lib/incremental";
125import * as markodown from "@clo/markodown";
126import * as marko from "@marko/compiler";
127import * as path from "node:path";