@jxsuite/parser 2.0.1 → 4.0.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/dist/content-loader.js +620 -286
- package/dist/content.js +183 -30
- package/dist/markdown.js +641 -382
- package/dist/serialize.js +28852 -28615
- package/package.json +6 -5
- package/src/Content.class.json +2 -0
- package/src/alerts.ts +22 -7
- package/src/content-data.ts +40 -0
- package/src/content-links.ts +15 -7
- package/src/content-loader.ts +702 -223
- package/src/content-routes.ts +23 -10
- package/src/content-rules.ts +40 -29
- package/src/content.ts +108 -70
- package/src/csv.ts +18 -14
- package/src/dates.ts +18 -13
- package/src/highlight.ts +26 -6
- package/src/html-to-jx.ts +3 -3
- package/src/inert.ts +7 -5
- package/src/markdown.ts +27 -23
- package/src/md-html.ts +3 -3
- package/src/md.ts +133 -64
- package/src/serialize.ts +544 -299
- package/src/source-style.ts +236 -0
- package/src/transpile.ts +182 -152
- package/src/types.ts +43 -11
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Markdown file's own text says about how it is written, read back so a save keeps it.
|
|
3
|
+
*
|
|
4
|
+
* Studio saves a Markdown document by serializing a Jx tree, and a tree holds values, not spelling.
|
|
5
|
+
* Without the text the file was read as, every save re-spells everything: a quoted `"717-669-8739"`
|
|
6
|
+
* loses its quotes, a plain scalar the author wrapped over three lines is joined, a long string
|
|
7
|
+
* becomes a `|` block, and `_emphasis_` becomes `*emphasis*`. A one-word edit then produces a diff
|
|
8
|
+
* of the whole file. The serializer's `source` option hands that text back, and this module is what
|
|
9
|
+
* reads it:
|
|
10
|
+
*
|
|
11
|
+
* - {@link spliceFrontmatter} keeps every top-level frontmatter key whose value did not change as the
|
|
12
|
+
* exact bytes it was read as, re-stringifies only the keys that changed, drops the removed ones
|
|
13
|
+
* and appends the new ones.
|
|
14
|
+
* - {@link markdownMarkers} reports the emphasis, strong and bullet markers the body was written
|
|
15
|
+
* with, so the stringifier writes them the same way.
|
|
16
|
+
*
|
|
17
|
+
* @module @jxsuite/parser/source-style
|
|
18
|
+
* @license MIT
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isMap, isScalar, parseDocument } from "yaml";
|
|
22
|
+
import type { Node, Pair } from "yaml";
|
|
23
|
+
import { isJsonObject, isNumber, isString } from "@jxsuite/schema/guards";
|
|
24
|
+
import type { JxDocumentValue } from "@jxsuite/schema/types";
|
|
25
|
+
import type { MdastNode } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
/** Opening `---` line, the block's text, and the closing `---` line. */
|
|
28
|
+
const FRONTMATTER_RE = /^---\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/u;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The YAML text of a source's leading frontmatter block, without its fences; `null` when the source
|
|
32
|
+
* opens with none. An empty block (`---` twice) is the empty string.
|
|
33
|
+
*/
|
|
34
|
+
export function frontmatterText(source: string): string | null {
|
|
35
|
+
const match = FRONTMATTER_RE.exec(source);
|
|
36
|
+
if (!match) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return match[1] ?? "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The source with its leading frontmatter block (fences included) taken off. */
|
|
43
|
+
export function bodyText(source: string): string {
|
|
44
|
+
return source.replace(FRONTMATTER_RE, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A frontmatter value as `yaml.parse` and a Jx document both hold it. */
|
|
48
|
+
type FrontmatterValue = JxDocumentValue | undefined;
|
|
49
|
+
|
|
50
|
+
/** A frontmatter block's values by key. */
|
|
51
|
+
export type FrontmatterValues = Record<string, FrontmatterValue>;
|
|
52
|
+
|
|
53
|
+
/** Whether two frontmatter values are the same value: JSON equality, key order ignored. */
|
|
54
|
+
export function sameValue(a: FrontmatterValue, b: FrontmatterValue): boolean {
|
|
55
|
+
if (a === b) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
59
|
+
return (
|
|
60
|
+
Array.isArray(a) &&
|
|
61
|
+
Array.isArray(b) &&
|
|
62
|
+
a.length === b.length &&
|
|
63
|
+
a.every((item, index) => sameValue(item, b[index]))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (!isJsonObject(a) || !isJsonObject(b)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const keys = Object.keys(a).filter((key) => a[key] !== undefined);
|
|
70
|
+
const otherKeys = Object.keys(b).filter((key) => b[key] !== undefined);
|
|
71
|
+
return keys.length === otherKeys.length && keys.every((key) => sameValue(a[key], b[key]));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The offset of the start of the line `offset` sits on. */
|
|
75
|
+
function lineStart(text: string, offset: number): number {
|
|
76
|
+
return text.lastIndexOf("\n", offset - 1) + 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The offset just past the end of the line `offset` sits on (past its newline, when it has one). */
|
|
80
|
+
function lineEnd(text: string, offset: number): number {
|
|
81
|
+
const newline = text.indexOf("\n", offset);
|
|
82
|
+
return newline === -1 ? text.length : newline + 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One top-level key of the source block: where its text starts and ends, and its value. */
|
|
86
|
+
interface SourceEntry {
|
|
87
|
+
key: string;
|
|
88
|
+
/** Offset of the first line of the key. */
|
|
89
|
+
start: number;
|
|
90
|
+
/** Offset just past the last line of its value. */
|
|
91
|
+
valueEnd: number;
|
|
92
|
+
value: FrontmatterValue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The source block's top-level keys, in order; `null` when the block is not a plain block map. */
|
|
96
|
+
function sourceEntries(text: string): SourceEntry[] | null {
|
|
97
|
+
const doc = parseDocument(text);
|
|
98
|
+
if (doc.errors.length > 0 || doc.warnings.length > 0) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const { contents } = doc;
|
|
102
|
+
if (!isMap(contents) || contents.flow === true) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/* SAFETY: a parsed document's map holds parsed pairs, whose key and value are nodes (or null for
|
|
106
|
+
a key with no value); `parseDocument` builds no other shape. */
|
|
107
|
+
const pairs = contents.items as Pair<Node, Node | null>[];
|
|
108
|
+
const keys: { name: string; range: [number, number, number] }[] = [];
|
|
109
|
+
for (const { key } of pairs) {
|
|
110
|
+
if (!isScalar(key) || !key.range || !isString(key.value)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
keys.push({ name: key.value, range: key.range });
|
|
114
|
+
}
|
|
115
|
+
// A block map's JS is a plain object keyed by the string keys just checked.
|
|
116
|
+
const js: unknown = doc.toJS();
|
|
117
|
+
const values = isJsonObject(js) ? js : {};
|
|
118
|
+
const entries: SourceEntry[] = [];
|
|
119
|
+
for (const [index, { name, range }] of keys.entries()) {
|
|
120
|
+
const end = pairs[index]?.value?.range?.[1] ?? range[1];
|
|
121
|
+
entries.push({
|
|
122
|
+
key: name,
|
|
123
|
+
start: lineStart(text, range[0]),
|
|
124
|
+
value: values[name],
|
|
125
|
+
valueEnd: lineEnd(text, Math.max(end - 1, range[0])),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return entries;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The frontmatter block's YAML, keeping the source's own text for every key that did not change.
|
|
133
|
+
*
|
|
134
|
+
* A top-level key whose value equals the value the source spelled is copied from the source byte
|
|
135
|
+
* for byte, its comments and blank lines with it. A key whose value changed is written by `write`
|
|
136
|
+
* in the source key's place, keeping the comment lines that followed it. A key the document no
|
|
137
|
+
* longer has is dropped, and a key the source never had is written by `write` after the last one.
|
|
138
|
+
* Anything above the first key (a leading comment) is kept.
|
|
139
|
+
*
|
|
140
|
+
* @param frontmatter The values the document holds now.
|
|
141
|
+
* @param source The YAML text the file was read with ({@link frontmatterText}).
|
|
142
|
+
* @param write Writes a mapping of keys as YAML, without a trailing newline.
|
|
143
|
+
* @returns The new block's text, or `null` when the source is not a block map this can splice (a
|
|
144
|
+
* flow map, a parse error, a non-string key), in which case the caller writes the whole block.
|
|
145
|
+
*/
|
|
146
|
+
export function spliceFrontmatter(
|
|
147
|
+
frontmatter: FrontmatterValues,
|
|
148
|
+
source: string,
|
|
149
|
+
write: (values: FrontmatterValues) => string,
|
|
150
|
+
): string | null {
|
|
151
|
+
const entries = sourceEntries(source);
|
|
152
|
+
if (!entries) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const present = (key: string) =>
|
|
156
|
+
Object.hasOwn(frontmatter, key) && frontmatter[key] !== undefined;
|
|
157
|
+
const segments: string[] = [];
|
|
158
|
+
const head = source.slice(0, entries[0]?.start ?? source.length);
|
|
159
|
+
if (head.trim().length > 0) {
|
|
160
|
+
segments.push(head);
|
|
161
|
+
}
|
|
162
|
+
for (const [index, entry] of entries.entries()) {
|
|
163
|
+
const chunkEnd = entries[index + 1]?.start ?? source.length;
|
|
164
|
+
if (!present(entry.key)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (sameValue(entry.value, frontmatter[entry.key])) {
|
|
168
|
+
segments.push(source.slice(entry.start, chunkEnd));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const tail = source.slice(Math.min(entry.valueEnd, chunkEnd), chunkEnd);
|
|
172
|
+
segments.push(`${write({ [entry.key]: frontmatter[entry.key] })}\n${tail}`);
|
|
173
|
+
}
|
|
174
|
+
const known = new Set(entries.map((entry) => entry.key));
|
|
175
|
+
const added: FrontmatterValues = {};
|
|
176
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
177
|
+
if (!known.has(key) && value !== undefined) {
|
|
178
|
+
added[key] = value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (Object.keys(added).length > 0) {
|
|
182
|
+
segments.push(write(added));
|
|
183
|
+
}
|
|
184
|
+
return segments
|
|
185
|
+
.map((segment) => (segment.endsWith("\n") ? segment : `${segment}\n`))
|
|
186
|
+
.join("")
|
|
187
|
+
.replace(/\s+$/u, "");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The markers a Markdown body was written with, for remark-stringify's options of the same name. */
|
|
191
|
+
export interface MarkdownMarkers {
|
|
192
|
+
emphasis: "*" | "_";
|
|
193
|
+
strong: "*" | "_";
|
|
194
|
+
bullet: "-" | "*" | "+";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The part of a unist position this module reads. */
|
|
198
|
+
interface StartPosition {
|
|
199
|
+
start?: { offset?: number };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The source offset a parsed mdast node starts at, when the parser recorded one. */
|
|
203
|
+
function startOffset(node: MdastNode): number | null {
|
|
204
|
+
/* SAFETY: remark-parse gives every node it parses a unist `Position`, whose `start` is a point
|
|
205
|
+
carrying an `offset`; a hand-built node has none, and every read below is optional. */
|
|
206
|
+
const position = node.position as StartPosition | undefined;
|
|
207
|
+
const offset = position?.start?.offset;
|
|
208
|
+
return isNumber(offset) ? offset : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The emphasis, strong and bullet markers `tree` (parsed from `source`) was written with: the first
|
|
213
|
+
* of each kind decides. A kind the body never uses keeps the house default (`*`, `*`, `-`).
|
|
214
|
+
*/
|
|
215
|
+
export function markdownMarkers(tree: MdastNode, source: string): MarkdownMarkers {
|
|
216
|
+
const markers: MarkdownMarkers = { bullet: "-", emphasis: "*", strong: "*" };
|
|
217
|
+
const seen = new Set<string>();
|
|
218
|
+
const visit = (node: MdastNode) => {
|
|
219
|
+
const offset = startOffset(node);
|
|
220
|
+
if (offset !== null && !seen.has(node.type)) {
|
|
221
|
+
const char = source[offset];
|
|
222
|
+
if ((node.type === "emphasis" || node.type === "strong") && (char === "*" || char === "_")) {
|
|
223
|
+
markers[node.type] = char;
|
|
224
|
+
seen.add(node.type);
|
|
225
|
+
} else if (node.type === "listItem" && (char === "-" || char === "*" || char === "+")) {
|
|
226
|
+
markers.bullet = char;
|
|
227
|
+
seen.add(node.type);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const child of node.children ?? []) {
|
|
231
|
+
visit(child);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
visit(tree);
|
|
235
|
+
return markers;
|
|
236
|
+
}
|