@jxsuite/compiler 0.27.0 → 0.28.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,6 +20,7 @@
20
20
  "exports": {
21
21
  ".": "./src/compiler.ts",
22
22
  "./content-loader": "./src/site/content-loader.ts",
23
+ "./format-host": "./src/site/format-host.ts",
23
24
  "./context-injection": "./src/site/context-injection.ts",
24
25
  "./head-merger": "./src/site/head-merger.ts",
25
26
  "./layout-resolver": "./src/site/layout-resolver.ts",
@@ -41,13 +42,10 @@
41
42
  },
42
43
  "dependencies": {
43
44
  "@apidevtools/json-schema-ref-parser": "^15.3.5",
44
- "@jxsuite/parser": "^0.27.0",
45
- "@jxsuite/runtime": "^0.27.0",
46
- "@jxsuite/schema": "^0.27.0",
47
- "remark-gfm": "^4.0.1",
48
- "remark-stringify": "^11.0.0",
49
- "sharp": "^0.34.5",
50
- "unified": "^11.0.5"
45
+ "@jxsuite/parser": "^0.28.0",
46
+ "@jxsuite/runtime": "^0.28.0",
47
+ "@jxsuite/schema": "^0.28.0",
48
+ "sharp": "^0.34.5"
51
49
  },
