@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/dist/content-loader.js +281 -23
- package/dist/content.js +117 -16
- package/dist/csv.js +10 -8
- package/dist/markdown.js +281 -162
- package/dist/serialize.js +167 -140
- package/package.json +8 -8
- package/src/Markdown.class.json +1 -1
- package/src/content-loader.ts +200 -14
- package/src/content.ts +38 -7
- package/src/dates.ts +184 -0
- package/src/markdown.ts +13 -1
- package/src/md.ts +52 -4
- package/src/serialize.ts +28 -3
- package/src/transpile.ts +25 -6
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
729
|
-
* single hyphens. The one algorithm behind rendered heading `id`s, `$toc` entries, and
|
|
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(/[^\
|
|
739
|
-
.replaceAll(/\s+/
|
|
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
|
-
|
|
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 : "";
|