@jxsuite/compiler 0.27.0 → 0.28.2
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/dist/cli.js +141 -351
- package/dist/compiler.js +100 -304
- package/package.json +6 -8
- package/src/compiler.ts +25 -13
- package/src/shared.ts +14 -7
- package/src/site/content-loader.ts +104 -229
- package/src/site/context-injection.ts +1 -1
- package/src/site/format-host.ts +84 -0
- package/src/site/head-merger.ts +4 -1
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +167 -43
- package/src/site/pages-discovery.ts +66 -31
- package/src/site/prototype-resolver.ts +17 -9
- package/src/site/site-build.ts +101 -70
- package/src/site/site-loader.ts +11 -0
- package/src/targets/compile-client.ts +32 -7
- package/src/targets/compile-element.ts +20 -10
- package/src/targets/compile-server.ts +5 -46
- package/src/targets/compile-markdown.ts +0 -977
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.2",
|
|
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.
|
|
45
|
-
"@jxsuite/runtime": "^0.
|
|
46
|
-
"@jxsuite/schema": "^0.
|
|
47
|
-
"
|
|
48
|
-
"remark-stringify": "^11.0.0",
|
|
49
|
-
"sharp": "^0.34.5",
|
|
50
|
-
"unified": "^11.0.5"
|
|
45
|
+
"@jxsuite/parser": "^0.28.2",
|
|
46
|
+
"@jxsuite/runtime": "^0.28.2",
|
|
47
|
+
"@jxsuite/schema": "^0.28.2",
|
|
48
|
+
"sharp": "^0.34.5"
|
|
51
49
|
},
|
|
52
50
|
"devDependencies": {
|
|
53
51
|
"@happy-dom/global-registrator": "^20.10.2",
|
package/src/compiler.ts
CHANGED
|
@@ -22,11 +22,7 @@ import {
|
|
|
22
22
|
DEFAULT_REACTIVITY_SRC,
|
|
23
23
|
DEFAULT_LIT_HTML_SRC,
|
|
24
24
|
} from "./shared.ts";
|
|
25
|
-
import {
|
|
26
|
-
compileServer,
|
|
27
|
-
compileSiteServer,
|
|
28
|
-
compilePagesFunctions,
|
|
29
|
-
} from "./targets/compile-server.ts";
|
|
25
|
+
import { compileServer, compileSiteServer } from "./targets/compile-server.ts";
|
|
30
26
|
|
|
31
27
|
import {
|
|
32
28
|
compileElement,
|
|
@@ -42,7 +38,6 @@ export {
|
|
|
42
38
|
isDynamic,
|
|
43
39
|
compileServer,
|
|
44
40
|
compileSiteServer,
|
|
45
|
-
compilePagesFunctions,
|
|
46
41
|
compileElement,
|
|
47
42
|
compileElementPage,
|
|
48
43
|
compileClient,
|
|
@@ -74,11 +69,19 @@ export async function compile(sourcePath: string | any, opts: Record<string, unk
|
|
|
74
69
|
let raw;
|
|
75
70
|
if (typeof sourcePath === "string") {
|
|
76
71
|
const source = readFileSync(sourcePath, "utf8");
|
|
77
|
-
if (sourcePath.endsWith(".
|
|
78
|
-
const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
|
|
79
|
-
raw = transpileJxMarkdown(source);
|
|
80
|
-
} else {
|
|
72
|
+
if (sourcePath.endsWith(".json")) {
|
|
81
73
|
raw = JSON.parse(source);
|
|
74
|
+
} else {
|
|
75
|
+
const { extname } = await import("node:path");
|
|
76
|
+
const ext = extname(sourcePath).toLowerCase();
|
|
77
|
+
const registry = (opts as JxMutableNode)
|
|
78
|
+
.formats as import("@jxsuite/schema/format-registry").FormatRegistry;
|
|
79
|
+
const entry = registry?.byExtension?.(ext, "parse");
|
|
80
|
+
if (!entry) {
|
|
81
|
+
const { unknownFormatError } = await import("./site/format-host.ts");
|
|
82
|
+
throw unknownFormatError(sourcePath, ext);
|
|
83
|
+
}
|
|
84
|
+
raw = await entry.call("parse", source);
|
|
82
85
|
}
|
|
83
86
|
} else {
|
|
84
87
|
raw = sourcePath;
|
|
@@ -97,7 +100,12 @@ export async function compile(sourcePath: string | any, opts: Record<string, unk
|
|
|
97
100
|
|
|
98
101
|
// Route 1: Fully static → plain HTML/CSS
|
|
99
102
|
if (!isDynamic(raw)) {
|
|
100
|
-
return compileStaticPage(raw, {
|
|
103
|
+
return compileStaticPage(raw, {
|
|
104
|
+
title,
|
|
105
|
+
reactivitySrc,
|
|
106
|
+
litHtmlSrc,
|
|
107
|
+
projectStyle,
|
|
108
|
+
});
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
// Route 2: Custom element tagName (contains hyphen) → lit-html web component
|
|
@@ -105,7 +113,11 @@ export async function compile(sourcePath: string | any, opts: Record<string, unk
|
|
|
105
113
|
const tagName = raw.tagName;
|
|
106
114
|
const className = tagNameToClassName(tagName);
|
|
107
115
|
const moduleContent = emitElementModule(raw, className, []);
|
|
108
|
-
const moduleFile = {
|
|
116
|
+
const moduleFile = {
|
|
117
|
+
path: `${tagName}.js`,
|
|
118
|
+
content: moduleContent,
|
|
119
|
+
tagName,
|
|
120
|
+
};
|
|
109
121
|
const styleBlock = compileStyles(raw, raw.$media ?? {});
|
|
110
122
|
|
|
111
123
|
const html = `<!DOCTYPE html>
|
|
@@ -162,7 +174,7 @@ export async function runCli(src: string, out?: string) {
|
|
|
162
174
|
}
|
|
163
175
|
if (server && out) {
|
|
164
176
|
const serverOut = out.replace(/(\.[^.]+)?$/, "-server.js");
|
|
165
|
-
writeFileSync(serverOut, /** @type {string} */
|
|
177
|
+
writeFileSync(serverOut, /** @type {string} */ server, "utf8");
|
|
166
178
|
console.error(`Server handler written to ${serverOut}`);
|
|
167
179
|
}
|
|
168
180
|
}
|
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} */
|
|
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>} */
|
|
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>} */
|
|
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>} */
|
|
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>} */
|
|
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, {
|
|
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] = {
|
|
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 —
|
|
2
|
+
* Content-loader.js — Format-agnostic content type loader
|
|
3
3
|
*
|
|
4
|
-
* Loads content types defined in project.json's `contentTypes` key.
|
|
5
|
-
* (
|
|
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
|
-
*
|
|
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 {
|
|
26
|
+
import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
21
27
|
|
|
22
|
-
// ───
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (
|
|
351
|
-
files =
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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 }} */
|
|
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);
|