@jxsuite/parser 1.5.6 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/parser",
3
- "version": "1.5.6",
3
+ "version": "1.7.0",
4
4
  "description": "Jx markdown parser and external class integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,11 +45,11 @@
45
45
  "build:metafile": "bun build src/content.ts src/content-loader.ts src/markdown.ts src/csv.ts src/serialize.ts --outdir=dist --format=esm --target=node --metafile=metafile.json",
46
46
  "test": "bun test --isolate",
47
47
  "test:coverage": "bun test --isolate --coverage",
48
- "upgrade": "bunx npm-check-updates -u && bun install"
48
+ "upgrade": "bun update --latest && bun install"
49
49
  },
50
50
  "dependencies": {
51
- "@jxsuite/markup": "^0.4.7",
52
- "@jxsuite/schema": "^1.9.0",
51
+ "@jxsuite/markup": "^0.4.9",
52
+ "@jxsuite/schema": "^2.1.0",
53
53
  "@shikijs/langs": "^4.4.3",
54
54
  "@shikijs/themes": "^4.4.3",
55
55
  "glob": "^13.0.6",
@@ -66,8 +66,8 @@
66
66
  "yaml": "^2.9.0"
67
67
  },
68
68
  "devDependencies": {
69
- "@happy-dom/global-registrator": "^20.11.6",
70
- "@jxsuite/runtime": "^2.1.0",
69
+ "@happy-dom/global-registrator": "^20.12.0",
70
+ "@jxsuite/runtime": "^3.1.0",
71
71
  "@types/mdast": "^4.0.4",
72
72
  "ajv": "^8.20.0"
73
73
  },
@@ -115,6 +115,37 @@
115
115
  "returnType": { "$ref": "#/$defs/returnTypes/ContentLoaderEntries" },
116
116
  "description": "Parse CSV text into typed content entries (pure, browser-safe)"
117
117
  },
