@jxsuite/parser 0.35.1 → 1.1.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/README.md +107 -66
- package/dist/content-loader.js +285 -0
- package/dist/content.js +2 -2
- package/dist/markdown.js +19 -5
- package/dist/serialize.js +2 -2
- package/jx-extension.json +17 -0
- package/package.json +19 -16
- package/schemas/document.fragment.schema.json +27 -0
- package/schemas/project.fragment.schema.json +59 -0
- package/src/Content.class.json +117 -0
- package/src/ContentCollection.class.json +3 -3
- package/src/ContentEntry.class.json +1 -1
- package/src/Csv.class.json +1 -1
- package/src/content-loader.ts +499 -0
- package/src/content.ts +6 -4
- package/src/html-to-jx.ts +8 -117
- package/src/markdown.ts +7 -0
- package/src/md-html.ts +5 -42
- package/src/md.ts +28 -2
- package/src/serialize.ts +1 -1
- package/src/transpile.ts +2 -2
- package/src/types.ts +15 -18
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-loader — format-agnostic loader for the `content` project section
|
|
3
|
+
*
|
|
4
|
+
* Implements the parser extension's `project` capabilities (specs/extensions.md §9): `Content` owns
|
|
5
|
+
* the project.json `content` section. JSON sources are handled natively (Jx IS JSON); every other
|
|
6
|
+
* format dispatches through the format registry view of the extension registry — the format class's
|
|
7
|
+
* `discover` capability lists entry files and its `load` capability parses each into
|
|
8
|
+
* ContentLoaderEntry[].
|
|
9
|
+
*
|
|
10
|
+
* There are no implicit format defaults: a content type either names a format class provided by an
|
|
11
|
+
* enabled extension via its `format` key, or its source extension must match a registered format.
|
|
12
|
+
* Remote http(s) sources require an explicit `format` whose class declares `format.remote: true`.
|
|
13
|
+
*
|
|
14
|
+
* Runs with `timing: ["compiler", "server"]` only (node `fs` on the code path, like
|
|
15
|
+
* Markdown.discover).
|
|
16
|
+
*
|
|
17
|
+
* @module @jxsuite/parser/content-loader
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
21
|
+
import { basename, extname, resolve } from "node:path";
|
|
22
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
23
|
+
import type { FormatEntry, FormatHostIO, FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
24
|
+
import type { ContentTypeSchema, JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
|
|
25
|
+
import type { ContentLoaderEntry, ContentTypeDef } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
export type { ContentEntry, ContentLoaderEntry, TocEntry } from "./types.ts";
|
|
28
|
+
|
|
29
|
+
// ─── Context and section shapes ──────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** The value of the project.json `content` section: content type name → definition. */
|
|
32
|
+
export type ContentSection = Record<string, ContentTypeDef>;
|
|
33
|
+
|
|
34
|
+
/** Host context passed to `Content.projectData` (specs/extensions.md §8). */
|
|
35
|
+
export interface ProjectDataContext {
|
|
36
|
+
projectConfig?: ProjectConfig;
|
|
37
|
+
/** Absolute project root directory. */
|
|
38
|
+
root: string;
|
|
39
|
+
/** The project's extension registry; format dispatch goes through its `formats` view. */
|
|
40
|
+
registry: ExtensionRegistry;
|
|
41
|
+
/** Injected host I/O (unused here — this loader is node-only by its declared timing). */
|
|
42
|
+
io?: FormatHostIO;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Host context passed to `Content.resolvePaths` (specs/extensions.md §8). */
|
|
46
|
+
export interface ResolvePathsContext {
|
|
47
|
+
/** The loaded section data, as returned by `Content.projectData`. */
|
|
48
|
+
data: Map<string, ContentLoaderEntry[]>;
|
|
49
|
+
projectConfig?: ProjectConfig;
|
|
50
|
+
/** Absolute project root directory. */
|
|
51
|
+
root: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The `$paths` value shape routed to this extension by its `contentType` discriminator. */
|
|
55
|
+
export interface ContentPathsSource {
|
|
56
|
+
contentType: string;
|
|
57
|
+
param?: string;
|
|
58
|
+
field?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── JSON loader (the single native built-in format) ─────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load a JSON file into ContentEntry(s). If the file is an array, each element is an entry. If it's
|
|
65
|
+
* an object with an `id` field, it's a single entry.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} filePath - Absolute path to .json file
|
|
68
|
+
* @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
|
|
69
|
+
*/
|
|
70
|
+
function loadJSONEntries(filePath: string): ContentLoaderEntry[] {
|
|
71
|
+
const raw = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
|
|
72
|
+
if (Array.isArray(raw)) {
|
|
73
|
+
return (raw as Record<string, unknown>[]).map((item: Record<string, unknown>, i: number) => ({
|
|
74
|
+
body: null,
|
|
75
|
+
data: item,
|
|
76
|
+
id: (item.id as string) ?? `${basename(filePath, ".json")}-${i}`,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
// Single object file — filename is the id
|
|
80
|
+
const rawObj = raw as Record<string, unknown>;
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
body: null,
|
|
84
|
+
data: rawObj,
|
|
85
|
+
id: (rawObj.id as string) ?? basename(filePath, ".json"),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Discover .json entry files for a source (single file or directory). */
|
|
91
|
+
function discoverJSONFiles(resolvedSource: string): string[] {
|
|
92
|
+
if (extname(resolvedSource)) {
|
|
93
|
+
return existsSync(resolvedSource) ? [resolvedSource] : [];
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return readdirSync(resolvedSource, { recursive: true })
|
|
97
|
+
.filter((f) => String(f).endsWith(".json"))
|
|
98
|
+
.map((f) => resolve(resolvedSource, String(f)));
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Content Config ───────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read the `content` section out of an already-loaded project config.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} projectRoot - Project root directory
|
|
110
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config with a `content` key
|
|
111
|
+
* @returns {{ config: { content: ContentSection }; contentDir: string }} Parsed section and the
|
|
112
|
+
* conventional content directory
|
|
113
|
+
*/
|
|
114
|
+
export function loadContentConfig(projectRoot: string, projectConfig?: ProjectConfig) {
|
|
115
|
+
const contentDir = resolve(projectRoot, "content");
|
|
116
|
+
|
|
117
|
+
const config = { content: (projectConfig?.content as ContentSection | undefined) ?? {} };
|
|
118
|
+
|
|
119
|
+
return { config, contentDir };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Content Type Loading ────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load every content type in a `content` section value.
|
|
126
|
+
*
|
|
127
|
+
* @param {ContentSection} section - The project.json `content` section value
|
|
128
|
+
* @param {string} root - Project root directory
|
|
129
|
+
* @param {FormatRegistry} formats - Format-dispatch view of the extension registry
|
|
130
|
+
* @returns {Promise<Map<string, ContentLoaderEntry[]>>} Map of content type name → entries
|
|
131
|
+
*/
|
|
132
|
+
export async function loadContentSection(
|
|
133
|
+
section: ContentSection,
|
|
134
|
+
root: string,
|
|
135
|
+
formats: FormatRegistry,
|
|
136
|
+
): Promise<Map<string, ContentLoaderEntry[]>> {
|
|
137
|
+
const contentTypes = new Map<string, ContentLoaderEntry[]>();
|
|
138
|
+
for (const [name, contentTypeDef] of Object.entries(section)) {
|
|
139
|
+
const entries = await loadContentType(name, contentTypeDef, root, formats);
|
|
140
|
+
contentTypes.set(name, entries);
|
|
141
|
+
}
|
|
142
|
+
return contentTypes;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get the $elements array for a specific content type, if defined in the project's `content`
|
|
147
|
+
* section.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} projectRoot - Project root directory
|
|
150
|
+
* @param {string} contentTypeName - Name of the content type
|
|
151
|
+
* @param {ProjectConfig} [projectConfig] - Already-loaded project config
|
|
152
|
+
* @returns {(string | { $ref: string })[] | undefined}
|
|
153
|
+
*/
|
|
154
|
+
export function getContentTypeElements(
|
|
155
|
+
projectRoot: string,
|
|
156
|
+
contentTypeName: string,
|
|
157
|
+
projectConfig?: ProjectConfig,
|
|
158
|
+
) {
|
|
159
|
+
const { config } = loadContentConfig(projectRoot, projectConfig);
|
|
160
|
+
const def = config.content[contentTypeName];
|
|
161
|
+
return def?.$elements;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Load a single content type by its definition, dispatching through the format registry.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} name - Content type name
|
|
168
|
+
* @param {ContentTypeDef} contentTypeDef - Content type definition from the `content` section
|
|
169
|
+
* @param {string} projectRoot - Absolute path to project root directory
|
|
170
|
+
* @param {FormatRegistry} registry - Format-dispatch view of the extension registry
|
|
171
|
+
* @returns {Promise<ContentLoaderEntry[]>} Array of ContentEntry
|
|
172
|
+
*/
|
|
173
|
+
async function loadContentType(
|
|
174
|
+
name: string,
|
|
175
|
+
contentTypeDef: ContentTypeDef,
|
|
176
|
+
projectRoot: string,
|
|
177
|
+
registry: FormatRegistry,
|
|
178
|
+
) {
|
|
179
|
+
const { source } = contentTypeDef;
|
|
180
|
+
if (!source) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const schema = contentTypeDef.schema as ContentTypeSchema | undefined;
|
|
184
|
+
|
|
185
|
+
// Derive directive allowedNames from content type $elements (tag names from npm packages)
|
|
186
|
+
const directiveOptions = contentTypeDef.$elements?.length
|
|
187
|
+
? {
|
|
188
|
+
allowedNames: contentTypeDef.$elements
|
|
189
|
+
.filter(
|
|
190
|
+
(e) => typeof e === "string" || (typeof e === "object" && (e as JxMutableNode)?.$ref),
|
|
191
|
+
)
|
|
192
|
+
.map((e) => (typeof e === "string" ? e : (e as JxMutableNode).$ref)),
|
|
193
|
+
}
|
|
194
|
+
: undefined;
|
|
195
|
+
|
|
196
|
+
// Resolve the format class: explicit `format` (an extension class name) wins; "json" is native
|
|
197
|
+
const formatName = contentTypeDef.format;
|
|
198
|
+
let entry: FormatEntry | undefined;
|
|
199
|
+
if (formatName && formatName !== "json") {
|
|
200
|
+
entry = registry.byName(formatName);
|
|
201
|
+
if (!entry) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Content type "${name}": format "${formatName}" is not a registered format class. ` +
|
|
204
|
+
`Enable an extension providing it in project.json "extensions", e.g. "@jxsuite/parser".`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Remote URL source — requires an explicit remote-capable format (no implicit fallback)
|
|
210
|
+
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
211
|
+
if (!entry) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Content type "${name}": remote sources require an explicit "format" naming a ` +
|
|
214
|
+
`format class with "remote": true (e.g. "Csv").`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (!entry.remote) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Content type "${name}": format "${entry.name}" does not support remote sources ` +
|
|
220
|
+
`(its format block lacks "remote": true).`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const entries = (await entry.call("load", source, {
|
|
225
|
+
directiveOptions,
|
|
226
|
+
schema,
|
|
227
|
+
})) as ContentLoaderEntry[];
|
|
228
|
+
if (schema) {
|
|
229
|
+
validateEntries(entries, schema, name);
|
|
230
|
+
}
|
|
231
|
+
return entries;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.warn(`Content type "${name}": ${errorMessage(error)}`);
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
|
|
239
|
+
const ext = extname(source).toLowerCase();
|
|
240
|
+
|
|
241
|
+
// JSON — the native built-in
|
|
242
|
+
if (formatName === "json" || (!entry && ext === ".json")) {
|
|
243
|
+
const files = discoverJSONFiles(resolvedSource);
|
|
244
|
+
const entries: ContentLoaderEntry[] = [];
|
|
245
|
+
for (const filePath of files) {
|
|
246
|
+
entries.push(...loadJSONEntries(filePath));
|
|
247
|
+
}
|
|
248
|
+
if (schema) {
|
|
249
|
+
validateEntries(entries, schema, name);
|
|
250
|
+
}
|
|
251
|
+
return entries;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Derive the format from the source extension when no explicit format is named
|
|
255
|
+
if (!entry && ext) {
|
|
256
|
+
entry = registry.byExtension(ext, "load");
|
|
257
|
+
}
|
|
258
|
+
if (!entry) {
|
|
259
|
+
throw unknownFormatError(
|
|
260
|
+
source,
|
|
261
|
+
ext || `content type "${name}" (directory sources need an explicit "format")`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Discover entry files via the class, then load each
|
|
266
|
+
const files = entry.capabilities.discover
|
|
267
|
+
? ((await entry.call("discover", source, {
|
|
268
|
+
baseDir: projectRoot,
|
|
269
|
+
})) as string[])
|
|
270
|
+
: [resolvedSource];
|
|
271
|
+
|
|
272
|
+
// Directory sources pass their resolved root so formats can derive path-based ids for nested files
|
|
273
|
+
const sourceRoot = extname(source) ? undefined : resolvedSource;
|
|
274
|
+
const entries: ContentLoaderEntry[] = [];
|
|
275
|
+
for (const filePath of files) {
|
|
276
|
+
entries.push(
|
|
277
|
+
...((await entry.call("load", filePath, {
|
|
278
|
+
directiveOptions,
|
|
279
|
+
schema,
|
|
280
|
+
...(sourceRoot !== undefined && { sourceRoot }),
|
|
281
|
+
})) as ContentLoaderEntry[]),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Validate entries against schema if present
|
|
286
|
+
if (schema) {
|
|
287
|
+
validateEntries(entries, schema, name);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return entries;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Error message for an unregistered non-JSON extension, naming the fix. */
|
|
294
|
+
function unknownFormatError(path: string, ext: string): Error {
|
|
295
|
+
return new Error(
|
|
296
|
+
`No format class registered for "${ext}" (${path}). ` +
|
|
297
|
+
`Enable an extension providing this format in project.json "extensions", ` +
|
|
298
|
+
`e.g. "@jxsuite/parser".`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function errorMessage(error: unknown): string {
|
|
303
|
+
return error instanceof Error ? error.message : String(error);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Schema Validation ────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Validate content entries against their content type schema. Logs warnings for missing required
|
|
310
|
+
* fields and type mismatches.
|
|
311
|
+
*
|
|
312
|
+
* @param {ContentLoaderEntry[]} entries - Array of ContentEntry
|
|
313
|
+
* @param {ContentTypeSchema} schema - JSON Schema for the content type
|
|
314
|
+
* @param {string} contentTypeName - For error messages
|
|
315
|
+
*/
|
|
316
|
+
function validateEntries(
|
|
317
|
+
entries: ContentLoaderEntry[],
|
|
318
|
+
schema: ContentTypeSchema,
|
|
319
|
+
contentTypeName: string,
|
|
320
|
+
) {
|
|
321
|
+
const required = schema.required ?? [];
|
|
322
|
+
const properties = schema.properties ?? {};
|
|
323
|
+
|
|
324
|
+
for (const entry of entries) {
|
|
325
|
+
// Check required fields
|
|
326
|
+
for (const field of required) {
|
|
327
|
+
if (!(field in entry.data) || entry.data[field] == null) {
|
|
328
|
+
console.warn(
|
|
329
|
+
`Content validation: "${contentTypeName}/${entry.id}" missing required field "${field}"`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Check types
|
|
335
|
+
for (const [field, def] of Object.entries(properties)) {
|
|
336
|
+
const value = entry.data[field];
|
|
337
|
+
if (value == null) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (def.type === "string" && typeof value !== "string") {
|
|
342
|
+
console.warn(
|
|
343
|
+
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected string, got ${typeof value}`,
|
|
344
|
+
);
|
|
345
|
+
} else if (def.type === "number" && typeof value !== "number") {
|
|
346
|
+
console.warn(
|
|
347
|
+
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected number, got ${typeof value}`,
|
|
348
|
+
);
|
|
349
|
+
} else if (def.type === "boolean" && typeof value !== "boolean") {
|
|
350
|
+
console.warn(
|
|
351
|
+
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected boolean, got ${typeof value}`,
|
|
352
|
+
);
|
|
353
|
+
} else if (def.type === "array" && !Array.isArray(value)) {
|
|
354
|
+
console.warn(
|
|
355
|
+
`Content validation: "${contentTypeName}/${entry.id}" field "${field}" expected array, got ${typeof value}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─── Content Type Reference Resolution ──────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
/** Relationship pointers into the `content` section read `#/content/<name>`. */
|
|
365
|
+
const CONTENT_REF_PREFIX = "#/content/";
|
|
366
|
+
|
|
367
|
+
/** Extract the target content type name from a `#/content/<name>` relationship pointer. */
|
|
368
|
+
function refTargetName(ref: string | undefined): string | undefined {
|
|
369
|
+
if (!ref?.startsWith(CONTENT_REF_PREFIX)) {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
return ref.slice(CONTENT_REF_PREFIX.length);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Resolve cross-content-type relationship references in entry data (specs/relationships.md). A bare
|
|
377
|
+
* field `$ref` is to-one: a blog post's `author: "jane-doe"` with a schema `$ref` of
|
|
378
|
+
* `#/content/authors` gets replaced by the full author entry. An array field whose `items` carry
|
|
379
|
+
* the `$ref` is to-many: each id in the stored array is replaced by its entry. Unresolvable ids are
|
|
380
|
+
* left untouched, with a warning naming the entry and the missing target.
|
|
381
|
+
*
|
|
382
|
+
* @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types
|
|
383
|
+
* @param {ContentSection} section - The `content` section value the types were loaded from
|
|
384
|
+
*/
|
|
385
|
+
export function resolveContentTypeRefs(
|
|
386
|
+
contentTypes: Map<string, ContentLoaderEntry[]>,
|
|
387
|
+
section: ContentSection,
|
|
388
|
+
) {
|
|
389
|
+
for (const [name, contentTypeDef] of Object.entries(section)) {
|
|
390
|
+
const schema = contentTypeDef.schema as ContentTypeSchema | undefined;
|
|
391
|
+
if (!schema?.properties) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const entries = contentTypes.get(name);
|
|
396
|
+
if (!entries) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (const [field, def] of Object.entries(schema.properties)) {
|
|
401
|
+
const toOne = refTargetName(def.$ref);
|
|
402
|
+
const toMany = toOne ? undefined : refTargetName(def.items?.$ref);
|
|
403
|
+
const target = toOne ?? toMany;
|
|
404
|
+
if (!target) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const refEntries = contentTypes.get(target);
|
|
408
|
+
if (!refEntries) {
|
|
409
|
+
console.warn(
|
|
410
|
+
`Content relationships: "${name}" field "${field}" references unknown content type "${target}"`,
|
|
411
|
+
);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
for (const entry of entries) {
|
|
416
|
+
const value = entry.data[field];
|
|
417
|
+
if (toOne && typeof value === "string") {
|
|
418
|
+
const resolved = refEntries.find((e) => e.id === value);
|
|
419
|
+
if (resolved) {
|
|
420
|
+
entry.data[field] = resolved;
|
|
421
|
+
} else {
|
|
422
|
+
console.warn(
|
|
423
|
+
`Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${value}"`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
} else if (toMany && Array.isArray(value)) {
|
|
427
|
+
entry.data[field] = value.map((id) => {
|
|
428
|
+
if (typeof id !== "string") {
|
|
429
|
+
return id;
|
|
430
|
+
}
|
|
431
|
+
const resolved = refEntries.find((e) => e.id === id);
|
|
432
|
+
if (!resolved) {
|
|
433
|
+
console.warn(
|
|
434
|
+
`Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${id}"`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
return resolved ?? id;
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── The Content project-section class ───────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* `Content` — the parser extension's project-section class (Content.class.json). Owns the
|
|
449
|
+
* project.json `content` section: `projectData` loads it into `_project.content`, `resolvePaths`
|
|
450
|
+
* expands `$paths` values carrying the `contentType` discriminator.
|
|
451
|
+
*/
|
|
452
|
+
// oxlint-disable-next-line typescript/no-extraneous-class, unicorn/no-static-only-class -- the extension capability contract dispatches static methods on the class export named by the descriptor title (specs/extensions.md §6.1)
|
|
453
|
+
export class Content {
|
|
454
|
+
/**
|
|
455
|
+
* Load the `content` section: every content type's entries, with relationship references already
|
|
456
|
+
* resolved.
|
|
457
|
+
*
|
|
458
|
+
* @param {unknown} sectionValue - The project.json `content` section value
|
|
459
|
+
* @param {ProjectDataContext} ctx - Host context ({ projectConfig, root, registry, io })
|
|
460
|
+
* @returns {Promise<Map<string, ContentLoaderEntry[]>>} Content type name → entries
|
|
461
|
+
*/
|
|
462
|
+
static async projectData(
|
|
463
|
+
sectionValue: unknown,
|
|
464
|
+
ctx: ProjectDataContext,
|
|
465
|
+
): Promise<Map<string, ContentLoaderEntry[]>> {
|
|
466
|
+
const section = (sectionValue ?? {}) as ContentSection;
|
|
467
|
+
const data = await loadContentSection(section, ctx.root, ctx.registry.formats);
|
|
468
|
+
resolveContentTypeRefs(data, section);
|
|
469
|
+
return data;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Expand a content-type `$paths` source into route-param maps: one `{ [param]: value }` per
|
|
474
|
+
* entry, with `param` defaulting to "slug" and `field` to "id" (the entry id).
|
|
475
|
+
*
|
|
476
|
+
* @param {ContentPathsSource} pathsDef - The `$paths` value ({ contentType, param?, field? })
|
|
477
|
+
* @param {ResolvePathsContext} ctx - Host context ({ data, projectConfig, root })
|
|
478
|
+
* @returns {Promise<Record<string, unknown>[]>} Array of route-param objects
|
|
479
|
+
*/
|
|
480
|
+
static async resolvePaths(
|
|
481
|
+
pathsDef: ContentPathsSource,
|
|
482
|
+
ctx: ResolvePathsContext,
|
|
483
|
+
): Promise<Record<string, unknown>[]> {
|
|
484
|
+
const entries = ctx.data.get(pathsDef.contentType);
|
|
485
|
+
if (!entries || entries.length === 0) {
|
|
486
|
+
console.warn(
|
|
487
|
+
`Warning: $paths references content type "${pathsDef.contentType}" but it has no entries`,
|
|
488
|
+
);
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
const param = pathsDef.param ?? "slug";
|
|
492
|
+
const field = pathsDef.field ?? "id";
|
|
493
|
+
return entries
|
|
494
|
+
.map((entry) => ({
|
|
495
|
+
[param]: field === "id" ? entry.id : (entry.data[field] ?? entry.id),
|
|
496
|
+
}))
|
|
497
|
+
.filter((p) => p[param]);
|
|
498
|
+
}
|
|
499
|
+
}
|
package/src/content.ts
CHANGED
|
@@ -14,7 +14,8 @@ interface CollectionConfig {
|
|
|
14
14
|
sort?: unknown;
|
|
15
15
|
limit?: number;
|
|
16
16
|
_project?: {
|
|
17
|
-
|
|
17
|
+
/** The loaded `content` section (Content.projectData result) under its section key. */
|
|
18
|
+
content?: Map<string, ContentLoaderEntry[]>;
|
|
18
19
|
[k: string]: unknown;
|
|
19
20
|
};
|
|
20
21
|
[k: string]: unknown;
|
|
@@ -25,7 +26,8 @@ interface EntryConfig {
|
|
|
25
26
|
id?: unknown;
|
|
26
27
|
field?: string;
|
|
27
28
|
_project?: {
|
|
28
|
-
|
|
29
|
+
/** The loaded `content` section (Content.projectData result) under its section key. */
|
|
30
|
+
content?: Map<string, ContentLoaderEntry[]>;
|
|
29
31
|
[k: string]: unknown;
|
|
30
32
|
};
|
|
31
33
|
_document?: {
|
|
@@ -178,7 +180,7 @@ export class ContentCollection {
|
|
|
178
180
|
|
|
179
181
|
resolve() {
|
|
180
182
|
const { contentType, filter, sort, limit, _project } = this.config;
|
|
181
|
-
const entries = _project?.
|
|
183
|
+
const entries = _project?.content?.get(contentType ?? "");
|
|
182
184
|
if (!entries) {
|
|
183
185
|
return [];
|
|
184
186
|
}
|
|
@@ -205,7 +207,7 @@ export class ContentEntry {
|
|
|
205
207
|
|
|
206
208
|
resolve() {
|
|
207
209
|
const { contentType, id, field, _project, _document } = this.config;
|
|
208
|
-
const entries = _project?.
|
|
210
|
+
const entries = _project?.content?.get(contentType ?? "");
|
|
209
211
|
if (!entries) {
|
|
210
212
|
return null;
|
|
211
213
|
}
|
package/src/html-to-jx.ts
CHANGED
|
@@ -1,121 +1,12 @@
|
|
|
1
|
-
import { fromHtml } from "hast-util-from-html";
|
|
2
|
-
import { whitespace } from "hast-util-whitespace";
|
|
3
|
-
import { find, html as htmlInfo } from "property-information";
|
|
4
|
-
|
|
5
|
-
import type { Nodes as HastNode } from "hast";
|
|
6
|
-
import type { JxElement } from "@jxsuite/schema/types";
|
|
7
|
-
|
|
8
1
|
/**
|
|
9
|
-
*
|
|
2
|
+
* Html-to-jx — compatibility shim.
|
|
10
3
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @param {HastNode[]} children
|
|
21
|
-
* @returns {(JxElement | string)[]}
|
|
22
|
-
*/
|
|
23
|
-
function convertHastChildren(children: HastNode[]) {
|
|
24
|
-
const result: (JxElement | string)[] = [];
|
|
25
|
-
for (const child of children) {
|
|
26
|
-
const converted = convertHastNode(child);
|
|
27
|
-
if (converted != null) {
|
|
28
|
-
result.push(converted);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return result;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* @param {HastNode} node
|
|
36
|
-
* @returns {JxElement | string | null}
|
|
37
|
-
*/
|
|
38
|
-
function convertHastNode(node: HastNode) {
|
|
39
|
-
if (node.type === "text") {
|
|
40
|
-
if (whitespace(node as unknown as HastNode)) {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
return node.value ?? null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (node.type === "element") {
|
|
47
|
-
const el: JxElement = { tagName: node.tagName };
|
|
48
|
-
|
|
49
|
-
if (node.properties && Object.keys(node.properties).length > 0) {
|
|
50
|
-
const { style, attrs } = hastPropsToJx(node.properties);
|
|
51
|
-
if (Object.keys(attrs).length > 0) {
|
|
52
|
-
el.attributes = attrs;
|
|
53
|
-
}
|
|
54
|
-
if (Object.keys(style).length > 0) {
|
|
55
|
-
el.style = style;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const kids = node.children ? convertHastChildren(node.children) : [];
|
|
60
|
-
|
|
61
|
-
if (kids.length === 1 && typeof kids[0] === "string") {
|
|
62
|
-
[el.textContent] = kids;
|
|
63
|
-
} else if (kids.length > 0) {
|
|
64
|
-
el.children = kids as JxElement[];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return el;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* @param {Record<string, unknown>} properties
|
|
75
|
-
* @returns {{ style: Record<string, string>; attrs: Record<string, string> }}
|
|
4
|
+
* The HTML → Jx conversion pipeline moved to `@jxsuite/markup/html-to-jx`; this module
|
|
5
|
+
* re-exports it so the parser's historical `@jxsuite/parser/html-to-jx` entrypoint keeps
|
|
6
|
+
* working for downstream consumers.
|
|
7
|
+
*
|
|
8
|
+
* @module @jxsuite/parser/html-to-jx
|
|
9
|
+
* @license MIT
|
|
76
10
|
*/
|
|
77
|
-
function hastPropsToJx(properties: Record<string, unknown>) {
|
|
78
|
-
const attrs: Record<string, string> = {};
|
|
79
|
-
const style: Record<string, string> = {};
|
|
80
11
|
|
|
81
|
-
|
|
82
|
-
if (value === false || value === undefined || value === null) {
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const info = find(htmlInfo, key);
|
|
87
|
-
const name = info.attribute;
|
|
88
|
-
|
|
89
|
-
if (name === "style" && typeof value === "string") {
|
|
90
|
-
parseInlineStyle(value, style);
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (value === true) {
|
|
95
|
-
attrs[name] = "";
|
|
96
|
-
} else if (Array.isArray(value)) {
|
|
97
|
-
attrs[name] = value.join(info.commaSeparated ? ", " : " ");
|
|
98
|
-
} else {
|
|
99
|
-
attrs[name] = String(value);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return { attrs, style };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* @param {string} styleStr
|
|
107
|
-
* @param {Record<string, string>} out
|
|
108
|
-
*/
|
|
109
|
-
function parseInlineStyle(styleStr: string, out: Record<string, string>) {
|
|
110
|
-
for (const decl of styleStr.split(";")) {
|
|
111
|
-
const colon = decl.indexOf(":");
|
|
112
|
-
if (colon === -1) {
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
const prop = decl.slice(0, colon).trim();
|
|
116
|
-
const val = decl.slice(colon + 1).trim();
|
|
117
|
-
if (prop && val) {
|
|
118
|
-
out[prop] = val;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
12
|
+
export { htmlToJx } from "@jxsuite/markup/html-to-jx";
|
package/src/markdown.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface MarkdownLoadOptions {
|
|
|
26
26
|
directiveOptions?: unknown;
|
|
27
27
|
/** Enable remark-directive parsing. Implied by directiveOptions. */
|
|
28
28
|
directives?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Resolved content-source root directory. When set, entries in subdirectories get path-based ids
|
|
31
|
+
* (`studio/canvas` for `studio/canvas.md`, `/index` stripped); files directly at the root keep
|
|
32
|
+
* basename ids.
|
|
33
|
+
*/
|
|
34
|
+
sourceRoot?: string;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
@@ -83,6 +89,7 @@ export class Markdown {
|
|
|
83
89
|
...(options.directiveOptions !== undefined && {
|
|
84
90
|
directiveOptions: options.directiveOptions,
|
|
85
91
|
}),
|
|
92
|
+
...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
|
|
86
93
|
});
|
|
87
94
|
const _meta: ContentLoaderEntry["_meta"] = {};
|
|
88
95
|
if (result.$excerpt != null) {
|