@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/parser",
3
- "version": "3.0.0",
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.4.13",
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
  },
@@ -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}/",
@@ -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 (existsSync(resolve(root, path))) {
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
- const { tokens } = h.codeToTokens(code, { defaultColor: false, lang, themes: THEMES });
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
- * re-exports it so the parser's historical `@jxsuite/parser/html-to-jx` entrypoint keeps
6
- * working for downstream consumers.
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
- * this module re-exports it so the parser's historical `@jxsuite/parser/md-html`
6
- * entrypoint keeps working for downstream consumers.
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
- // Not markdown-native or carries Jx-specific props → directive
590
- if (!allowlist.has(tag) || hasJxProps(el)) {
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 `![alt](src)`, 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 rows: MdastNode[] = [];
701
- for (const section of childList(el)) {
702
- if (isString(section)) {
703
- continue;
704
- }
705
- if (section.tagName === "thead" || section.tagName === "tbody") {
706
- for (const row of childList(section)) {
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
- lines.push("---", stringify(frontmatter).trim(), "---", "");
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, { bullet: "-", emphasis: "*", strong: "*" });
1773
- const md = processor.stringify(mdast);
1774
-
1775
- lines.push(md);
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
- .join("\n")
1780
- .replaceAll(/\n{3,}/g, "\n\n")
1781
- .trim()}\n`;
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
+ }