@jxsuite/parser 3.0.0 → 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 +12 -2
- package/dist/markdown.js +253 -36
- package/dist/serialize.js +29451 -29244
- package/package.json +3 -2
- package/src/Content.class.json +2 -0
- package/src/content-loader.ts +21 -2
- package/src/highlight.ts +20 -1
- package/src/html-to-jx.ts +3 -3
- package/src/md-html.ts +3 -3
- package/src/serialize.ts +185 -41
- package/src/source-style.ts +236 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/parser",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Jx markdown parser and external class integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"upgrade": "bun update --latest && bun install"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@jxsuite/markup": "^0.
|
|
51
|
+
"@jxsuite/markup": "^1.0.0",
|
|
52
52
|
"@jxsuite/schema": "^4.0.0",
|
|
53
53
|
"@shikijs/langs": "^4.5.0",
|
|
54
54
|
"@shikijs/themes": "^4.5.0",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@happy-dom/global-registrator": "^21.0.3",
|
|
70
70
|
"@jxsuite/runtime": "^5.0.0",
|
|
71
|
+
"@jxsuite/test-support": "^0.0.0",
|
|
71
72
|
"@types/mdast": "^4.0.4",
|
|
72
73
|
"ajv": "^8.20.0"
|
|
73
74
|
},
|
package/src/Content.class.json
CHANGED
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
"settings": {
|
|
19
19
|
"icon": "grid-four",
|
|
20
20
|
"label": "Content Types",
|
|
21
|
+
"intro": "Each content type is a collection, such as blog posts, team members or events: a folder of entries that share the fields you define here.",
|
|
21
22
|
"order": 50,
|
|
22
23
|
"layout": "map",
|
|
23
24
|
"entry": {
|
|
25
|
+
"noun": "content type",
|
|
24
26
|
"ui": { "schema": { "control": "schema-builder" } },
|
|
25
27
|
"newEntry": {
|
|
26
28
|
"source": "./content/${key}/",
|
package/src/content-loader.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* @module @jxsuite/parser/content-loader
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
21
21
|
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
22
22
|
import {
|
|
23
23
|
assetUrlFor,
|
|
@@ -182,6 +182,25 @@ export function localesForExpansion(projectConfig: ProjectConfig | undefined): s
|
|
|
182
182
|
return resolveI18n(projectConfig ?? {}).i18n?.locales ?? [];
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Whether `path` (relative to `root`) exists with exactly this spelling.
|
|
187
|
+
*
|
|
188
|
+
* `existsSync` alone answers "is there a directory called `fr-ca`" with yes when only `fr-CA`
|
|
189
|
+
* exists, on a case-insensitive filesystem (macOS and Windows by default), so the spelling that
|
|
190
|
+
* wins would depend on the machine. The filesystem's own name for the entry, from `realpath`, is
|
|
191
|
+
* compared segment for segment instead, which is the same answer everywhere.
|
|
192
|
+
*/
|
|
193
|
+
function existsWithSpelling(root: string, path: string): boolean {
|
|
194
|
+
const full = resolve(root, path);
|
|
195
|
+
if (!existsSync(full)) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
const wanted = full.split(/[\\/]/).filter(Boolean);
|
|
199
|
+
const actual = realpathSync.native(full).split(/[\\/]/).filter(Boolean);
|
|
200
|
+
const tail = actual.slice(-wanted.length);
|
|
201
|
+
return wanted.every((segment, index) => segment === tail[index]);
|
|
202
|
+
}
|
|
203
|
+
|
|
185
204
|
/**
|
|
186
205
|
* The directory a locale's entries live in, out of the spellings that can legitimately be on disk.
|
|
187
206
|
*
|
|
@@ -204,7 +223,7 @@ export function localeSource(root: string, source: string, locale: string): stri
|
|
|
204
223
|
const candidates = [locale, locale.toLowerCase()];
|
|
205
224
|
for (const spelling of candidates) {
|
|
206
225
|
const path = source.split(LOCALE_PLACEHOLDER).join(spelling);
|
|
207
|
-
if (
|
|
226
|
+
if (existsWithSpelling(root, path)) {
|
|
208
227
|
return path;
|
|
209
228
|
}
|
|
210
229
|
}
|
package/src/highlight.ts
CHANGED
|
@@ -80,6 +80,16 @@ function getHighlighter(): HighlighterCore {
|
|
|
80
80
|
],
|
|
81
81
|
themes: [githubLight, githubDark],
|
|
82
82
|
});
|
|
83
|
+
/* The first tokenization in a process comes out wrong in the dark theme (a space after `a` takes
|
|
84
|
+
the identifier blue, `=` loses its keyword red) and every later one of the same code is right,
|
|
85
|
+
on macOS and on Linux alike, so the first block of a build differed from every copy of it. The
|
|
86
|
+
cause is inside Shiki's first use of its JavaScript regex engine; one throwaway call before
|
|
87
|
+
anything is returned makes the first real call the second. */
|
|
88
|
+
_highlighter.codeToTokens("const a = 1;", {
|
|
89
|
+
defaultColor: false,
|
|
90
|
+
lang: "javascript",
|
|
91
|
+
themes: THEMES,
|
|
92
|
+
});
|
|
83
93
|
return _highlighter;
|
|
84
94
|
}
|
|
85
95
|
|
|
@@ -98,7 +108,16 @@ export function highlightFence(code: string, info: string): (JxElement | string)
|
|
|
98
108
|
if (!h.getLoadedLanguages().includes(lang)) {
|
|
99
109
|
return null;
|
|
100
110
|
}
|
|
101
|
-
|
|
111
|
+
/* No per-line time limit (Shiki's default is 500 ms). With one, a line that tokenizes slowly
|
|
112
|
+
(the first line of a grammar, while its regexes are still being compiled, on a loaded CI runner)
|
|
113
|
+
is cut short and its rest painted in whatever scope it stopped in, so the same fence built
|
|
114
|
+
twice could highlight differently. A build's output must not depend on the machine's speed. */
|
|
115
|
+
const { tokens } = h.codeToTokens(code, {
|
|
116
|
+
defaultColor: false,
|
|
117
|
+
lang,
|
|
118
|
+
themes: THEMES,
|
|
119
|
+
tokenizeTimeLimit: 0,
|
|
120
|
+
});
|
|
102
121
|
const out: (JxElement | string)[] = [];
|
|
103
122
|
for (const [i, line] of tokens.entries()) {
|
|
104
123
|
if (i > 0) {
|
package/src/html-to-jx.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Html-to-jx — compatibility shim.
|
|
3
3
|
*
|
|
4
|
-
* The HTML → Jx conversion pipeline moved to `@jxsuite/markup/html-to-jx`; this module
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The HTML → Jx conversion pipeline moved to `@jxsuite/markup/html-to-jx`; this module re-exports
|
|
5
|
+
* it so the parser's historical `@jxsuite/parser/html-to-jx` entrypoint keeps working for
|
|
6
|
+
* downstream consumers.
|
|
7
7
|
*
|
|
8
8
|
* @module @jxsuite/parser/html-to-jx
|
|
9
9
|
* @license MIT
|
package/src/md-html.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Md-html — compatibility shim.
|
|
3
3
|
*
|
|
4
|
-
* The browser-safe markdown → sanitized HTML pipeline moved to `@jxsuite/markup/md-html`;
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The browser-safe markdown → sanitized HTML pipeline moved to `@jxsuite/markup/md-html`; this
|
|
5
|
+
* module re-exports it so the parser's historical `@jxsuite/parser/md-html` entrypoint keeps
|
|
6
|
+
* working for downstream consumers.
|
|
7
7
|
*
|
|
8
8
|
* @module @jxsuite/parser/md-html
|
|
9
9
|
* @license MIT
|
package/src/serialize.ts
CHANGED
|
@@ -30,12 +30,15 @@ import {
|
|
|
30
30
|
tagNameCandidates,
|
|
31
31
|
} from "@jxsuite/schema/guards";
|
|
32
32
|
import type { TagNameSlot } from "@jxsuite/schema/guards";
|
|
33
|
+
import remarkParse from "remark-parse";
|
|
33
34
|
import remarkStringify from "remark-stringify";
|
|
34
35
|
import remarkDirective from "remark-directive";
|
|
35
36
|
import remarkGfm from "remark-gfm";
|
|
36
37
|
import { stringify } from "yaml";
|
|
37
38
|
import { htmlToJx } from "@jxsuite/markup/html-to-jx";
|
|
38
39
|
import { expandDotPaths } from "./transpile.ts";
|
|
40
|
+
import { bodyText, frontmatterText, markdownMarkers, spliceFrontmatter } from "./source-style.ts";
|
|
41
|
+
import type { MarkdownMarkers } from "./source-style.ts";
|
|
39
42
|
import type {
|
|
40
43
|
JsonObject,
|
|
41
44
|
JsonValue,
|
|
@@ -256,6 +259,12 @@ export interface SerializeOptions<ScopeValue = JxDocumentValue> {
|
|
|
256
259
|
allowlist?: ReadonlySet<string> | string[];
|
|
257
260
|
/** Roundtrip mode: emit YAML frontmatter from non-children doc keys (default true). */
|
|
258
261
|
frontmatter?: boolean;
|
|
262
|
+
/**
|
|
263
|
+
* Roundtrip mode: the text the document was read from. Every frontmatter key whose value is
|
|
264
|
+
* unchanged keeps its own spelling (quotes, wrapping, block style, comments), and the body is
|
|
265
|
+
* written with the emphasis, strong and bullet markers the source used (`source-style.ts`).
|
|
266
|
+
*/
|
|
267
|
+
source?: string;
|
|
259
268
|
}
|
|
260
269
|
|
|
261
270
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
@@ -567,6 +576,112 @@ function hasJxProps(el: JxElement) {
|
|
|
567
576
|
return false;
|
|
568
577
|
}
|
|
569
578
|
|
|
579
|
+
/**
|
|
580
|
+
* The attributes each markdown-native tag can state in markdown syntax: a link's `href` and
|
|
581
|
+
* `title`, an image's `src`, `alt` and `title`, an ordered list's `start`. Any other attribute (an
|
|
582
|
+
* image's `width` and `height`, a heading's `id`) has no markdown spelling, so an element that
|
|
583
|
+
* carries one is written as a directive, which keeps every attribute, rather than as markdown that
|
|
584
|
+
* would drop it.
|
|
585
|
+
*/
|
|
586
|
+
const MD_ATTRIBUTES = new Map([
|
|
587
|
+
["a", new Set(["href", "title"])],
|
|
588
|
+
["img", new Set(["alt", "src", "title"])],
|
|
589
|
+
["ol", new Set(["start"])],
|
|
590
|
+
]);
|
|
591
|
+
|
|
592
|
+
/** Whether every attribute an element carries has a markdown spelling for its tag. */
|
|
593
|
+
function hasOnlyMarkdownAttributes(el: JxElement, tag: string): boolean {
|
|
594
|
+
const allowed = MD_ATTRIBUTES.get(tag);
|
|
595
|
+
return Object.keys(el.attributes ?? {}).every((name) => allowed?.has(name) ?? false);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The parts of a table. They are markdown only inside a table GFM can write ({@link isGfmTable}),
|
|
600
|
+
* which converts them itself; met anywhere else (a directive table's `tbody`, a stray `tr`) they
|
|
601
|
+
* are directives, since they have no markdown form of their own.
|
|
602
|
+
*/
|
|
603
|
+
const TABLE_PARTS: ReadonlySet<string> = new Set(["tbody", "td", "th", "thead", "tr"]);
|
|
604
|
+
|
|
605
|
+
/** A table part's children, without the whitespace an HTML source left between its tags. */
|
|
606
|
+
function partChildren(el: JxElement): (JxElement | string)[] {
|
|
607
|
+
return childList(el).filter((child) => !isString(child) || child.trim() !== "");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** `slot` as a plain element of `tag`: no attributes and no Jx props, or `null`. */
|
|
611
|
+
function plainPart(slot: JxElement | string | undefined, tag: string): JxElement | null {
|
|
612
|
+
if (slot === undefined || isString(slot) || slot.tagName !== tag) {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
const attributes = Object.keys(slot.attributes ?? {});
|
|
616
|
+
return attributes.length === 0 && !hasJxProps(slot) ? slot : null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Whether a table cell holds only what a GFM cell can: one line of inline content. */
|
|
620
|
+
function isGfmCell(cell: JxElement): boolean {
|
|
621
|
+
if (cell.textContent != null) {
|
|
622
|
+
return !String(cell.textContent).includes("\n");
|
|
623
|
+
}
|
|
624
|
+
return childList(cell).every((child) =>
|
|
625
|
+
isString(child)
|
|
626
|
+
? !child.includes("\n")
|
|
627
|
+
: isString(child.tagName) && (MD_INLINE.has(child.tagName) || child.tagName === "span"),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** A row's cells when every one is a plain `cellTag` holding GFM cell content, else `null`. */
|
|
632
|
+
function gfmRowCells(row: JxElement | string | undefined, cellTag: string): JxElement[] | null {
|
|
633
|
+
const tr = plainPart(row, "tr");
|
|
634
|
+
if (!tr || tr.textContent != null) {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
const cells: JxElement[] = [];
|
|
638
|
+
for (const slot of partChildren(tr)) {
|
|
639
|
+
const cell = plainPart(slot, cellTag);
|
|
640
|
+
if (!cell || !isGfmCell(cell)) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
cells.push(cell);
|
|
644
|
+
}
|
|
645
|
+
return cells.length > 0 ? cells : null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The rows of a table GFM can write and read back as the same element tree, or `null`: a `thead`
|
|
650
|
+
* holding one row of `th` cells, then optionally a `tbody` whose every row has as many `td` cells,
|
|
651
|
+
* none of them carrying an attribute or anything but one line of inline content. That is exactly
|
|
652
|
+
* the shape the parser makes of a pipe table. Any other table (no header row, `td` cells in the
|
|
653
|
+
* head, a caption, a class) would come back as a different tree, so it stays a directive.
|
|
654
|
+
*/
|
|
655
|
+
function gfmTableRows(el: JxElement): { head: JxElement[]; body: JxElement[][] } | null {
|
|
656
|
+
if (el.textContent != null) {
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
const [headSlot, bodySlot, ...rest] = partChildren(el);
|
|
660
|
+
const thead = plainPart(headSlot, "thead");
|
|
661
|
+
if (!thead || rest.length > 0) {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
const headRows = partChildren(thead);
|
|
665
|
+
const head = headRows.length === 1 ? gfmRowCells(headRows[0], "th") : null;
|
|
666
|
+
if (!head) {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
if (bodySlot === undefined) {
|
|
670
|
+
return { body: [], head };
|
|
671
|
+
}
|
|
672
|
+
const tbody = plainPart(bodySlot, "tbody");
|
|
673
|
+
const rows = tbody ? partChildren(tbody) : [];
|
|
674
|
+
const body: JxElement[][] = [];
|
|
675
|
+
for (const row of rows) {
|
|
676
|
+
const cells = gfmRowCells(row, "td");
|
|
677
|
+
if (!cells || cells.length !== head.length) {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
body.push(cells);
|
|
681
|
+
}
|
|
682
|
+
return body.length > 0 ? { body, head } : null;
|
|
683
|
+
}
|
|
684
|
+
|
|
570
685
|
function convertJxNode(
|
|
571
686
|
slot: JxDocumentValue | undefined,
|
|
572
687
|
isBlock: boolean,
|
|
@@ -586,8 +701,19 @@ function convertJxNode(
|
|
|
586
701
|
|
|
587
702
|
const tag = serializableTag(el.tagName) || "div";
|
|
588
703
|
|
|
589
|
-
|
|
590
|
-
|
|
704
|
+
/* Not markdown-native, carries Jx-specific props or an attribute markdown cannot spell, or is a
|
|
705
|
+
table part outside a GFM table → directive. So is an image standing at block level: markdown
|
|
706
|
+
has only the inline ``, which would run into the paragraph beside it, and the
|
|
707
|
+
`::img{…}` leaf it was read from is its exact form. */
|
|
708
|
+
const tableRows = tag === "table" ? gfmTableRows(el) : null;
|
|
709
|
+
if (
|
|
710
|
+
!allowlist.has(tag) ||
|
|
711
|
+
hasJxProps(el) ||
|
|
712
|
+
!hasOnlyMarkdownAttributes(el, tag) ||
|
|
713
|
+
TABLE_PARTS.has(tag) ||
|
|
714
|
+
(tag === "table" && tableRows === null) ||
|
|
715
|
+
(isBlock && tag === "img")
|
|
716
|
+
) {
|
|
591
717
|
return convertToDirective(el, isBlock, allowlist);
|
|
592
718
|
}
|
|
593
719
|
|
|
@@ -696,39 +822,21 @@ function convertJxNode(
|
|
|
696
822
|
}
|
|
697
823
|
|
|
698
824
|
case "table": {
|
|
699
|
-
// Flatten thead/tbody back to rows
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const mdRow = convertJxNode(row, true, allowlist);
|
|
708
|
-
if (mdRow) {
|
|
709
|
-
if (section.tagName === "thead") {
|
|
710
|
-
for (const cell of mdRow.children ?? []) {
|
|
711
|
-
cell.isHeader = true;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
rows.push(mdRow);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
return { children: rows, type: "table" };
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
case "tableRow": {
|
|
723
|
-
return {
|
|
724
|
-
children: convertJxChildren(childList(el), false, allowlist),
|
|
825
|
+
// Flatten thead/tbody back to rows: the header row first, as GFM writes it.
|
|
826
|
+
const { body, head } = tableRows ?? { body: [], head: [] };
|
|
827
|
+
const row = (cells: readonly JxElement[], isHeader: boolean): MdastNode => ({
|
|
828
|
+
children: cells.map((cell) => ({
|
|
829
|
+
children: inline(cell),
|
|
830
|
+
type: "tableCell",
|
|
831
|
+
...(isHeader && { isHeader: true }),
|
|
832
|
+
})),
|
|
725
833
|
type: "tableRow",
|
|
834
|
+
});
|
|
835
|
+
return {
|
|
836
|
+
children: [row(head, true), ...body.map((cells) => row(cells, false))],
|
|
837
|
+
type: "table",
|
|
726
838
|
};
|
|
727
839
|
}
|
|
728
|
-
|
|
729
|
-
case "tableCell": {
|
|
730
|
-
return { children: inline(el), type: "tableCell" };
|
|
731
|
-
}
|
|
732
840
|
default: {
|
|
733
841
|
break;
|
|
734
842
|
}
|
|
@@ -1736,6 +1844,10 @@ export function serializeJxMarkdown<ScopeValue>(
|
|
|
1736
1844
|
`declare module`, so the type-aware linter sees this import as an error type, not that Plugin. */
|
|
1737
1845
|
const remarkStringifyPlugin = remarkStringify as UnifiedPlugin;
|
|
1738
1846
|
|
|
1847
|
+
/* SAFETY: remark-parse is a shorthand `declare module` in the root types.d.ts (its default import is
|
|
1848
|
+
`any`); it default-exports a unified attacher taking no settings, the UnifiedPlugin shape. */
|
|
1849
|
+
const remarkParsePlugin = remarkParse as UnifiedPlugin;
|
|
1850
|
+
|
|
1739
1851
|
/** A fresh unified processor, seen through the surface this module drives. */
|
|
1740
1852
|
function createProcessor(): UnifiedProcessor {
|
|
1741
1853
|
/* SAFETY: unified() returns a Processor, which has the use/parse/runSync/stringify members
|
|
@@ -1760,25 +1872,57 @@ function serializeRoundtrip<ScopeValue>(
|
|
|
1760
1872
|
}
|
|
1761
1873
|
|
|
1762
1874
|
if (Object.keys(frontmatter).length > 0) {
|
|
1763
|
-
|
|
1875
|
+
const sourceYaml = opts.source === undefined ? null : frontmatterText(opts.source);
|
|
1876
|
+
const spliced =
|
|
1877
|
+
sourceYaml === null
|
|
1878
|
+
? null
|
|
1879
|
+
: spliceFrontmatter(frontmatter, sourceYaml, (values) => stringifyFrontmatter(values));
|
|
1880
|
+
lines.push("---", spliced ?? stringifyFrontmatter(frontmatter), "---", "");
|
|
1764
1881
|
}
|
|
1765
1882
|
}
|
|
1766
1883
|
|
|
1767
1884
|
if (Array.isArray(doc.children) && doc.children.length > 0) {
|
|
1768
1885
|
const mdast = jxToMdast(doc, opts);
|
|
1886
|
+
const markers =
|
|
1887
|
+
opts.source === undefined
|
|
1888
|
+
? { bullet: "-", emphasis: "*", strong: "*" }
|
|
1889
|
+
: sourceMarkers(opts.source);
|
|
1769
1890
|
const processor = createProcessor()
|
|
1770
1891
|
.use(remarkGfm)
|
|
1771
1892
|
.use(remarkDirective)
|
|
1772
|
-
.use(remarkStringifyPlugin, {
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
lines.push(
|
|
1893
|
+
.use(remarkStringifyPlugin, { ...markers });
|
|
1894
|
+
// Runs of blank lines are squeezed in the body only: inside the frontmatter they are a block
|
|
1895
|
+
// Scalar's own text (a `|-` excerpt's paragraph breaks), and squeezing them edits a value.
|
|
1896
|
+
lines.push(processor.stringify(mdast).replaceAll(/\n{3,}/g, "\n\n"));
|
|
1776
1897
|
}
|
|
1777
1898
|
|
|
1778
|
-
return `${lines
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1899
|
+
return `${lines.join("\n").trim()}\n`;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* The frontmatter block's YAML, written so that every reader gets back the values it was given.
|
|
1904
|
+
*
|
|
1905
|
+
* - `lineWidth: 0`: a long string stays on its line. Folding it (`>`) is a style the author did not
|
|
1906
|
+
* choose, and a one-word edit that refolds every paragraph of an excerpt is a diff nobody can
|
|
1907
|
+
* review.
|
|
1908
|
+
* - YAML 1.1 quoting: a string that a YAML 1.1 reader would type (`"2024-07-26T17:39:36Z"`, a
|
|
1909
|
+
* timestamp; `"yes"`, a boolean) stays quoted. The 1.2 core schema this package reads with calls
|
|
1910
|
+
* them strings unquoted, but the loaders a site's other tools use (js-yaml, gray-matter) do not,
|
|
1911
|
+
* and a date that was a string must not become a `Date` because Studio saved the file.
|
|
1912
|
+
*/
|
|
1913
|
+
/** The markers the body of `source` was written with (`source-style.ts`). */
|
|
1914
|
+
function sourceMarkers(source: string): MarkdownMarkers {
|
|
1915
|
+
const body = bodyText(source);
|
|
1916
|
+
const tree = createProcessor()
|
|
1917
|
+
.use(remarkParsePlugin)
|
|
1918
|
+
.use(remarkGfm)
|
|
1919
|
+
.use(remarkDirective)
|
|
1920
|
+
.parse(body);
|
|
1921
|
+
return markdownMarkers(tree, body);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function stringifyFrontmatter(frontmatter: DirectiveProps): string {
|
|
1925
|
+
return stringify(frontmatter, { lineWidth: 0, version: "1.1" }).trim();
|
|
1782
1926
|
}
|
|
1783
1927
|
|
|
1784
1928
|
function serializeExport<ScopeValue>(doc: JxDocument, opts: SerializeOptions<ScopeValue>): string {
|
|
@@ -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
|
+
}
|