@jxsuite/compiler 0.34.1 → 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.
@@ -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
- }