@jxsuite/parser 1.4.1 → 1.5.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/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 : "";