@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.
@@ -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
- contentTypes?: Map<string, ContentLoaderEntry[]>;
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
- contentTypes?: Map<string, ContentLoaderEntry[]>;
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?.contentTypes?.get(contentType ?? "");
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?.contentTypes?.get(contentType ?? "");
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
- * Convert an HTML string into an array of Jx tree nodes.
2
+ * Html-to-jx — compatibility shim.
10
3
  *
11
- * @param {string} htmlString
12
- * @returns {(JxElement | string)[]}
13
- */
14
- export function htmlToJx(htmlString: string) {
15
- const hast = fromHtml(htmlString, { fragment: true });
16
- return convertHastChildren(hast.children as HastNode[]);
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
- for (const [key, value] of Object.entries(properties)) {
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) {