@jxsuite/parser 0.27.0 → 0.28.2
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/content.js +53 -1
- package/dist/csv.js +189 -0
- package/dist/markdown.js +44815 -0
- package/dist/serialize.js +30786 -0
- package/package.json +14 -7
- package/src/Csv.class.json +171 -0
- package/src/Markdown.class.json +272 -0
- package/src/content.ts +17 -4
- package/src/csv.ts +209 -0
- package/src/markdown.ts +112 -0
- package/src/md.ts +6 -49
- package/src/serialize.ts +1482 -0
- package/src/MarkdownFile.class.json +0 -94
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
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -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} */
|
|
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} */
|
|
192
|
+
/** @type {unknown} */ obj,
|
|
193
193
|
);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
// ───
|
|
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} */
|
|
276
|
+
filtered = results.filter(/** @type {(r: MarkdownFileResult) => boolean} */ filter);
|
|
320
277
|
}
|
|
321
278
|
|
|
322
279
|
// Sort
|