@jxsuite/parser 1.4.1 → 1.5.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/src/md.ts CHANGED
@@ -18,6 +18,7 @@ import remarkParseFrontmatter from "remark-parse-frontmatter";
18
18
  import remarkGfm from "remark-gfm";
19
19
  import remarkDirective from "remark-directive";
20
20
  import { readFileSync } from "node:fs";
21
+ import { DEFAULT_FORMAT_LOCALE } from "@jxsuite/schema/intl";
21
22
  import { basename, extname, relative, resolve as resolvePath } from "node:path";
22
23
  import { globSync } from "glob";
23
24
  import { assignHeadingIds, mdastNodeToJx } from "./transpile.ts";
@@ -82,15 +83,62 @@ function mdastToString(node: MdastNode): string {
82
83
 
83
84
  // ─── Helpers ──────────────────────────────────────────────────────────────────
84
85
 
86
+ /** Average reading speed. See {@link readingTime} for why this is one number and not a table. */
87
+ const WORDS_PER_MINUTE = 200;
88
+
89
+ /**
90
+ * One segmenter, built once.
91
+ *
92
+ * The locale is fixed rather than the host's: word boundaries are a property of the text, and a
93
+ * word count that changed with the build machine's locale would be the same determinism bug the
94
+ * `Intl` helpers exist to avoid.
95
+ */
96
+ const WORD_SEGMENTER = new Intl.Segmenter(DEFAULT_FORMAT_LOCALE, { granularity: "word" });
97
+
98
+ /** A segment is a word when it carries a letter or a digit. See {@link countWords}. */
99
+ const WORD_LIKE = /[\p{L}\p{N}]/u;
100
+
101
+ /**
102
+ * Count the words in a string by **segmenting** it, not by splitting on whitespace (UAX #29).
103
+ *
104
+ * Splitting on `\s+` assumes a script that puts spaces between its words. Japanese, Chinese and
105
+ * Thai do not, so an entire article in any of them counted as **one word** — and therefore one
106
+ * minute to read, whatever its length. `Intl.Segmenter` applies the Unicode word-boundary rules.
107
+ *
108
+ * **A segment counts when it contains a letter or a digit, rather than when `isWordLike` says so.**
109
+ * That property is what `isWordLike` is defined to mean, but Bun's engine answers `false` for a
110
+ * mixed alphanumeric segment — `w0`, `v3`, `h1` — so trusting it would drop every token of that
111
+ * shape from the count, silently and only for some documents. The predicate is spelled out because
112
+ * it has to be correct on the runtime this actually runs on.
113
+ *
114
+ * This runs in the parser under Bun, so there is no browser-support question to weigh.
115
+ *
116
+ * @param {string} text
117
+ * @returns {number}
118
+ */
119
+ function countWords(text: string): number {
120
+ let words = 0;
121
+ for (const { segment } of WORD_SEGMENTER.segment(text)) {
122
+ if (WORD_LIKE.test(segment)) {
123
+ words += 1;
124
+ }
125
+ }
126
+ return words;
127
+ }
128
+
85
129
  /**
86
- * Estimate reading time based on word count (~200 wpm average).
130
+ * Estimate reading time from the word count.
131
+ *
132
+ * **200 wpm, and the limitation is stated rather than modelled.** Real reading speed varies by
133
+ * script, by density and by reader — a Japanese character carries more than a Latin one, so a
134
+ * per-script table would be more accurate and would also be a table of guesses nobody could verify.
135
+ * One honest constant that the docs name beats a plausible one that they cannot.
87
136
  *
88
137
  * @param {string} text
89
138
  * @returns {number} Minutes (rounded up, minimum 1)
90
139
  */
91
140
  function readingTime(text: string) {
92
- const words = text.split(/\s+/).filter(Boolean).length;
93
- return Math.max(1, Math.ceil(words / 200));
141
+ return Math.max(1, Math.ceil(countWords(text) / WORDS_PER_MINUTE));
94
142
  }
95
143
 
96
144
  /**
@@ -199,7 +247,7 @@ export function processMarkdown(
199
247
  $excerpt: excerpt,
200
248
  $readingTime: readingTime(plainText),
201
249
  $toc: toc,
202
- $wordCount: plainText.split(/\s+/).filter(Boolean).length,
250
+ $wordCount: countWords(plainText),
203
251
  frontmatter,
204
252
  path: filePath,
205
253
  slug,
package/src/serialize.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { unified } from "unified";
22
- import { isRef } from "@jxsuite/schema/guards";
22
+ import { isRef, tagNameCandidates } from "@jxsuite/schema/guards";
23
23
  import remarkStringify from "remark-stringify";
24
24
  import remarkDirective from "remark-directive";
25
25
  import remarkGfm from "remark-gfm";
@@ -494,7 +494,7 @@ function convertJxNode(
494
494
  return null;
495
495
  }
496
496
 
497
- const tag = el.tagName ?? "div";
497
+ const tag = serializableTag(el.tagName) || "div";
498
498
 
499
499
  // Not markdown-native or carries Jx-specific props → directive
500
500
  if (!allowlist.has(tag) || hasJxProps(el)) {
@@ -690,6 +690,31 @@ const JX_DOLLAR_KEYS = new Set([
690
690
  "$elements",
691
691
  ]);
692
692
 
693
+ /**
694
+ * The tag, for a serializer that must write ONE name.
695
+ *
696
+ * A markdown directive is named by a literal, so a tag chosen at creation cannot round-trip through
697
+ * this format: picking a candidate would silently rewrite the author's document into one branch of
698
+ * it, which is the failure the tag-name pattern was added to stop. Content documents do not carry
699
+ * chosen tags today — the construct is an element-document (component) feature — so this refuses
700
+ * rather than guesses, and says which candidates it saw.
701
+ *
702
+ * @param {unknown} tagName
703
+ * @returns {string} The literal tag, or "" when none is declared
704
+ */
705
+ function serializableTag(tagName: unknown): string {
706
+ if (typeof tagName === "string") {
707
+ return tagName;
708
+ }
709
+ if (tagName == null) {
710
+ return "";
711
+ }
712
+ throw new Error(
713
+ `Markdown cannot express a tag chosen at creation (candidates: ` +
714
+ `${tagNameCandidates(tagName).join(", ")}). Keep this element in a JSON component.`,
715
+ );
716
+ }
717
+
693
718
  /**
694
719
  * `$prototype` element types that serialize as a directive named after the prototype (no tagName),
695
720
  * e.g. `:::Array`. Mirrors the set in transpile.ts.
@@ -898,7 +923,7 @@ function nodeToMdast(
898
923
  return expandArray(node, ctx, scope);
899
924
  }
900
925
 
901
- const tag = node.tagName ?? "div";
926
+ const tag = serializableTag(node.tagName) || "div";
902
927
  const text = resolveText(node.textContent, ctx, scope);
903
928
 
904
929
  // InnerHTML — convert HTML content to mdast
package/src/transpile.ts CHANGED
@@ -725,18 +725,35 @@ export function transpileJxMarkdown(source: string) {
725
725
  // ─── Heading anchors ─────────────────────────────────────────────────────────
726
726
 
727
727
  /**
728
- * Slugify a heading's text into an anchor id: lowercase, punctuation stripped, spaces collapsed to
729
- * single hyphens. The one algorithm behind rendered heading `id`s, `$toc` entries, and search deep
730
- * links (specs/parser.md).
728
+ * Slugify a heading's text into an anchor id: normalized, lowercased, punctuation stripped, spaces
729
+ * collapsed to single hyphens. The one algorithm behind rendered heading `id`s, `$toc` entries, and
730
+ * search deep links (specs/parser.md).
731
+ *
732
+ * **NFC first, and that is the whole point of the change** (UAX #15). "Café" typed on macOS is `e`
733
+ * + U+0301; the same word typed on Windows, or pasted from a web page, is U+00E9. They look
734
+ * identical, they compare unequal, and they produced two different anchors — so a link written
735
+ * against one heading silently stopped working when someone re-typed the heading. Normalizing puts
736
+ * both spellings in one form before anything else looks at them.
737
+ *
738
+ * **`toLowerCase`, never `toLocaleLowerCase`.** Locale-sensitive casing would make the same heading
739
+ * produce different anchors in different projects — Turkish maps `I` to `ı`, not `i` — and an
740
+ * anchor is a URL, which belongs to the document rather than to the reader's locale.
741
+ *
742
+ * **`_` stays in the character class deliberately.** The old class was `\w`, which is
743
+ * `[A-Za-z0-9_]`; `\p{L}\p{N}` is the Unicode-aware replacement and does **not** include `_`.
744
+ * Keeping it means this function is **byte-identical on every pure-ASCII heading**, so no existing
745
+ * anchor on an English site moves — `tests/transpile.test.ts` asserts exactly that against a
746
+ * corpus. What changes is only headings that were being mangled anyway.
731
747
  *
732
748
  * @param {string} text
733
749
  * @returns {string}
734
750
  */
735
751
  export function slugifyHeading(text: string) {
736
752
  return text
753
+ .normalize("NFC")
737
754
  .toLowerCase()
738
- .replaceAll(/[^\w\s-]/g, "")
739
- .replaceAll(/\s+/g, "-")
755
+ .replaceAll(/[^\p{L}\p{N}_\s-]/gu, "")
756
+ .replaceAll(/\s+/gu, "-")
740
757
  .replaceAll(/-+/g, "-")
741
758
  .replaceAll(/^-|-$/g, "");
742
759
  }
@@ -780,7 +797,9 @@ export function assignHeadingIds(children?: (JxElement | string)[]): TocEntry[]
780
797
  if (typeof node !== "object" || node === null) {
781
798
  continue;
782
799
  }
783
- const tag = node.tagName;
800
+ // Heading extraction reads a literal tag; a chosen one is never a heading (every candidate
801
+ // Would have to be, and the construct exists to vary a WRAPPER), so it is simply not one.
802
+ const tag = typeof node.tagName === "string" ? node.tagName : "";
784
803
  if (tag && HEADING_TAGS.has(tag)) {
785
804
  const text = jxNodeText(node).trim();
786
805
  let id = typeof node.id === "string" && node.id.length > 0 ? node.id : "";