@jxsuite/compiler 0.35.0 → 1.0.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 +182 -161
- package/dist/compiler.js +116 -112
- package/package.json +11 -7
- package/src/cli.ts +102 -9
- package/src/site/context-injection.ts +0 -6
- package/src/site/db-push.ts +178 -0
- package/src/site/format-host.ts +128 -13
- package/src/site/pages-discovery.ts +61 -49
- package/src/site/project-sections.ts +48 -0
- package/src/site/prototype-resolver.ts +55 -11
- package/src/site/schema-command.ts +242 -0
- package/src/site/site-build.ts +162 -26
- package/src/site/site-loader.ts +3 -5
- package/src/targets/compile-server.ts +83 -5
- package/src/site/content-loader.ts +0 -385
|
@@ -1,385 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Content-loader.js — Format-agnostic content type loader
|
|
3
|
-
*
|
|
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[].
|
|
8
|
-
*
|
|
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`.
|
|
12
|
-
*
|
|
13
|
-
* @module content-loader
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
17
|
-
import { errorMessage } from "@jxsuite/schema/parse";
|
|
18
|
-
import { basename, extname, resolve } from "node:path";
|
|
19
|
-
import { buildProjectFormatRegistry, unknownFormatError } from "./format-host.ts";
|
|
20
|
-
import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
21
|
-
import type {
|
|
22
|
-
ContentTypeDef,
|
|
23
|
-
ContentTypeSchema,
|
|
24
|
-
ContentTypeSchemaField,
|
|
25
|
-
JxMutableNode,
|
|
26
|
-
ProjectConfig,
|
|
27
|
-
} from "@jxsuite/schema/types";
|
|
28
|
-
import type { ContentLoaderEntry } from "@jxsuite/parser/types";
|
|
29
|
-
|
|
30
|
-
// ─── JSON loader (the single native built-in format) ─────────────────────────
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Load a JSON file into ContentEntry(s). If the file is an array, each element is an entry. If it's
|
|
34
|
-
* an object with an `id` field, it's a single entry.
|
|
35
|
-
*
|
|
36
|
-
* @param {string} filePath - Absolute path to .json file
|
|
37
|
-
* @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
|
|
38
|
-
*/
|
|
39
|
-
function loadJSONEntries(filePath: string) {
|
|
40
|
-
const raw = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
|
|
41
|
-
if (Array.isArray(raw)) {
|
|
42
|
-
return (raw as Record<string, unknown>[]).map((item: Record<string, unknown>, i: number) => ({
|
|
43
|
-
body: null,
|
|
44
|
-
data: item,
|
|
45
|
-
id: (item.id as string) ?? `${basename(filePath, ".json")}-${i}`,
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
48
|
-
// Single object file — filename is the id
|
|
49
|
-
const rawObj = raw as Record<string, unknown>;
|
|
50
|
-
return [
|
|
51
|
-
{
|
|
52
|
-
body: null,
|
|
53
|
-
data: rawObj,
|
|
54
|
-
id: (rawObj.id as string) ?? basename(filePath, ".json"),
|
|
55
|
-
},
|
|
56
|
-
];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Discover .json entry files for a source (single file or directory). */
|
|
60
|
-
function discoverJSONFiles(resolvedSource: string): string[] {
|
|
61
|
-
if (extname(resolvedSource)) {
|
|
62
|
-
return existsSync(resolvedSource) ? [resolvedSource] : [];
|
|
63
|
-
}
|
|
64
|
-
try {
|
|
65
|
-
return readdirSync(resolvedSource, { recursive: true })
|
|
66
|
-
.filter((f) => String(f).endsWith(".json"))
|
|
67
|
-
.map((f) => resolve(resolvedSource, String(f)));
|
|
68
|
-
} catch {
|
|
69
|
-
return [];
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ─── Content Config ───────────────────────────────────────────────────────────
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Load and parse content types config from project.json.
|
|
77
|
-
*
|
|
78
|
-
* @param {string} projectRoot - Project root directory
|
|
79
|
-
* @param {ProjectConfig} [projectConfig] - Already-loaded project config with `contentTypes` key
|
|
80
|
-
* @returns {{
|
|
81
|
-
* config: { contentTypes: Record<string, ContentTypeDef> };
|
|
82
|
-
* contentDir: string;
|
|
83
|
-
* } | null}
|
|
84
|
-
* Parsed config or null if no content dir
|
|
85
|
-
*/
|
|
86
|
-
export function loadContentConfig(projectRoot: string, projectConfig?: ProjectConfig) {
|
|
87
|
-
const contentDir = resolve(projectRoot, "content");
|
|
88
|
-
|
|
89
|
-
const config = { contentTypes: projectConfig?.contentTypes ?? {} };
|
|
90
|
-
|
|
91
|
-
return { config, contentDir };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ─── Content Type Loading ────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Load all content types defined in project.json.
|
|
98
|
-
*
|
|
99
|
-
* @param {string} projectRoot - Project root directory
|
|
100
|
-
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
101
|
-
* @param {FormatRegistry} [registry] - Pre-built format registry (built from imports if omitted)
|
|
102
|
-
* @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → array of
|
|
103
|
-
* ContentEntry
|
|
104
|
-
*/
|
|
105
|
-
export async function loadContentTypes(
|
|
106
|
-
projectRoot: string,
|
|
107
|
-
projectConfig?: ProjectConfig,
|
|
108
|
-
registry?: FormatRegistry,
|
|
109
|
-
) {
|
|
110
|
-
const result = loadContentConfig(projectRoot, projectConfig);
|
|
111
|
-
if (!result) {
|
|
112
|
-
return new Map<string, ContentLoaderEntry[]>();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const { config } = result;
|
|
116
|
-
const contentTypes = new Map<string, ContentLoaderEntry[]>();
|
|
117
|
-
if (Object.keys(config.contentTypes).length === 0) {
|
|
118
|
-
return contentTypes;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const formats = registry ?? (await buildProjectFormatRegistry(projectRoot, projectConfig));
|
|
122
|
-
|
|
123
|
-
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
124
|
-
const entries = await loadContentType(name, contentTypeDef, projectRoot, formats);
|
|
125
|
-
contentTypes.set(name, entries);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return contentTypes;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Get the $elements array for a specific content type, if defined in project.json contentTypes.
|
|
133
|
-
*
|
|
134
|
-
* @param {string} projectRoot - Project root directory
|
|
135
|
-
* @param {string} contentTypeName - Name of the content type
|
|
136
|
-
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
137
|
-
* @returns {(string | JxElement)[] | undefined}
|
|
138
|
-
*/
|
|
139
|
-
export function getContentTypeElements(
|
|
140
|
-
projectRoot: string,
|
|
141
|
-
contentTypeName: string,
|
|
142
|
-
projectConfig?: ProjectConfig,
|
|
143
|
-
) {
|
|
144
|
-
const result = loadContentConfig(projectRoot, projectConfig);
|
|
145
|
-
if (!result) {
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
const def = result.config.contentTypes?.[contentTypeName];
|
|
149
|
-
return def?.$elements;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Load a single content type by its definition, dispatching through the format registry.
|
|
154
|
-
*
|
|
155
|
-
* @param {string} name - Content type name
|
|
156
|
-
* @param {ContentTypeDef} contentTypeDef - Content type definition from project.json
|
|
157
|
-
* @param {string} projectRoot - Absolute path to project root directory
|
|
158
|
-
* @param {FormatRegistry} registry - Format registry built from project imports
|
|
159
|
-
* @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
|
|
160
|
-
*/
|
|
161
|
-
async function loadContentType(
|
|
162
|
-
name: string,
|
|
163
|
-
contentTypeDef: ContentTypeDef,
|
|
164
|
-
projectRoot: string,
|
|
165
|
-
registry: FormatRegistry,
|
|
166
|
-
) {
|
|
167
|
-
const { source } = contentTypeDef;
|
|
168
|
-
if (!source) {
|
|
169
|
-
return [];
|
|
170
|
-
}
|
|
171
|
-
const { schema } = contentTypeDef;
|
|
172
|
-
|
|
173
|
-
// Derive directive allowedNames from content type $elements (tag names from npm packages)
|
|
174
|
-
const directiveOptions = contentTypeDef.$elements?.length
|
|
175
|
-
? {
|
|
176
|
-
allowedNames: contentTypeDef.$elements
|
|
177
|
-
.filter(
|
|
178
|
-
(e) => typeof e === "string" || (typeof e === "object" && (e as JxMutableNode)?.$ref),
|
|
179
|
-
)
|
|
180
|
-
.map((e) => (typeof e === "string" ? e : (e as JxMutableNode).$ref)),
|
|
181
|
-
}
|
|
182
|
-
: undefined;
|
|
183
|
-
|
|
184
|
-
// Resolve the format class: explicit `format` (an import name) wins; "json" is native
|
|
185
|
-
const formatName = contentTypeDef.format;
|
|
186
|
-
let entry: FormatEntry | undefined;
|
|
187
|
-
if (formatName && formatName !== "json") {
|
|
188
|
-
entry = registry.byName(formatName);
|
|
189
|
-
if (!entry) {
|
|
190
|
-
throw new Error(
|
|
191
|
-
`Content type "${name}": format "${formatName}" is not an imported format class. ` +
|
|
192
|
-
`Add it to project.json imports, e.g. "${formatName}": "@jxsuite/parser/${formatName}.class.json".`,
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Remote URL source — requires an explicit remote-capable format (no implicit fallback)
|
|
198
|
-
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
199
|
-
if (!entry) {
|
|
200
|
-
throw new Error(
|
|
201
|
-
`Content type "${name}": remote sources require an explicit "format" naming an ` +
|
|
202
|
-
`imported format class with "remote": true (e.g. "Csv").`,
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
if (!entry.remote) {
|
|
206
|
-
throw new Error(
|
|
207
|
-
`Content type "${name}": format "${entry.name}" does not support remote sources ` +
|
|
208
|
-
`(its format block lacks "remote": true).`,
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
try {
|
|
212
|
-
const entries = (await entry.call("load", source, {
|
|
213
|
-
directiveOptions,
|
|
214
|
-
schema,
|
|
215
|
-
})) as ContentLoaderEntry[];
|
|
216
|
-
if (schema) {
|
|
217
|
-
validateEntries(entries, schema, name);
|
|
218
|
-
}
|
|
219
|
-
return entries;
|
|
220
|
-
} catch (error) {
|
|
221
|
-
console.warn(`Content type "${name}": ${errorMessage(error)}`);
|
|
222
|
-
return [];
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
|
|
227
|
-
const ext = extname(source).toLowerCase();
|
|
228
|
-
|
|
229
|
-
// JSON — the native built-in
|
|
230
|
-
if (formatName === "json" || (!entry && ext === ".json")) {
|
|
231
|
-
const files = discoverJSONFiles(resolvedSource);
|
|
232
|
-
const entries: ContentLoaderEntry[] = [];
|
|
233
|
-
for (const filePath of files) {
|
|
234
|
-
entries.push(...loadJSONEntries(filePath));
|
|
235
|
-
}
|
|
236
|
-
if (schema) {
|
|
237
|
-
validateEntries(entries, schema, name);
|
|
238
|
-
}
|
|
239
|
-
return entries;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Derive the format from the source extension when no explicit format is named
|
|
243
|
-
if (!entry && ext) {
|
|
244
|
-
entry = registry.byExtension(ext, "load");
|
|
245
|
-
}
|
|
246
|
-
if (!entry) {
|
|
247
|
-
throw unknownFormatError(
|
|
248
|
-
source,
|
|
249
|
-
ext || `content type "${name}" (directory sources need an explicit "format")`,
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Discover entry files via the class, then load each
|
|
254
|
-
const files = entry.capabilities.discover
|
|
255
|
-
? ((await entry.call("discover", source, {
|
|
256
|
-
baseDir: projectRoot,
|
|
257
|
-
})) as string[])
|
|
258
|
-
: [resolvedSource];
|
|
259
|
-
|
|
260
|
-
const entries: ContentLoaderEntry[] = [];
|
|
261
|
-
for (const filePath of files) {
|
|
262
|
-
entries.push(
|
|
263
|
-
...((await entry.call("load", filePath, {
|
|
264
|
-
directiveOptions,
|
|
265
|
-
schema,
|
|
266
|
-
})) as ContentLoaderEntry[]),
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Validate entries against schema if present
|
|
271
|
-
if (schema) {
|
|
272
|
-
validateEntries(entries, schema, name);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return entries;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ─── Schema Validation ────────────────────────────────────────────────────────
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Validate content entries against their content type schema. Logs warnings for missing required
|
|
282
|
-
* fields and type mismatches.
|
|
283
|
-
*
|
|
284
|
-
* @param {ContentLoaderEntry[]} entries - Array of ContentEntry
|
|
285
|
-
* @param {ContentTypeSchema} schema - JSON Schema for the content type
|
|
286
|
-
* @param {string} contentTypeName - For error messages
|
|
287
|
-
*/
|
|
288
|
-
function validateEntries(
|
|
289
|
-
entries: ContentLoaderEntry[],
|
|
290
|
-
schema: ContentTypeSchema,
|
|
291
|
-
contentTypeName: string,
|
|
292
|
-
) {
|
|
293
|
-
const required = schema.required ?? [];
|
|
294
|
-
const properties = schema.properties ?? {};
|
|
295
|
-
|
|
296
|
-
for (const entry of entries) {
|
|
297
|
-
// Check required fields
|
|
298
|
-
for (const field of required) {
|
|
299
|
-
if (!(field in entry.data) || entry.data[field] == null) {
|
|
300
|
-
console.warn(
|
|
301
|
-
`Content validation: "${contentTypeName}/${entry.id}" missing required field "${field}"`,
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Check types
|
|
307
|
-
for (const [field, def] of Object.entries(properties)) {
|
|
308
|
-
const value = entry.data[field];
|
|
309
|
-
if (value == null) {
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (def.type === "string" && typeof value !== "string") {
|
|
314
|
-
console.warn(
|
|
315
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
|
|
316
|
-
);
|
|
317
|
-
} else if (def.type === "number" && typeof value !== "number") {
|
|
318
|
-
console.warn(
|
|
319
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
|
|
320
|
-
);
|
|
321
|
-
} else if (def.type === "boolean" && typeof value !== "boolean") {
|
|
322
|
-
console.warn(
|
|
323
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
|
|
324
|
-
);
|
|
325
|
-
} else if (def.type === "array" && !Array.isArray(value)) {
|
|
326
|
-
console.warn(
|
|
327
|
-
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ─── Content Type Querying (re-exported from @jxsuite/parser/content) ────────
|
|
335
|
-
|
|
336
|
-
export { queryContentType, findEntry } from "@jxsuite/parser/content";
|
|
337
|
-
|
|
338
|
-
// ─── Content Type Reference Resolution ──────────────────────────────────────
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Resolve cross-content-type $ref references in entry data. For example, a blog post's `author:
|
|
342
|
-
* "jane-doe"` with a schema `$ref` to the authors content type gets resolved to the full author
|
|
343
|
-
* entry.
|
|
344
|
-
*
|
|
345
|
-
* @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types @param {{
|
|
346
|
-
* contentTypes: Record<string, ContentTypeDef> }} config - Content config
|
|
347
|
-
*/
|
|
348
|
-
export function resolveContentTypeRefs(
|
|
349
|
-
contentTypes: Map<string, ContentLoaderEntry[]>,
|
|
350
|
-
config: { contentTypes: Record<string, ContentTypeDef> },
|
|
351
|
-
) {
|
|
352
|
-
for (const [name, contentTypeDef] of Object.entries(config.contentTypes)) {
|
|
353
|
-
const { schema } = contentTypeDef;
|
|
354
|
-
if (!schema?.properties) {
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const entries = contentTypes.get(name);
|
|
359
|
-
if (!entries) {
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
for (const [field, rawDef] of Object.entries(schema.properties)) {
|
|
364
|
-
const def = rawDef as ContentTypeSchemaField;
|
|
365
|
-
if (!def.$ref?.startsWith("#/contentTypes/")) {
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
const refContentType = def.$ref.replace("#/contentTypes/", "");
|
|
369
|
-
const refEntries = contentTypes.get(refContentType);
|
|
370
|
-
if (!refEntries) {
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
for (const entry of entries) {
|
|
375
|
-
const refId = entry.data[field];
|
|
376
|
-
if (typeof refId === "string") {
|
|
377
|
-
const resolved = refEntries.find((e) => e.id === refId);
|
|
378
|
-
if (resolved) {
|
|
379
|
-
entry.data[field] = resolved;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|