@jxsuite/parser 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/src/csv.ts ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Csv — CSV content format for Jx
3
+ *
4
+ * RFC 4180-style CSV parsing with schema-driven type coercion, packaged as a Jx
5
+ * format-extension class. `parse` is pure and browser-safe; `discover`, `load`, and
6
+ * instance `resolve` touch the filesystem (or fetch remote URLs) and dynamically
7
+ * import node modules so the module itself stays importable in the browser.
8
+ *
9
+ * @module @jxsuite/parser/csv
10
+ * @license MIT
11
+ */
12
+
13
+ import type { ContentTypeSchema } from "@jxsuite/schema/types";
14
+ import type { ContentLoaderEntry } from "./types.ts";
15
+
16
+ // ─── CSV parser (minimal, spec-compliant) ────────────────────────────────────
17
+
18
+ /**
19
+ * Parse a CSV string into an array of objects using the first row as headers. Handles quoted fields
20
+ * with commas and newlines.
21
+ */
22
+ export function parseCSV(csv: string): Record<string, string>[] {
23
+ const rows: Record<string, string>[] = [];
24
+ let current = "";
25
+ let inQuotes = false;
26
+ const lines: string[] = [];
27
+
28
+ // Split into rows respecting quoted newlines (preserve raw characters)
29
+ for (let i = 0; i < csv.length; i++) {
30
+ const ch = csv[i];
31
+ if (ch === '"') {
32
+ current += ch;
33
+ if (inQuotes && csv[i + 1] === '"') {
34
+ current += csv[i + 1];
35
+ i++;
36
+ } else {
37
+ inQuotes = !inQuotes;
38
+ }
39
+ } else if ((ch === "\n" || (ch === "\r" && csv[i + 1] === "\n")) && !inQuotes) {
40
+ lines.push(current);
41
+ current = "";
42
+ if (ch === "\r") i++;
43
+ } else {
44
+ current += ch;
45
+ }
46
+ }
47
+ if (current.trim()) lines.push(current);
48
+
49
+ if (lines.length === 0) return [];
50
+
51
+ const parseRow = (line: string) => {
52
+ const fields: string[] = [];
53
+ let field = "";
54
+ let q = false;
55
+ for (let i = 0; i < line.length; i++) {
56
+ const ch = line[i];
57
+ if (ch === '"') {
58
+ if (q && line[i + 1] === '"') {
59
+ field += '"';
60
+ i++;
61
+ } else {
62
+ q = !q;
63
+ }
64
+ } else if (ch === "," && !q) {
65
+ fields.push(field);
66
+ field = "";
67
+ } else {
68
+ field += ch;
69
+ }
70
+ }
71
+ fields.push(field);
72
+ return fields;
73
+ };
74
+
75
+ const headers = parseRow(lines[0]);
76
+ for (let i = 1; i < lines.length; i++) {
77
+ const fields = parseRow(lines[i]);
78
+ const obj: Record<string, string> = {};
79
+ for (let j = 0; j < headers.length; j++) {
80
+ obj[headers[j].trim()] = fields[j]?.trim() ?? "";
81
+ }
82
+ rows.push(obj);
83
+ }
84
+ return rows;
85
+ }
86
+
87
+ // ─── Type coercion ───────────────────────────────────────────────────────────
88
+
89
+ export interface CsvOptions {
90
+ /** Content type schema — drives per-column type coercion. */
91
+ schema?: ContentTypeSchema;
92
+ /** Column used as entry id. Default fallback chain: id → sku → slug → Slug → row index. */
93
+ idField?: string;
94
+ }
95
+
96
+ /**
97
+ * Coerce raw CSV row strings to typed values per the schema. - number: strips currency
98
+ * symbols/commas; null for empty cells - boolean: "true" → true, everything else → false - array:
99
+ * comma-split, trimmed, empties dropped
100
+ */
101
+ export function coerceCSVRows(
102
+ rows: Record<string, string>[],
103
+ schema?: ContentTypeSchema,
104
+ idField?: string,
105
+ ): ContentLoaderEntry[] {
106
+ return rows.map((row, i) => {
107
+ const data: Record<string, unknown> = { ...row };
108
+ if (schema?.properties) {
109
+ for (const [key, def] of Object.entries(schema.properties)) {
110
+ if (!(key in data)) continue;
111
+ if (def.type === "number") {
112
+ const raw = String(data[key] ?? "").trim();
113
+ if (raw === "") {
114
+ data[key] = null;
115
+ } else {
116
+ const cleaned = raw.replace(/[$€£¥,\s]/g, "");
117
+ const n = Number(cleaned);
118
+ data[key] = isNaN(n) ? null : n;
119
+ }
120
+ } else if (def.type === "boolean") {
121
+ data[key] = data[key] === "true";
122
+ } else if (def.type === "array") {
123
+ const raw = String(data[key] ?? "").trim();
124
+ data[key] =
125
+ raw === ""
126
+ ? []
127
+ : raw
128
+ .split(",")
129
+ .map((s) => s.trim())
130
+ .filter(Boolean);
131
+ }
132
+ }
133
+ }
134
+ const id =
135
+ (idField ? (data[idField] as string | undefined) : undefined) ??
136
+ (data.id as string | undefined) ??
137
+ (data.sku as string | undefined) ??
138
+ (data.slug as string | undefined) ??
139
+ (data.Slug as string | undefined) ??
140
+ String(i);
141
+ return { id, data, body: null };
142
+ });
143
+ }
144
+
145
+ // ─── Csv format class ────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * CSV format-extension class. Satisfies the Jx external class contract ($prototype + instance
149
+ * resolve) and the format capability contract (static parse / discover / load).
150
+ *
151
+ * @example
152
+ * { "$prototype": "Csv", "$src": "@jxsuite/parser/Csv.class.json", "src": "./products.csv" }
153
+ */
154
+ export class Csv {
155
+ config: { src: string; basePath?: string } & CsvOptions;
156
+
157
+ constructor(config: { src: string; basePath?: string } & CsvOptions) {
158
+ this.config = config;
159
+ }
160
+
161
+ /** Parse CSV source text into content entries (pure, browser-safe). */
162
+ static parse(source: string, options: CsvOptions = {}): ContentLoaderEntry[] {
163
+ return coerceCSVRows(parseCSV(source), options.schema, options.idField);
164
+ }
165
+
166
+ /** List .csv entry files for a content-type source (file path or directory). */
167
+ static async discover(source: string, options: { baseDir?: string } = {}): Promise<string[]> {
168
+ const { existsSync, readdirSync } = await import("node:fs");
169
+ const { resolve, extname } = await import("node:path");
170
+ const resolved = options.baseDir ? resolve(options.baseDir, source) : resolve(source);
171
+
172
+ if (extname(resolved)) {
173
+ return existsSync(resolved) ? [resolved] : [];
174
+ }
175
+ try {
176
+ return readdirSync(resolved, { recursive: true })
177
+ .filter((f) => String(f).endsWith(".csv"))
178
+ .map((f) => resolve(resolved, String(f)));
179
+ } catch {
180
+ return [];
181
+ }
182
+ }
183
+
184
+ /** Load one CSV source (file path or http(s) URL) into content entries. */
185
+ static async load(path: string, options: CsvOptions = {}): Promise<ContentLoaderEntry[]> {
186
+ if (path.startsWith("http://") || path.startsWith("https://")) {
187
+ const response = await fetch(path);
188
+ if (!response.ok) {
189
+ throw new Error(
190
+ `Failed to fetch CSV from ${path}: ${response.status} ${response.statusText}`,
191
+ );
192
+ }
193
+ return Csv.parse(await response.text(), options);
194
+ }
195
+ const { readFileSync } = await import("node:fs");
196
+ return Csv.parse(readFileSync(path, "utf-8"), options);
197
+ }
198
+
199
+ /** Runtime on-demand access: load the configured source. */
200
+ async resolve(): Promise<ContentLoaderEntry[]> {
201
+ const { src, basePath, ...options } = this.config;
202
+ if (src.startsWith("http://") || src.startsWith("https://")) {
203
+ return Csv.load(src, options);
204
+ }
205
+ const { resolve } = await import("node:path");
206
+ const filePath = basePath ? resolve(basePath, src) : resolve(src);
207
+ return Csv.load(filePath, options);
208
+ }
209
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Markdown — the markdown format-extension class for Jx
3
+ *
4
+ * Single class carrying every format capability:
5
+ * - static `parse` (markdown source → Jx document) — browser-safe
6
+ * - static `serialize` (Jx document → markdown source) — browser-safe
7
+ * - static `discover` / `load` (compile-time content access) — node-only, dynamic imports
8
+ * - instance `resolve` (runtime on-demand access for `$prototype: "Markdown"` state)
9
+ *
10
+ * The node-only capabilities dynamically import `node:fs` / `./md.ts` inside the
11
+ * method so this module stays importable in the browser (studio calls parse/serialize
12
+ * in-process).
13
+ *
14
+ * @module @jxsuite/parser/markdown
15
+ * @license MIT
16
+ */
17
+
18
+ import { transpileJxMarkdown } from "./transpile.ts";
19
+ import { serializeJxMarkdown, type SerializeOptions } from "./serialize.ts";
20
+ import type { JxDocument } from "@jxsuite/schema/types";
21
+ import type { ContentLoaderEntry, MarkdownFileResult } from "./types.ts";
22
+
23
+ export interface MarkdownLoadOptions {
24
+ /** Options for the MarkdownDirective plugin (allowedNames, prefix, ...). */
25
+ directiveOptions?: unknown;
26
+ /** Enable remark-directive parsing. Implied by directiveOptions. */
27
+ directives?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Markdown format class. Satisfies the Jx external class contract ($prototype + instance resolve)
32
+ * and the format capability contract (static parse / serialize / discover / load).
33
+ *
34
+ * @example
35
+ * { "$prototype": "Markdown", "$src": "@jxsuite/parser/Markdown.class.json", "src": "./post.md" }
36
+ */
37
+ export class Markdown {
38
+ config: { src: string; basePath?: string } & MarkdownLoadOptions;
39
+
40
+ constructor(config: { src: string; basePath?: string } & MarkdownLoadOptions) {
41
+ this.config = config;
42
+ }
43
+
44
+ /** Transpile Jx Markdown source into a complete Jx JSON document (browser-safe). */
45
+ static parse(source: string): JxDocument {
46
+ return transpileJxMarkdown(source);
47
+ }
48
+
49
+ /** Serialize a Jx document to markdown source (browser-safe). See SerializeOptions. */
50
+ static serialize(doc: JxDocument, options?: SerializeOptions): string {
51
+ return serializeJxMarkdown(doc, options);
52
+ }
53
+
54
+ /** List .md entry files for a content-type source (file path or directory). */
55
+ static async discover(source: string, options: { baseDir?: string } = {}): Promise<string[]> {
56
+ const { existsSync, readdirSync } = await import("node:fs");
57
+ const { resolve, extname } = await import("node:path");
58
+ const resolved = options.baseDir ? resolve(options.baseDir, source) : resolve(source);
59
+
60
+ if (extname(resolved)) {
61
+ return existsSync(resolved) ? [resolved] : [];
62
+ }
63
+ try {
64
+ return readdirSync(resolved, { recursive: true })
65
+ .filter((f) => String(f).endsWith(".md"))
66
+ .map((f) => resolve(resolved, String(f)));
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
71
+
72
+ /** Load one markdown file into a content entry (frontmatter → data, body preserved). */
73
+ static async load(
74
+ path: string,
75
+ options: MarkdownLoadOptions = {},
76
+ ): Promise<ContentLoaderEntry[]> {
77
+ const { processMarkdown } = await import("./md.ts");
78
+ const { readFileSync } = await import("node:fs");
79
+ const source = readFileSync(path, "utf-8");
80
+ const result = processMarkdown(source, path, {
81
+ ...(options.directives !== undefined && { directives: options.directives }),
82
+ ...(options.directiveOptions !== undefined && {
83
+ directiveOptions: options.directiveOptions,
84
+ }),
85
+ });
86
+ const _meta: ContentLoaderEntry["_meta"] = {};
87
+ if (result.$excerpt != null) _meta.excerpt = result.$excerpt;
88
+ if (result.$toc != null) _meta.toc = result.$toc;
89
+ if (result.$readingTime != null) _meta.readingTime = result.$readingTime;
90
+ if (result.$wordCount != null) _meta.wordCount = result.$wordCount;
91
+ return [
92
+ {
93
+ id: result.slug,
94
+ data: result.frontmatter,
95
+ body: source,
96
+ $children: result.$children,
97
+ _meta,
98
+ },
99
+ ];
100
+ }
101
+
102
+ /** Runtime on-demand access: parse the configured markdown file. */
103
+ async resolve(): Promise<MarkdownFileResult> {
104
+ const { processMarkdown } = await import("./md.ts");
105
+ const { readFileSync } = await import("node:fs");
106
+ const { resolve } = await import("node:path");
107
+ const { src, basePath, ...processorConfig } = this.config;
108
+ const filePath = basePath ? resolve(basePath, src) : resolve(src);
109
+ const source = readFileSync(filePath, "utf-8");
110
+ return processMarkdown(source, filePath, processorConfig);
111
+ }
112
+ }
package/src/md.ts CHANGED
@@ -127,7 +127,7 @@ function extractExcerpt(tree: MdastNode) {
127
127
  * @param {unknown} [config.directiveOptions] - Directive plugin options
128
128
  * @returns {MarkdownFileResult}
129
129
  */
130
- function processMarkdown(
130
+ export function processMarkdown(
131
131
  source: string,
132
132
  filePath: string,
133
133
  config: {
@@ -162,7 +162,7 @@ function processMarkdown(
162
162
  (n: MdastNode) => n.type !== "yaml" && n.type !== "toml",
163
163
  ) as MdastNode[];
164
164
  const $children = bodyNodes
165
- .map(/** @type {(n: MdastNode) => any} */ (mdastNodeToJx))
165
+ .map(/** @type {(n: MdastNode) => any} */ mdastNodeToJx)
166
166
  .filter(Boolean) as (JxElement | string)[];
167
167
 
168
168
  return {
@@ -189,56 +189,13 @@ function getNestedValue(obj: Record<string, unknown> | MarkdownFileResult, path:
189
189
  .split(".")
190
190
  .reduce(
191
191
  (o: unknown, k) => (o as Record<string, unknown> | undefined)?.[k],
192
- /** @type {unknown} */ (obj),
192
+ /** @type {unknown} */ obj,
193
193
  );
194
194
  }
195
195
 
196
- // ─── MarkdownFile ─────────────────────────────────────────────────────────────
196
+ // ─── Markdown format class (re-exported from browser-safe module) ────────────
197
197
 
198
- /**
199
- * Parse a single markdown file. Satisfies the Jx external class contract ($prototype).
200
- *
201
- * @example
202
- * { "$prototype": "MarkdownFile", "$src": "@jxsuite/md", "src": "./content/about.md" }
203
- */
204
- export class MarkdownFile {
205
- config: {
206
- src: string;
207
- remarkPlugins?: unknown[];
208
- rehypePlugins?: unknown[];
209
- basePath?: string;
210
- directives?: boolean;
211
- };
212
- /**
213
- * @param {object} config
214
- * @param {string} config.src - File path to markdown file
215
- * @param {unknown[]} [config.remarkPlugins] Default is `[]`
216
- * @param {unknown[]} [config.rehypePlugins] Default is `[]`
217
- * @param {string} [config.basePath] - Base path for resolving src
218
- * @param {boolean} [config.directives] - Enable directive support
219
- */
220
- constructor(config: {
221
- src: string;
222
- remarkPlugins?: unknown[];
223
- rehypePlugins?: unknown[];
224
- basePath?: string;
225
- directives?: boolean;
226
- }) {
227
- this.config = config;
228
- }
229
-
230
- /**
231
- * Parse and resolve the markdown file.
232
- *
233
- * @returns {MarkdownFileResult}
234
- */
235
- resolve() {
236
- const { src, basePath, ...processorConfig } = this.config;
237
- const filePath = basePath ? resolvePath(basePath, src) : resolvePath(src);
238
- const source = readFileSync(filePath, "utf-8");
239
- return processMarkdown(source, filePath, processorConfig);
240
- }
241
- }
198
+ export { Markdown } from "./markdown.ts";
242
199
 
243
200
  // ─── MarkdownCollection ───────────────────────────────────────────────────────
244
201
 
@@ -316,7 +273,7 @@ export class MarkdownCollection {
316
273
  // Filter
317
274
  let filtered = results;
318
275
  if (typeof filter === "function") {
319
- filtered = results.filter(/** @type {(r: MarkdownFileResult) => boolean} */ (filter));
276
+ filtered = results.filter(/** @type {(r: MarkdownFileResult) => boolean} */ filter);
320
277
  }
321
278
 
322
279
  // Sort