@jxsuite/compiler 0.0.1

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,161 @@
1
+ /**
2
+ * Prototype-resolver.js — Generic $prototype resolution at build time
3
+ *
4
+ * Mirrors the runtime's import-map → .class.json → $implementation → resolve() pipeline, but runs
5
+ * at compile time using filesystem APIs.
6
+ *
7
+ * Any state entry with a $prototype that maps to a .class.json via doc.imports gets resolved: the
8
+ * class is instantiated, .resolve() is called, and the state entry is replaced with the resolved
9
+ * value.
10
+ *
11
+ * This is the extension point for content sources (Markdown, CSV, etc.) — each provides a
12
+ * .class.json + JS implementation, and the compiler resolves them generically.
13
+ *
14
+ * @module prototype-resolver
15
+ */
16
+
17
+ import { readFileSync } from "node:fs";
18
+ import { dirname, resolve } from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { createRequire } from "node:module";
21
+
22
+ /**
23
+ * Prototype names handled elsewhere (builtins + legacy content system). These are skipped by the
24
+ * generic resolver.
25
+ */
26
+ const SKIP_PROTOTYPES = new Set([
27
+ "Function",
28
+ "LocalStorage",
29
+ "SessionStorage",
30
+ "Array",
31
+ "ContentCollection",
32
+ "ContentEntry",
33
+ ]);
34
+
35
+ /**
36
+ * Keys reserved by the Jx prototype system — stripped before passing config to the external class
37
+ * constructor. Mirrors runtime's EXTERNAL_RESERVED.
38
+ */
39
+ const RESERVED_KEYS = new Set([
40
+ "$prototype",
41
+ "$src",
42
+ "$export",
43
+ "timing",
44
+ "default",
45
+ "description",
46
+ ]);
47
+
48
+ /**
49
+ * Resolve all generic $prototype entries in a document's state at build time.
50
+ *
51
+ * For each state entry with a $prototype that: 1. Is not a builtin (Function, Array, etc.) 2. Maps
52
+ * to a .class.json path via doc.imports
53
+ *
54
+ * The resolver: - Reads the .class.json from disk - Follows $implementation to import the JS module
55
+ * - Instantiates the class with the config - Calls .resolve() and replaces the state entry with the
56
+ * result
57
+ *
58
+ * @param {any} doc - The page document (mutated in place)
59
+ * @param {{ sourcePath?: string }} route - Route info (sourcePath = absolute path to page .json)
60
+ * @param {string} projectRoot - Absolute path to the project root
61
+ */
62
+ export async function resolvePrototypes(doc, route, projectRoot) {
63
+ const imports = doc.imports ?? {};
64
+ const state = doc.state;
65
+ if (!state) return;
66
+
67
+ for (const [key, def] of Object.entries(state)) {
68
+ if (!def || typeof def !== "object" || !def.$prototype) continue;
69
+ if (SKIP_PROTOTYPES.has(def.$prototype)) continue;
70
+ // Only resolve timing: "compiler" (or unset timing with a .class.json mapping).
71
+ // Leave timing: "server" and timing: "client" for their respective pipelines.
72
+ if (def.timing && def.timing !== "compiler") continue;
73
+
74
+ // Look up in imports if no $src already set
75
+ if (!def.$src) {
76
+ const mapped = imports[def.$prototype];
77
+ if (!mapped) continue; // not in imports — leave for runtime
78
+ def.$src = mapped;
79
+ }
80
+
81
+ try {
82
+ const resolved = await resolveClassPrototype(def, route, projectRoot);
83
+ // Preserve timing metadata on the resolved value so compilePage() can strip it
84
+ if (def.timing && resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
85
+ resolved.timing = def.timing;
86
+ }
87
+ state[key] = resolved;
88
+ } catch (err) {
89
+ console.warn(
90
+ `prototype-resolver: failed to resolve "${key}" ($prototype: "${def.$prototype}"):`,
91
+ /** @type {Error} */ (err).message,
92
+ );
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Resolve a single $prototype entry via its .class.json.
99
+ *
100
+ * @param {Record<string, any>} def - The state entry definition
101
+ * @param {{ sourcePath?: string }} route
102
+ * @param {string} projectRoot
103
+ * @returns {Promise<any>} The resolved value
104
+ */
105
+ async function resolveClassPrototype(def, route, projectRoot) {
106
+ const src = def.$src;
107
+
108
+ // 1. Resolve .class.json path — handles both npm specifiers and relative paths
109
+ let classJsonPath;
110
+ if (src.startsWith("./") || src.startsWith("../")) {
111
+ // Relative path — resolve from page directory
112
+ classJsonPath = route.sourcePath
113
+ ? resolve(dirname(route.sourcePath), src)
114
+ : resolve(projectRoot, src);
115
+ } else {
116
+ // npm/bare specifier — use createRequire from the project root to walk node_modules
117
+ const require = createRequire(resolve(projectRoot, "package.json"));
118
+ classJsonPath = require.resolve(src);
119
+ }
120
+
121
+ // 2. Read and parse .class.json
122
+ const classJsonText = readFileSync(classJsonPath, "utf-8");
123
+ const classDef = JSON.parse(classJsonText);
124
+
125
+ if (!classDef.$implementation) {
126
+ throw new Error(`${src} has no $implementation field`);
127
+ }
128
+
129
+ // 3. Resolve $implementation relative to .class.json location
130
+ const classJsonURL = pathToFileURL(classJsonPath).href;
131
+ const implURL = new URL(classDef.$implementation, classJsonURL).href;
132
+
133
+ // 4. Import the module
134
+ const mod = await import(implURL);
135
+
136
+ // 5. Find the exported class
137
+ const exportName = def.$export ?? classDef.title ?? def.$prototype;
138
+ const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
139
+ if (!ExportedClass) {
140
+ throw new Error(`Module ${classDef.$implementation} does not export "${exportName}"`);
141
+ }
142
+
143
+ // 6. Build config — filter out reserved keys
144
+ /** @type {Record<string, any>} */
145
+ const config = {};
146
+ for (const [k, v] of Object.entries(def)) {
147
+ if (!RESERVED_KEYS.has(k)) config[k] = v;
148
+ }
149
+
150
+ // Auto-set basePath from the page's directory if the config has `src` but no `basePath`
151
+ if (config.src && !config.basePath && route.sourcePath) {
152
+ config.basePath = dirname(route.sourcePath);
153
+ }
154
+
155
+ // 7. Instantiate and resolve
156
+ const instance = new ExportedClass(config);
157
+ if (typeof instance.resolve === "function") {
158
+ return await instance.resolve();
159
+ }
160
+ return instance;
161
+ }