52
50
  "devDependencies": {
53
51
  "@happy-dom/global-registrator": "^20.10.2",
package/src/compiler.ts CHANGED
@@ -74,11 +74,19 @@ export async function compile(sourcePath: string | any, opts: Record<string, unk
74
74
  let raw;
75
75
  if (typeof sourcePath === "string") {
76
76
  const source = readFileSync(sourcePath, "utf8");
77
- if (sourcePath.endsWith(".md")) {
78
- const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
79
- raw = transpileJxMarkdown(source);
80
- } else {
77
+ if (sourcePath.endsWith(".json")) {
81
78
  raw = JSON.parse(source);
79
+ } else {
80
+ const { extname } = await import("node:path");
81
+ const ext = extname(sourcePath).toLowerCase();
82
+ const registry = (opts as JxMutableNode)
83
+ .formats as import("@jxsuite/schema/format-registry").FormatRegistry;
84
+ const entry = registry?.byExtension?.(ext, "parse");
85
+ if (!entry) {
86
+ const { unknownFormatError } = await import("./site/format-host.ts");
87
+ throw unknownFormatError(sourcePath, ext);
88
+ }
89
+ raw = await entry.call("parse", source);
82
90
  }
83
91
  } else {
84
92
  raw = sourcePath;
@@ -97,7 +105,12 @@ export async function compile(sourcePath: string | any, opts: Record<string, unk
97
105
 
98
106
  // Route 1: Fully static → plain HTML/CSS
99
107
  if (!isDynamic(raw)) {
100
- return compileStaticPage(raw, { title, reactivitySrc, litHtmlSrc, projectStyle });
108
+ return compileStaticPage(raw, {
109
+ title,
110
+ reactivitySrc,
111
+ litHtmlSrc,
112
+ projectStyle,
113
+ });
101
114
  }
102
115
 
103
116
  // Route 2: Custom element tagName (contains hyphen) → lit-html web component
@@ -105,7 +118,11 @@ export async function compile(sourcePath: string | any, opts: Record<string, unk
105
118
  const tagName = raw.tagName;
106
119
  const className = tagNameToClassName(tagName);
107
120
  const moduleContent = emitElementModule(raw, className, []);
108
- const moduleFile = { path: `${tagName}.js`, content: moduleContent, tagName };
121
+ const moduleFile = {
122
+ path: `${tagName}.js`,
123
+ content: moduleContent,
124
+ tagName,
125
+ };
109
126
  const styleBlock = compileStyles(raw, raw.$media ?? {});
110
127
 
111
128
  const html = `<!DOCTYPE html>
@@ -162,7 +179,7 @@ export async function runCli(src: string, out?: string) {
162
179
  }
163
180
  if (server && out) {
164
181
  const serverOut = out.replace(/(\.[^.]+)?$/, "-server.js");
165
- writeFileSync(serverOut, /** @type {string} */ (server), "utf8");
182
+ writeFileSync(serverOut, /** @type {string} */ server, "utf8");
166
183
  console.error(`Server handler written to ${serverOut}`);
167
184
  }
168
185
  }
package/src/shared.ts CHANGED
@@ -115,7 +115,7 @@ export function isDynamic(def: JxElement | JxMutableNode | string) {
115
115
  continue;
116
116
  if (typeof d !== "object" || d === null || Array.isArray(d)) return true;
117
117
  if ((d as JxPrototypeDef).$prototype) return true;
118
- if ("default" in /** @type {object} */ (d)) return true;
118
+ if ("default" in /** @type {object} */ d) return true;
119
119
  if (isSchemaOnly(d)) continue;
120
120
  return true;
121
121
  }
@@ -564,7 +564,7 @@ export function compileStyles(
564
564
  : key;
565
565
  const mProps = toCSSText(val);
566
566
  if (mProps) rules.push(`${atRule} { ${selector} { ${mProps} } }`);
567
- for (const [sel, sub] of Object.entries(/** @type {Record<string, unknown>} */ (val))) {
567
+ for (const [sel, sub] of Object.entries(/** @type {Record<string, unknown>} */ val)) {
568
568
  if (sub === null || typeof sub !== "object" || Array.isArray(sub)) continue;
569
569
  if (sel.startsWith("@")) continue;
570
570
  const resolved = sel.startsWith("&")
@@ -663,7 +663,7 @@ function emitNestedElement(
663
663
  : key;
664
664
  const mProps = toCSSText(val);
665
665
  if (mProps) rules.push(`${atRule} { ${selector} { ${mProps} } }`);
666
- for (const [sel, sub] of Object.entries(/** @type {Record<string, unknown>} */ (val))) {
666
+ for (const [sel, sub] of Object.entries(/** @type {Record<string, unknown>} */ val)) {
667
667
  if (sub === null || typeof sub !== "object" || Array.isArray(sub)) continue;
668
668
  if (sel.startsWith("@")) continue;
669
669
  const resolved = sel.startsWith("&")
@@ -744,7 +744,7 @@ export function collectStyles(
744
744
  const atBaseProps = toCSSText(val);
745
745
  if (atBaseProps) rules.push(`${atRule} { ${selector} { ${atBaseProps} } }`);
746
746
  for (const [sel, nestedRules] of Object.entries(
747
- /** @type {Record<string, unknown>} */ (val),
747
+ /** @type {Record<string, unknown>} */ val,
748
748
  )) {
749
749
  if (nestedRules === null || typeof nestedRules !== "object" || Array.isArray(nestedRules))
750
750
  continue;
@@ -764,7 +764,7 @@ export function collectStyles(
764
764
  : `${selector} ${prop}`;
765
765
  emitNestedElement(
766
766
  resolved,
767
- /** @type {Record<string, unknown>} */ (val),
767
+ /** @type {Record<string, unknown>} */ val,
768
768
  rules,
769
769
  mediaQueries,
770
770
  );
@@ -889,7 +889,11 @@ function _walkServerEntries(
889
889
  entry.$export &&
890
890
  !entry.$prototype
891
891
  ) {
892
- entries.set(entry.$export, { key, exportName: entry.$export, src: entry.$src });
892
+ entries.set(entry.$export, {
893
+ key,
894
+ exportName: entry.$export,
895
+ src: entry.$src,
896
+ });
893
897
  }
894
898
  }
895
899
  }
@@ -995,7 +999,10 @@ export function preRenderComponentHtml(
995
999
  !Array.isArray(existing) &&
996
1000
  "default" in existing
997
1001
  ) {
998
- stateDefs[key] = { .../** @type {JxStateObject} */ (existing), default: value };
1002
+ stateDefs[key] = {
1003
+ .../** @type {JxStateObject} */ existing,
1004
+ default: value,
1005
+ };
999
1006
  } else {
1000
1007
  stateDefs[key] = value as JxStateDefinition;
1001
1008
  }
@@ -1,150 +1,31 @@
1
1
  /**
2
- * Content-loader.js — Content type loader
2
+ * Content-loader.js — Format-agnostic content type loader
3
3
  *
4
- * Loads content types defined in project.json's `contentTypes` key. Supports Markdown (.md), JSON
5
- * (.json), and CSV (.csv) source files or remote URLs.
4
+ * Loads content types defined in project.json's `contentTypes` key. JSON sources are handled
5
+ * natively (Jx IS JSON); every other format dispatches through the format registry built from the
6
+ * project's imports map — the class's `discover` capability lists entry files and its `load`
7
+ * capability parses each into ContentLoaderEntry[].
6
8
  *
7
- * Phase 2 implementation of site-architecture spec §6.
9
+ * There are no implicit format defaults: a content type either names an imported format class via
10
+ * its `format` key, or its source extension must match a registered format. Remote http(s) sources
11
+ * require an explicit `format` whose class declares `format.remote: true`.
8
12
  *
9
13
  * @module content-loader
10
14
  */
11
15
 
12
16
  import { readFileSync, readdirSync, existsSync } from "node:fs";
13
17
  import { resolve, basename, extname } from "node:path";
18
+ import { buildProjectFormatRegistry, unknownFormatError } from "./format-host.ts";
19
+ import type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
14
20
  import type {
15
21
  ProjectConfig,
16
22
  ContentTypeDef,
17
23
  ContentTypeSchema,
18
24
  JxMutableNode,
19
25
  } from "@jxsuite/schema/types";
20
- import type { MarkdownFileResult, ContentLoaderEntry } from "@jxsuite/parser/types";
26
+ import type { ContentLoaderEntry } from "@jxsuite/parser/types";
21
27
 
22
- // ─── CSV Parser (minimal, spec-compliant) ─────────────────────────────────────
23
-
24
- /**
25
- * Parse a CSV string into an array of objects using the first row as headers. Handles quoted fields
26
- * with commas and newlines.
27
- *
28
- * @param {string} csv - Raw CSV text
29
- * @returns {Record<string, string>[]} Array of row objects
30
- */
31
- function parseCSV(csv: string) {
32
- const rows: Record<string, string>[] = [];
33
- let current = "";
34
- let inQuotes = false;
35
- const lines: string[] = [];
36
-
37
- // Split into rows respecting quoted newlines (preserve raw characters)
38
- for (let i = 0; i < csv.length; i++) {
39
- const ch = csv[i];
40
- if (ch === '"') {
41
- current += ch;
42
- if (inQuotes && csv[i + 1] === '"') {
43
- current += csv[i + 1];
44
- i++;
45
- } else {
46
- inQuotes = !inQuotes;
47
- }
48
- } else if ((ch === "\n" || (ch === "\r" && csv[i + 1] === "\n")) && !inQuotes) {
49
- lines.push(current);
50
- current = "";
51
- if (ch === "\r") i++;
52
- } else {
53
- current += ch;
54
- }
55
- }
56
- if (current.trim()) lines.push(current);
57
-
58
- if (lines.length === 0) return [];
59
-
60
- /** @param {string} line */
61
- const parseRow = (line: string) => {
62
- const fields: string[] = [];
63
- let field = "";
64
- let q = false;
65
- for (let i = 0; i < line.length; i++) {
66
- const ch = line[i];
67
- if (ch === '"') {
68
- if (q && line[i + 1] === '"') {
69
- field += '"';
70
- i++;
71
- } else {
72
- q = !q;
73
- }
74
- } else if (ch === "," && !q) {
75
- fields.push(field);
76
- field = "";
77
- } else {
78
- field += ch;
79
- }
80
- }
81
- fields.push(field);
82
- return fields;
83
- };
84
-
85
- const headers = parseRow(lines[0]);
86
- for (let i = 1; i < lines.length; i++) {
87
- const fields = parseRow(lines[i]);
88
- const obj: Record<string, string> = {};
89
- for (let j = 0; j < headers.length; j++) {
90
- obj[headers[j].trim()] = fields[j]?.trim() ?? "";
91
- }
92
- rows.push(obj);
93
- }
94
- return rows;
95
- }
96
-
97
- // ─── Markdown loader ──────────────────────────────────────────────────────────
98
-
99
- /**
100
- * @type {{
101
- * MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
102
- * } | null}
103
- */
104
- let _mdModule: {
105
- MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
106
- } | null = null;
107
-
108
- /**
109
- * Lazily import @jxsuite/parser for Markdown support. This avoids hard dependency — only loads when
110
- * MD content types exist.
111
- *
112
- * @returns {Promise<{
113
- * MarkdownFile: new (opts: Record<string, unknown>) => { resolve(): MarkdownFileResult };
114
- * }>}
115
- */
116
- async function getMarkdownModule() {
117
- if (!_mdModule) {
118
- _mdModule = (await import("@jxsuite/parser")) as unknown as NonNullable<typeof _mdModule>;
119
- }
120
- return _mdModule as NonNullable<typeof _mdModule>;
121
- }
122
-
123
- /**
124
- * Load a markdown file into a ContentEntry. If directiveOptions are provided, they control which
125
- * custom element directives are available in the markdown.
126
- *
127
- * @param {string} filePath - Absolute path to .md file
128
- * @param {unknown} [directiveOptions] - Options for the MarkdownDirective plugin
129
- * @returns {Promise<ContentLoaderEntry>} ContentEntry shape
130
- */
131
- async function loadMarkdownEntry(filePath: string, directiveOptions?: unknown) {
132
- const { MarkdownFile } = await getMarkdownModule();
133
- const file = new MarkdownFile({ src: filePath, directiveOptions });
134
- const result = file.resolve();
135
- const _meta: ContentLoaderEntry["_meta"] = {};
136
- if (result.$excerpt != null) _meta.excerpt = result.$excerpt;
137
- if (result.$toc != null) _meta.toc = result.$toc;
138
- if (result.$readingTime != null) _meta.readingTime = result.$readingTime;
139
- if (result.$wordCount != null) _meta.wordCount = result.$wordCount;
140
- return {
141
- id: result.slug,
142
- data: result.frontmatter,
143
- body: readFileSync(filePath, "utf-8"),
144
- $children: result.$children,
145
- _meta,
146
- };
147
- }
28
+ // ─── JSON loader (the single native built-in format) ─────────────────────────
148
29
 
149
30
  /**
150
31
  * Load a JSON file into ContentEntry(s). If the file is an array, each element is an entry. If it's
@@ -173,72 +54,18 @@ function loadJSONEntries(filePath: string) {
173
54
  ];
174
55
  }
175
56
 
176
- /**
177
- * Load a CSV file into ContentEntry(s).
178
- *
179
- * @param {string} filePath - Absolute path to .csv file
180
- * @param {ContentTypeSchema} [schema] - Content type schema (for type coercion)
181
- * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
182
- */
183
- /**
184
- * Coerce raw CSV row strings to typed values per the schema. - number: strips currency
185
- * symbols/commas, returns null for empty cells - boolean: "true" → true, everything else → false -
186
- * string: trimmed (already done by parseCSV, but defensively applied)
187
- */
188
- function coerceCSVRows(
189
- rows: Record<string, string>[],
190
- schema?: ContentTypeSchema,
191
- ): ContentLoaderEntry[] {
192
- return rows.map((row, i) => {
193
- const data: Record<string, unknown> = { ...row };
194
- if (schema?.properties) {
195
- for (const [key, def] of Object.entries(schema.properties)) {
196
- if (!(key in data)) continue;
197
- if (def.type === "number") {
198
- const raw = String(data[key] ?? "").trim();
199
- if (raw === "") {
200
- data[key] = null;
201
- } else {
202
- const cleaned = raw.replace(/[$€£¥,\s]/g, "");
203
- const n = Number(cleaned);
204
- data[key] = isNaN(n) ? null : n;
205
- }
206
- } else if (def.type === "boolean") {
207
- data[key] = data[key] === "true";
208
- } else if (def.type === "array") {
209
- const raw = String(data[key] ?? "").trim();
210
- data[key] =
211
- raw === ""
212
- ? []
213
- : raw
214
- .split(",")
215
- .map((s) => s.trim())
216
- .filter(Boolean);
217
- }
218
- }
219
- }
220
- const id =
221
- (data.id as string | undefined) ??
222
- (data.sku as string | undefined) ??
223
- (data.slug as string | undefined) ??
224
- (data.Slug as string | undefined) ??
225
- String(i);
226
- return { id, data, body: null };
227
- });
228
- }
229
-
230
- function loadCSVEntries(filePath: string, schema?: ContentTypeSchema) {
231
- const rows = parseCSV(readFileSync(filePath, "utf-8"));
232
- return coerceCSVRows(rows, schema);
233
- }
234
-
235
- /** Fetch a remote CSV URL and return ContentLoaderEntry[]. */
236
- async function loadRemoteCSV(url: string, schema?: ContentTypeSchema) {
237
- const response = await fetch(url);
238
- if (!response.ok) {
239
- throw new Error(`Failed to fetch CSV from ${url}: ${response.status} ${response.statusText}`);
57
+ /** Discover .json entry files for a source (single file or directory). */
58
+ function discoverJSONFiles(resolvedSource: string): string[] {
59
+ if (extname(resolvedSource)) {
60
+ return existsSync(resolvedSource) ? [resolvedSource] : [];
61
+ }
62
+ try {
63
+ return readdirSync(resolvedSource, { recursive: true })
64
+ .filter((f) => String(f).endsWith(".json"))
65
+ .map((f) => resolve(resolvedSource, String(f)));
66
+ } catch {
67
+ return [];
240
68
  }
241
- return coerceCSVRows(parseCSV(await response.text()), schema);
242
69
  }
243
70
 
244
71
  // ─── Content Config ───────────────────────────────────────────────────────────
@@ -269,18 +96,26 @@ export function loadContentConfig(projectRoot: string, projectConfig?: ProjectCo
269
96
  *
270
97
  * @param {string} projectRoot - Project root directory
271
98
  * @param {ProjectConfig} [projectConfig] - Already-loaded project config
99
+ * @param {FormatRegistry} [registry] - Pre-built format registry (built from imports if omitted)
272
100
  * @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
273
101
  * ContentEntry
274
102
  */
275
- export async function loadContentTypes(projectRoot: string, projectConfig?: ProjectConfig) {
103
+ export async function loadContentTypes(
104
+ projectRoot: string,
105
+ projectConfig?: ProjectConfig,
106
+ registry?: FormatRegistry,
107
+ ) {
276
108
  const result = loadContentConfig(projectRoot, projectConfig);
277
109
  if (!result) return new Map();
278
110
 
279
111
  const { config } = result;
280
112
  const contentTypes: Map<string, ContentLoaderEntry[]> = new Map();
113
+ if (Object.keys(config.contentTypes).length === 0) return contentTypes;
114
+
115
+ const formats = registry ?? (await buildProjectFormatRegistry(projectRoot, projectConfig));
281
116
 
282
117
  for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
283
- const entries = await loadContentType(name, contentTypeDef, projectRoot);
118
+ const entries = await loadContentType(name, contentTypeDef, projectRoot, formats);
284
119
  contentTypes.set(name, entries);
285
120
  }
286
121
 
@@ -307,14 +142,20 @@ export function getContentTypeElements(
307
142
  }
308
143
 
309
144
  /**
310
- * Load a single content type by its definition.
145
+ * Load a single content type by its definition, dispatching through the format registry.
311
146
  *
312
147
  * @param {string} name - Content type name
313
148
  * @param {ContentTypeDef} contentTypeDef - Content type definition from project.json
314
149
  * @param {string} projectRoot - Absolute path to project root directory
150
+ * @param {FormatRegistry} registry - Format registry built from project imports
315
151
  * @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
316
152
  */
317
- async function loadContentType(name: string, contentTypeDef: ContentTypeDef, projectRoot: string) {
153
+ async function loadContentType(
154
+ name: string,
155
+ contentTypeDef: ContentTypeDef,
156
+ projectRoot: string,
157
+ registry: FormatRegistry,
158
+ ) {
318
159
  const source = contentTypeDef.source;
319
160
  if (!source) return [];
320
161
  const schema = contentTypeDef.schema;
@@ -330,10 +171,38 @@ async function loadContentType(name: string, contentTypeDef: ContentTypeDef, pro
330
171
  }
331
172
  : undefined;
332
173
 
333
- // Remote URL source (e.g. Google Sheets CSV export)
174
+ // Resolve the format class: explicit `format` (an import name) wins; "json" is native
175
+ const formatName = contentTypeDef.format;
176
+ let entry: FormatEntry | undefined;
177
+ if (formatName && formatName !== "json") {
178
+ entry = registry.byName(formatName);
179
+ if (!entry) {
180
+ throw new Error(
181
+ `Content type "${name}": format "${formatName}" is not an imported format class. ` +
182
+ `Add it to project.json imports, e.g. "${formatName}": "@jxsuite/parser/${formatName}.class.json".`,
183
+ );
184
+ }
185
+ }
186
+
187
+ // Remote URL source — requires an explicit remote-capable format (no implicit fallback)
334
188
  if (source.startsWith("http://") || source.startsWith("https://")) {
189
+ if (!entry) {
190
+ throw new Error(
191
+ `Content type "${name}": remote sources require an explicit "format" naming an ` +
192
+ `imported format class with "remote": true (e.g. "Csv").`,
193
+ );
194
+ }
195
+ if (!entry.remote) {
196
+ throw new Error(
197
+ `Content type "${name}": format "${entry.name}" does not support remote sources ` +
198
+ `(its format block lacks "remote": true).`,
199
+ );
200
+ }
335
201
  try {
336
- const entries = await loadRemoteCSV(source, schema);
202
+ const entries = (await entry.call("load", source, {
203
+ schema,
204
+ directiveOptions,
205
+ })) as ContentLoaderEntry[];
337
206
  if (schema) validateEntries(entries, schema, name);
338
207
  return entries;
339
208
  } catch (e) {
@@ -342,38 +211,44 @@ async function loadContentType(name: string, contentTypeDef: ContentTypeDef, pro
342
211
  }
343
212
  }
344
213
 
345
- // Resolve source path and discover files
346
214
  const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
347
- /** @type {string[]} */
348
- let files: string[];
349
-
350
- if (extname(source)) {
351
- files = existsSync(resolvedSource) ? [resolvedSource] : [];
352
- } else {
353
- const format = contentTypeDef.format || "md";
354
- const ext = `.${format}`;
355
- const dir = resolvedSource.endsWith("/") ? resolvedSource : resolvedSource + "/";
356
- try {
357
- files = readdirSync(dir, { recursive: true })
358
- .filter((f) => String(f).endsWith(ext))
359
- .map((f) => resolve(dir, String(f)));
360
- } catch {
361
- files = [];
362
- }
215
+ const ext = extname(source).toLowerCase();
216
+
217
+ // JSON — the native built-in
218
+ if (formatName === "json" || (!entry && ext === ".json")) {
219
+ const files = discoverJSONFiles(resolvedSource);
220
+ const entries: ContentLoaderEntry[] = [];
221
+ for (const filePath of files) entries.push(...loadJSONEntries(filePath));
222
+ if (schema) validateEntries(entries, schema, name);
223
+ return entries;
363
224
  }
364
225
 
365
- const entries: ContentLoaderEntry[] = [];
226
+ // Derive the format from the source extension when no explicit format is named
227
+ if (!entry && ext) {
228
+ entry = registry.byExtension(ext, "load");
229
+ }
230
+ if (!entry) {
231
+ throw unknownFormatError(
232
+ source,
233
+ ext || `content type "${name}" (directory sources need an explicit "format")`,
234
+ );
235
+ }
366
236
 
237
+ // Discover entry files via the class, then load each
238
+ const files = entry.capabilities.discover
239
+ ? ((await entry.call("discover", source, {
240
+ baseDir: projectRoot,
241
+ })) as string[])
242
+ : [resolvedSource];
243
+
244
+ const entries: ContentLoaderEntry[] = [];
367
245
  for (const filePath of files) {
368
- const ext = extname(filePath).toLowerCase();
369
-
370
- if (ext === ".md") {
371
- entries.push(await loadMarkdownEntry(filePath, directiveOptions));
372
- } else if (ext === ".json") {
373
- entries.push(...loadJSONEntries(filePath));
374
- } else if (ext === ".csv") {
375
- entries.push(...loadCSVEntries(filePath, schema));
376
- }
246
+ entries.push(
247
+ ...((await entry.call("load", filePath, {
248
+ schema,
249
+ directiveOptions,
250
+ })) as ContentLoaderEntry[]),
251
+ );
377
252
  }
378
253
 
379
254
  // Validate entries against schema if present
@@ -98,7 +98,7 @@ export function injectContext(
98
98
  ...(doc.$elements as (string | JxElement)[]),
99
99
  ]) {
100
100
  const key =
101
- typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ (entry)?.$ref;
101
+ typeof entry === "string" ? entry : /** @type {{ $ref?: string }} */ entry?.$ref;
102
102
  if (key && !seen.has(key)) {
103
103
  seen.add(key);
104
104
  merged.push(entry);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Format-host — node-side FormatHostIO and project registry construction
3
+ *
4
+ * Provides the filesystem/module I/O the shared format registry needs to run inside the compiler
5
+ * (and dev server). Format classes are auto-discovered from the project-level imports map; see
6
+ * specs/extensions.md.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, resolve, isAbsolute, sep } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ import { createRequire } from "node:module";
13
+ import {
14
+ buildFormatRegistry,
15
+ type FormatRegistry,
16
+ type FormatHostIO,
17
+ } from "@jxsuite/schema/format-registry";
18
+ import type { ProjectConfig } from "@jxsuite/schema/types";
19
+
20
+ export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
21
+
22
+ /**
23
+ * Import a class $implementation module by absolute path, tolerating source-vs-built layouts. A
24
+ * `.class.json` typically declares `"$implementation": "./markdown.js"` next to its TypeScript
25
+ * source: under Bun the sibling `.ts` resolves directly, while under node the built module lives in
26
+ * the package's `dist/` directory. Tries, in order: the literal path, src/ → dist/, .js → .ts.
27
+ *
28
+ * @param {string} path - Absolute path to the implementation module
29
+ * @returns {Promise<Record<string, unknown>>}
30
+ */
31
+ export async function importImplementation(path: string): Promise<Record<string, unknown>> {
32
+ const candidates = [path];
33
+ const srcSeg = `${sep}src${sep}`;
34
+ const distSeg = `${sep}dist${sep}`;
35
+ if (path.includes(srcSeg)) candidates.push(path.replace(srcSeg, distSeg));
36
+ if (path.endsWith(".js")) candidates.push(path.slice(0, -3) + ".ts");
37
+
38
+ let lastError: unknown;
39
+ for (const candidate of candidates) {
40
+ try {
41
+ return await import(pathToFileURL(candidate).href);
42
+ } catch (e) {
43
+ lastError = e;
44
+ }
45
+ }
46
+ throw lastError;
47
+ }
48
+
49
+ /** Create a node FormatHostIO rooted at a project (bare specifiers resolve via node_modules). */
50
+ export function createNodeFormatIO(projectRoot: string): FormatHostIO {
51
+ return {
52
+ loadJson: async (path) => JSON.parse(readFileSync(path, "utf-8")),
53
+ importModule: (path) => importImplementation(path),
54
+ resolvePath: (base, ref) => {
55
+ if (ref.startsWith("./") || ref.startsWith("../")) {
56
+ return resolve(dirname(base), ref);
57
+ }
58
+ if (isAbsolute(ref)) return ref;
59
+ const require = createRequire(resolve(projectRoot, "package.json"));
60
+ return require.resolve(ref);
61
+ },
62
+ };
63
+ }
64
+
65
+ /** Build the format registry for a project from its project.json imports map. */
66
+ export async function buildProjectFormatRegistry(
67
+ projectRoot: string,
68
+ projectConfig?: ProjectConfig,
69
+ ): Promise<FormatRegistry> {
70
+ return buildFormatRegistry(
71
+ projectConfig?.imports as Record<string, string> | undefined,
72
+ createNodeFormatIO(projectRoot),
73
+ resolve(projectRoot, "project.json"),
74
+ );
75
+ }
76
+
77
+ /** Error message for an unregistered non-JSON extension, naming the fix. */
78
+ export function unknownFormatError(path: string, ext: string): Error {
79
+ return new Error(
80
+ `No format class imported for "${ext}" (${path}). ` +
81
+ `Add a format class to project.json imports, e.g. ` +
82
+ `"Markdown": "@jxsuite/parser/Markdown.class.json".`,
83
+ );
84
+ }
@@ -37,7 +37,10 @@ export function mergeHead(
37
37
  { tagName: "meta", attributes: { charset: context.charset ?? "utf-8" } },
38
38
  {
39
39
  tagName: "meta",
40
- attributes: { name: "viewport", content: "width=device-width, initial-scale=1" },
40
+ attributes: {
41
+ name: "viewport",
42
+ content: "width=device-width, initial-scale=1",
43
+ },
41
44
  },
42
45
  ];
43
46