118
+ "rewrite": {
119
+ "role": "rewrite",
120
+ "$prototype": "Function",
121
+ "access": "public",
122
+ "scope": "static",
123
+ "identifier": "rewrite",
124
+ "timing": ["compiler", "server", "client"],
125
+ "parameters": [
126
+ {
127
+ "identifier": "source",
128
+ "type": { "type": "string" },
129
+ "description": "Raw CSV text"
130
+ },
131
+ {
132
+ "identifier": "edits",
133
+ "type": {
134
+ "type": "array",
135
+ "items": {
136
+ "type": "object",
137
+ "properties": {
138
+ "from": { "type": "string" },
139
+ "to": { "type": "string" }
140
+ }
141
+ }
142
+ },
143
+ "description": "Authored cell values to replace, matched whole against a data cell"
144
+ }
145
+ ],
146
+ "returnType": { "type": "string" },
147
+ "description": "Replace authored cell values in CSV source text, preserving every other byte"
148
+ },
118
149
  "discover": {
119
150
  "role": "discover",
120
151
  "$prototype": "Function",
package/src/csv.ts CHANGED
@@ -2,10 +2,15 @@
2
2
  * Csv — CSV content format for Jx
3
3
  *
4
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
5
+ * format-extension class. `parse` and `rewrite` are pure and browser-safe; `discover`, `load`, and
6
6
  * instance `resolve` touch the filesystem (or fetch remote URLs) and dynamically
7
7
  * import node modules so the module itself stays importable in the browser.
8
8
  *
9
+ * There is no `serialize`, and there will not be one: a collection is a data source entries are
10
+ * loaded FROM, and re-emitting one would mean choosing a quoting style, a line ending and a column
11
+ * order the author already chose. `rewrite` is the narrower promise that covers the case that
12
+ * actually needed it — a rename repairing a reference a row names (specs/extensions.md §8).
13
+ *
9
14
  * @module @jxsuite/parser/csv
10
15
  * @license MIT
11
16
  */
@@ -90,6 +95,164 @@ export function parseCSV(csv: string): Record<string, string>[] {
90
95
  return rows;
91
96
  }
92
97
 
98
+ // ─── Cell-level rewriting ────────────────────────────────────────────────────
99
+
100
+ /** One authored value to replace, wherever a data cell holds exactly it. */
101
+ export interface CsvEdit {
102
+ /** The cell value as `parse` produced it — unquoted and trimmed. */
103
+ from: string;
104
+ /** What to write in its place. */
105
+ to: string;
106
+ }
107
+
108
+ /** One field of a CSV source: where its bytes are, and what {@link parseCSV} made of it. */
109
+ interface CsvField {
110
+ /** Row index in the source. Row 0 is the header. */
111
+ row: number;
112
+ /** Offsets of the field's raw text within the source, delimiting commas excluded. */
113
+ start: number;
114
+ end: number;
115
+ /** The value {@link parseCSV} produces for it — unquoted and trimmed. */
116
+ value: string;
117
+ }
118
+
119
+ /**
120
+ * Row spans, splitting on newlines that are not inside quotes.
121
+ *
122
+ * A deliberate mirror of {@link parseCSV}'s first pass, offsets instead of text: the two must agree
123
+ * on where a row ends or a rewrite would splice into the wrong cell. The accumulated text there is
124
+ * exactly `source.slice(start, end)` here, because the only characters that pass are contiguous.
125
+ */
126
+ function scanRows(csv: string): { start: number; end: number }[] {
127
+ const rows: { start: number; end: number }[] = [];
128
+ let start = 0;
129
+ let inQuotes = false;
130
+ for (let i = 0; i < csv.length; i++) {
131
+ const ch = csv[i];
132
+ if (ch === '"') {
133
+ if (inQuotes && csv[i + 1] === '"') {
134
+ i += 1;
135
+ } else {
136
+ inQuotes = !inQuotes;
137
+ }
138
+ } else if ((ch === "\n" || (ch === "\r" && csv[i + 1] === "\n")) && !inQuotes) {
139
+ rows.push({ end: i, start });
140
+ if (ch === "\r") {
141
+ i += 1;
142
+ }
143
+ start = i + 1;
144
+ }
145
+ }
146
+ // The same test {@link parseCSV} applies to its final accumulated row, which is exactly this
147
+ // Slice: a trailing newline leaves whitespace behind, and that is not a row.
148
+ if (csv.slice(start).trim()) {
149
+ rows.push({ end: csv.length, start });
150
+ }
151
+ return rows;
152
+ }
153
+
154
+ /** Field spans within one row, mirroring {@link parseCSV}'s second pass. */
155
+ function scanRowFields(csv: string, row: number, span: { start: number; end: number }): CsvField[] {
156
+ const line = csv.slice(span.start, span.end);
157
+ const fields: CsvField[] = [];
158
+ let fieldStart = 0;
159
+ let value = "";
160
+ let inQuotes = false;
161
+ for (let i = 0; i < line.length; i++) {
162
+ const ch = line[i];
163
+ if (ch === '"') {
164
+ if (inQuotes && line[i + 1] === '"') {
165
+ value += '"';
166
+ i += 1;
167
+ } else {
168
+ inQuotes = !inQuotes;
169
+ }
170
+ } else if (ch === "," && !inQuotes) {
171
+ fields.push({
172
+ end: span.start + i,
173
+ row,
174
+ start: span.start + fieldStart,
175
+ value: value.trim(),
176
+ });
177
+ value = "";
178
+ fieldStart = i + 1;
179
+ } else {
180
+ value += ch;
181
+ }
182
+ }
183
+ fields.push({ end: span.end, row, start: span.start + fieldStart, value: value.trim() });
184
+ return fields;
185
+ }
186
+
187
+ /** Every field of a CSV source in document order, with the offsets a splice needs. */
188
+ function scanFields(csv: string): CsvField[] {
189
+ return scanRows(csv).flatMap((span, row) => scanRowFields(csv, row, span));
190
+ }
191
+
192
+ /**
193
+ * Write `value` into the space a field's raw text occupied, keeping that field's own conventions.
194
+ *
195
+ * Padding survives because it is the author's, and quoting survives for the same reason — a column
196
+ * written `"a","b"` stays quoted even when the new value would not need it. A value that WOULD be
197
+ * misread unquoted (a comma, a quote, a newline, or padding of its own) is quoted regardless, which
198
+ * is the only case where the output style is not simply the input's.
199
+ */
200
+ function encodeField(raw: string, value: string): string {
201
+ const lead = raw.slice(0, raw.length - raw.trimStart().length);
202
+ const trail = raw.slice(raw.trimEnd().length);
203
+ const core = raw.trim();
204
+ const wasQuoted = core.length >= 2 && core.startsWith('"') && core.endsWith('"');
205
+ const mustQuote = /["\r\n,]/.test(value) || value.trim() !== value;
206
+ return lead + (wasQuoted || mustQuote ? `"${value.replaceAll('"', '""')}"` : value) + trail;
207
+ }
208
+
209
+ /**
210
+ * Replace authored cell values in CSV source text, preserving every other byte.
211
+ *
212
+ * The `rewrite` capability (specs/extensions.md §8), and deliberately narrower than `serialize`. A
213
+ * CSV collection is a data source entries are loaded FROM, not a document Jx round-trips, so it has
214
+ * no serializer and never will — quoting style, line endings and column order are the author's and
215
+ * the loader has no opinion about any of them. But the rename refactor does not need a document
216
+ * back; it needs one cell's text changed. Without that, renaming a file a CSV row names updated
217
+ * every other reference and reported this one as a remainder the author had to repair by hand
218
+ * (issue 246).
219
+ *
220
+ * Matching is on the WHOLE cell value as {@link parseCSV} produced it, never on a substring:
221
+ * `hero.jpg` must not rewrite the middle of `my-hero.jpg`. The HEADER row is never touched — it
222
+ * names columns rather than files, `parse` does not expose it as a value, and an edit derived from
223
+ * `parse` output therefore cannot legitimately name it.
224
+ *
225
+ * @param source — the CSV file's original text
226
+ * @param edits — authored values to replace
227
+ * @returns The source with exactly those cells rewritten
228
+ */
229
+ export function rewriteCSV(source: string, edits: readonly CsvEdit[]): string {
230
+ const replacements = new Map<string, string>();
231
+ for (const edit of edits) {
232
+ if (edit.from !== edit.to) {
233
+ replacements.set(edit.from, edit.to);
234
+ }
235
+ }
236
+ if (replacements.size === 0) {
237
+ return source;
238
+ }
239
+ let out = "";
240
+ let cursor = 0;
241
+ for (const field of scanFields(source)) {
242
+ if (field.row === 0) {
243
+ continue;
244
+ }
245
+ const to = replacements.get(field.value);
246
+ if (to === undefined) {
247
+ continue;
248
+ }
249
+ out += source.slice(cursor, field.start);
250
+ out += encodeField(source.slice(field.start, field.end), to);
251
+ cursor = field.end;
252
+ }
253
+ return out + source.slice(cursor);
254
+ }
255
+
93
256
  // ─── Type coercion ───────────────────────────────────────────────────────────
94
257
 
95
258
  export interface CsvOptions {
@@ -171,6 +334,18 @@ export class Csv {
171
334
  return coerceCSVRows(parseCSV(source), options.schema, options.idField);
172
335
  }
173
336
 
337
+ /**
338
+ * Replace authored cell values, preserving every other byte (pure, browser-safe).
339
+ *
340
+ * The `rewrite` capability. Csv declares it and deliberately declares no `serialize`: rows are
341
+ * loaded, not round-tripped, so there is no document to write back — but one cell's text can be
342
+ * changed without inventing an opinion about quoting, line endings or column order. See
343
+ * {@link rewriteCSV}.
344
+ */
345
+ static rewrite(source: string, edits: readonly CsvEdit[] = []): string {
346
+ return rewriteCSV(source, edits);
347
+ }
348
+
174
349
  /** List .csv entry files for a content-type source (file path or directory). */
175
350
  static async discover(source: string, options: { baseDir?: string } = {}): Promise<string[]> {
176
351
  const { existsSync, readdirSync } = await import("node:fs");
package/src/serialize.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * The single Jx → markdown serializer, replacing the studio's md-convert and the
5
5
  * compiler's compile-markdown. Two modes over a shared tag map:
6
6
  *
7
- * - `roundtrip` (default) — lossless: YAML frontmatter from non-children doc keys,
7
+ * - `roundtrip` (default) — lossless for everything it can express (a `tagName` chosen at render
8
+ * time cannot be, and throws): YAML frontmatter from non-children doc keys,
8
9
  * non-markdown elements emitted as remark directives with collapsed dot-path
9
10
  * attributes. Inverse of `transpileJxMarkdown()`.
10
11
  * - `export` — lossy: strips all Jx decoration, unwraps wrapper tags, inlines custom
@@ -191,7 +192,7 @@ function codeLang(codeChild: JxElement | undefined): string | null {
191
192
  // ─── Options ────────────────────────────────────────────────────────────────
192
193
 
193
194
  export interface SerializeOptions {
194
- /** `roundtrip` (lossless, default) or `export` (lossy clean markdown). */
195
+ /** `roundtrip` (default; lossless where expressible) or `export` (lossy clean markdown). */
195
196
  mode?: "roundtrip" | "export";
196
197
  /** Export mode: component definitions for inlining custom elements. */
197
198
  componentDefs?: Map<string, JxElement>;