@jxsuite/compiler 0.26.2 → 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/dist/cli.js +232 -350
- package/dist/compiler.js +182 -293
- package/package.json +6 -8
- package/src/compiler.ts +24 -7
- package/src/shared.ts +14 -7
- package/src/site/content-loader.ts +104 -215
- 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 +8 -0
- package/src/site/image-transform.ts +157 -43
- package/src/site/pages-discovery.ts +71 -34
- package/src/site/prototype-resolver.ts +17 -9
- package/src/site/site-build.ts +98 -40
- package/src/site/site-loader.ts +19 -0
- package/src/targets/compile-client.ts +32 -7
- package/src/targets/compile-element.ts +20 -10
- package/src/targets/compile-image-endpoint.ts +138 -0
- package/src/targets/compile-server.ts +12 -3
- 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.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.
|
|
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.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(".
|
|
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, {
|
|
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 = {
|
|
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} */
|
|
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} */
|
|
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,58 +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
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
const id = (data.id as string | undefined) ?? (data.sku as string | undefined) ?? String(i);
|
|
212
|
-
return { id, data, body: null };
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function loadCSVEntries(filePath: string, schema?: ContentTypeSchema) {
|
|
217
|
-
const rows = parseCSV(readFileSync(filePath, "utf-8"));
|
|
218
|
-
return coerceCSVRows(rows, schema);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Fetch a remote CSV URL and return ContentLoaderEntry[]. */
|
|
222
|
-
async function loadRemoteCSV(url: string, schema?: ContentTypeSchema) {
|
|
223
|
-
const response = await fetch(url);
|
|
224
|
-
if (!response.ok) {
|
|
225
|
-
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 [];
|
|
226
68
|
}
|
|
227
|
-
return coerceCSVRows(parseCSV(await response.text()), schema);
|
|
228
69
|
}
|
|
229
70
|
|
|
230
71
|
// ─── Content Config ───────────────────────────────────────────────────────────
|
|
@@ -255,18 +96,26 @@ export function loadContentConfig(projectRoot: string, projectConfig?: ProjectCo
|
|
|
255
96
|
*
|
|
256
97
|
* @param {string} projectRoot - Project root directory
|
|
257
98
|
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
99
|
+
* @param {FormatRegistry} [registry] - Pre-built format registry (built from imports if omitted)
|
|
258
100
|
* @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
|
|
259
101
|
* ContentEntry
|
|
260
102
|
*/
|
|
261
|
-
export async function loadContentTypes(
|
|
103
|
+
export async function loadContentTypes(
|
|
104
|
+
projectRoot: string,
|
|
105
|
+
projectConfig?: ProjectConfig,
|
|
106
|
+
registry?: FormatRegistry,
|
|
107
|
+
) {
|
|
262
108
|
const result = loadContentConfig(projectRoot, projectConfig);
|
|
263
109
|
if (!result) return new Map();
|
|
264
110
|
|
|
265
111
|
const { config } = result;
|
|
266
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));
|
|
267
116
|
|
|
268
117
|
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
269
|
-
const entries = await loadContentType(name, contentTypeDef, projectRoot);
|
|
118
|
+
const entries = await loadContentType(name, contentTypeDef, projectRoot, formats);
|
|
270
119
|
contentTypes.set(name, entries);
|
|
271
120
|
}
|
|
272
121
|
|
|
@@ -293,14 +142,20 @@ export function getContentTypeElements(
|
|
|
293
142
|
}
|
|
294
143
|
|
|
295
144
|
/**
|
|
296
|
-
* Load a single content type by its definition.
|
|
145
|
+
* Load a single content type by its definition, dispatching through the format registry.
|
|
297
146
|
*
|
|
298
147
|
* @param {string} name - Content type name
|
|
299
148
|
* @param {ContentTypeDef} contentTypeDef - Content type definition from project.json
|
|
300
149
|
* @param {string} projectRoot - Absolute path to project root directory
|
|
150
|
+
* @param {FormatRegistry} registry - Format registry built from project imports
|
|
301
151
|
* @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
|
|
302
152
|
*/
|
|
303
|
-
async function loadContentType(
|
|
153
|
+
async function loadContentType(
|
|
154
|
+
name: string,
|
|
155
|
+
contentTypeDef: ContentTypeDef,
|
|
156
|
+
projectRoot: string,
|
|
157
|
+
registry: FormatRegistry,
|
|
158
|
+
) {
|
|
304
159
|
const source = contentTypeDef.source;
|
|
305
160
|
if (!source) return [];
|
|
306
161
|
const schema = contentTypeDef.schema;
|
|
@@ -316,10 +171,38 @@ async function loadContentType(name: string, contentTypeDef: ContentTypeDef, pro
|
|
|
316
171
|
}
|
|
317
172
|
: undefined;
|
|
318
173
|
|
|
319
|
-
//
|
|
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)
|
|
320
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
|
+
}
|
|
321
201
|
try {
|
|
322
|
-
const entries = await
|
|
202
|
+
const entries = (await entry.call("load", source, {
|
|
203
|
+
schema,
|
|
204
|
+
directiveOptions,
|
|
205
|
+
})) as ContentLoaderEntry[];
|
|
323
206
|
if (schema) validateEntries(entries, schema, name);
|
|
324
207
|
return entries;
|
|
325
208
|
} catch (e) {
|
|
@@ -328,38 +211,44 @@ async function loadContentType(name: string, contentTypeDef: ContentTypeDef, pro
|
|
|
328
211
|
}
|
|
329
212
|
}
|
|
330
213
|
|
|
331
|
-
// Resolve source path and discover files
|
|
332
214
|
const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
files =
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
files = readdirSync(dir, { recursive: true })
|
|
344
|
-
.filter((f) => String(f).endsWith(ext))
|
|
345
|
-
.map((f) => resolve(dir, String(f)));
|
|
346
|
-
} catch {
|
|
347
|
-
files = [];
|
|
348
|
-
}
|
|
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;
|
|
349
224
|
}
|
|
350
225
|
|
|
351
|
-
|
|
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
|
+
}
|
|
352
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[] = [];
|
|
353
245
|
for (const filePath of files) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
} else if (ext === ".csv") {
|
|
361
|
-
entries.push(...loadCSVEntries(filePath, schema));
|
|
362
|
-
}
|
|
246
|
+
entries.push(
|
|
247
|
+
...((await entry.call("load", filePath, {
|
|
248
|
+
schema,
|
|
249
|
+
directiveOptions,
|
|
250
|
+
})) as ContentLoaderEntry[]),
|
|
251
|
+
);
|
|
363
252
|
}
|
|
364
253
|
|
|
365
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);
|
|
@@ -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
|
+
}
|
package/src/site/head-merger.ts
CHANGED
|
@@ -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: {
|
|
40
|
+
attributes: {
|
|
41
|
+
name: "viewport",
|
|
42
|
+
content: "width=device-width, initial-scale=1",
|
|
43
|
+
},
|
|
41
44
|
},
|
|
42
45
|
];
|
|
43
46
|
|