@jxsuite/parser 1.0.0 → 1.2.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": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Jx markdown parser and external class integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,8 +48,10 @@
48
48
  "upgrade": "bunx npm-check-updates -u && bun install"
49
49
  },
50
50
  "dependencies": {
51
- "@jxsuite/markup": "^0.2.0",
52
- "@jxsuite/schema": "^1.0.0",
51
+ "@jxsuite/markup": "^0.3.1",
52
+ "@jxsuite/schema": "^1.2.0",
53
+ "@shikijs/langs": "^4.3.1",
54
+ "@shikijs/themes": "^4.3.1",
53
55
  "glob": "^13.0.6",
54
56
  "mdast-util-to-string": "^4.0.0",
55
57
  "remark-directive": "^4.0.0",
@@ -58,13 +60,14 @@
58
60
  "remark-parse": "^11.0.0",
59
61
  "remark-parse-frontmatter": "^1.0.3",
60
62
  "remark-stringify": "^11.0.0",
63
+ "shiki": "^4.3.1",
61
64
  "unified": "^11.0.5",
62
65
  "unist-util-visit": "^5.1.0",
63
66
  "yaml": "^2.9.0"
64
67
  },
65
68
  "devDependencies": {
66
69
  "@happy-dom/global-registrator": "^20.10.6",
67
- "@jxsuite/runtime": "^1.0.0",
70
+ "@jxsuite/runtime": "^1.2.0",
68
71
  "@types/mdast": "^4.0.4",
69
72
  "ajv": "^8.20.0"
70
73
  },
@@ -17,7 +17,7 @@
17
17
 
18
18
  "$studio": {
19
19
  "icon": "table",
20
- "modes": ["source"]
20
+ "modes": ["grid", "source"]
21
21
  },
22
22
 
23
23
  "$defs": {
@@ -269,12 +269,15 @@ async function loadContentType(
269
269
  })) as string[])
270
270
  : [resolvedSource];
271
271
 
272
+ // Directory sources pass their resolved root so formats can derive path-based ids for nested files
273
+ const sourceRoot = extname(source) ? undefined : resolvedSource;
272
274
  const entries: ContentLoaderEntry[] = [];
273
275
  for (const filePath of files) {
274
276
  entries.push(
275
277
  ...((await entry.call("load", filePath, {
276
278
  directiveOptions,
277
279
  schema,
280
+ ...(sourceRoot !== undefined && { sourceRoot }),
278
281
  })) as ContentLoaderEntry[]),
279
282
  );
280
283
  }
@@ -374,7 +377,7 @@ function refTargetName(ref: string | undefined): string | undefined {
374
377
  * field `$ref` is to-one: a blog post's `author: "jane-doe"` with a schema `$ref` of
375
378
  * `#/content/authors` gets replaced by the full author entry. An array field whose `items` carry
376
379
  * the `$ref` is to-many: each id in the stored array is replaced by its entry. Unresolvable ids are
377
- * left untouched.
380
+ * left untouched, with a warning naming the entry and the missing target.
378
381
  *
379
382
  * @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types
380
383
  * @param {ContentSection} section - The `content` section value the types were loaded from
@@ -403,6 +406,9 @@ export function resolveContentTypeRefs(
403
406
  }
404
407
  const refEntries = contentTypes.get(target);
405
408
  if (!refEntries) {
409
+ console.warn(
410
+ `Content relationships: "${name}" field "${field}" references unknown content type "${target}"`,
411
+ );
406
412
  continue;
407
413
  }
408
414
 
@@ -412,11 +418,24 @@ export function resolveContentTypeRefs(
412
418
  const resolved = refEntries.find((e) => e.id === value);
413
419
  if (resolved) {
414
420
  entry.data[field] = resolved;
421
+ } else {
422
+ console.warn(
423
+ `Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${value}"`,
424
+ );
415
425
  }
416
426
  } else if (toMany && Array.isArray(value)) {
417
- entry.data[field] = value.map((id) =>
418
- typeof id === "string" ? (refEntries.find((e) => e.id === id) ?? id) : id,
419
- );
427
+ entry.data[field] = value.map((id) => {
428
+ if (typeof id !== "string") {
429
+ return id;
430
+ }
431
+ const resolved = refEntries.find((e) => e.id === id);
432
+ if (!resolved) {
433
+ console.warn(
434
+ `Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${id}"`,
435
+ );
436
+ }
437
+ return resolved ?? id;
438
+ });
420
439
  }
421
440
  }
422
441
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Build-time syntax highlighting for fenced code blocks (node-only).
3
+ *
4
+ * A synchronous Shiki core (JavaScript regex engine — no WASM, no async) tokenizes a fixed
5
+ * grammar set against a light + dark GitHub theme pair. Tokens are emitted as span elements
6
+ * carrying both colors as CSS custom properties (--shiki-light / --shiki-dark); the page's
7
+ * stylesheet decides which one paints, so highlighting follows the color-scheme contract
8
+ * (spec §9.5) for auto and forced schemes alike.
9
+ *
10
+ * Lives outside the browser-safe transpile module: only the compile-time markdown path
11
+ * (processMarkdown in md.ts) pays for the grammars.
12
+ *
13
+ * @docs framework/site/jx-markdown
14
+ * @module @jxsuite/parser/highlight
15
+ * @license MIT
16
+ */
17
+
18
+ import { createHighlighterCoreSync } from "shiki/core";
19
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
20
+ import githubDark from "@shikijs/themes/github-dark-default";
21
+ import githubLight from "@shikijs/themes/github-light-default";
22
+ import css from "@shikijs/langs/css";
23
+ import html from "@shikijs/langs/html";
24
+ import javascript from "@shikijs/langs/javascript";
25
+ import json from "@shikijs/langs/json";
26
+ import markdown from "@shikijs/langs/markdown";
27
+ import shellscript from "@shikijs/langs/shellscript";
28
+ import typescript from "@shikijs/langs/typescript";
29
+ import yaml from "@shikijs/langs/yaml";
30
+ import type { JxElement } from "@jxsuite/schema/types";
31
+ import type { HighlighterCore } from "shiki/core";
32
+
33
+ const THEMES = { dark: "github-dark-default", light: "github-light-default" } as const;
34
+
35
+ let _highlighter: HighlighterCore | null = null;
36
+
37
+ /** Lazy singleton — grammar/theme setup only runs when a fence is actually highlighted. */
38
+ function getHighlighter(): HighlighterCore {
39
+ _highlighter ??= createHighlighterCoreSync({
40
+ engine: createJavaScriptRegexEngine(),
41
+ langs: [json, typescript, javascript, markdown, html, shellscript, css, yaml],
42
+ themes: [githubLight, githubDark],
43
+ });
44
+ return _highlighter;
45
+ }
46
+
47
+ /**
48
+ * Tokenize a fenced code block into span elements with dual-theme color variables. Returns null for
49
+ * unknown languages (callers keep the plain-text fallback).
50
+ *
51
+ * @param {string} code
52
+ * @param {string} lang - Fence info string (grammar name or registered alias)
53
+ * @returns {(JxElement | string)[] | null}
54
+ */
55
+ export function highlightFence(code: string, lang: string): (JxElement | string)[] | null {
56
+ const h = getHighlighter();
57
+ if (!h.getLoadedLanguages().includes(lang)) {
58
+ return null;
59
+ }
60
+ const { tokens } = h.codeToTokens(code, { defaultColor: false, lang, themes: THEMES });
61
+ const out: (JxElement | string)[] = [];
62
+ for (const [i, line] of tokens.entries()) {
63
+ if (i > 0) {
64
+ out.push("\n");
65
+ }
66
+ for (const token of line) {
67
+ const span: JxElement = { tagName: "span", textContent: token.content };
68
+ if (token.htmlStyle && Object.keys(token.htmlStyle).length > 0) {
69
+ span.style = token.htmlStyle as Record<string, string>;
70
+ }
71
+ out.push(span);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * Walk a transpiled Jx tree and highlight every `pre > code.language-*` block in place. Unknown
79
+ * languages and bare fences keep their plain textContent.
80
+ *
81
+ * @param {(JxElement | string)[]} nodes
82
+ */
83
+ export function highlightCodeBlocks(nodes: (JxElement | string)[]): void {
84
+ for (const node of nodes) {
85
+ if (!node || typeof node !== "object") {
86
+ continue;
87
+ }
88
+ if (node.tagName === "pre" && Array.isArray(node.children)) {
89
+ for (const child of node.children) {
90
+ if (!child || typeof child !== "object" || child.tagName !== "code") {
91
+ continue;
92
+ }
93
+ const lang = /^language-(\S+)/.exec(child.className ?? "")?.[1];
94
+ const code = child.textContent;
95
+ if (!lang || typeof code !== "string" || code.length === 0) {
96
+ continue;
97
+ }
98
+ const spans = highlightFence(code, lang);
99
+ if (!spans) {
100
+ continue;
101
+ }
102
+ delete child.textContent;
103
+ child.children = spans;
104
+ child.className = `${child.className} shiki`;
105
+ }
106
+ continue;
107
+ }
108
+ if (Array.isArray(node.children)) {
109
+ highlightCodeBlocks(node.children as (JxElement | string)[]);
110
+ }
111
+ }
112
+ }
package/src/markdown.ts CHANGED
@@ -26,6 +26,12 @@ export interface MarkdownLoadOptions {
26
26
  directiveOptions?: unknown;
27
27
  /** Enable remark-directive parsing. Implied by directiveOptions. */
28
28
  directives?: boolean;
29
+ /**
30
+ * Resolved content-source root directory. When set, entries in subdirectories get path-based ids
31
+ * (`studio/canvas` for `studio/canvas.md`, `/index` stripped); files directly at the root keep
32
+ * basename ids.
33
+ */
34
+ sourceRoot?: string;
29
35
  }
30
36
 
31
37
  /**
@@ -83,6 +89,7 @@ export class Markdown {
83
89
  ...(options.directiveOptions !== undefined && {
84
90
  directiveOptions: options.directiveOptions,
85
91
  }),
92
+ ...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
86
93
  });
87
94
  const _meta: ContentLoaderEntry["_meta"] = {};
88
95
  if (result.$excerpt != null) {
package/src/md.ts CHANGED
@@ -18,10 +18,11 @@ 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 { basename, extname, resolve as resolvePath } from "node:path";
21
+ import { basename, extname, relative, resolve as resolvePath } from "node:path";
22
22
  import { globSync } from "glob";
23
- import { mdastNodeToJx } from "./transpile.ts";
24
- import type { MarkdownFileResult, MdastNode, TocEntry, UnifiedProcessor } from "./types.ts";
23
+ import { assignHeadingIds, mdastNodeToJx } from "./transpile.ts";
24
+ import { highlightCodeBlocks } from "./highlight.ts";
25
+ import type { MarkdownFileResult, MdastNode, UnifiedProcessor } from "./types.ts";
25
26
  import type { JxElement } from "@jxsuite/schema/types";
26
27
 
27
28
  // ─── Tree utilities (inline to avoid Bun ESM resolution issues with unist-util-*) ──
@@ -92,28 +93,6 @@ function readingTime(text: string) {
92
93
  return Math.max(1, Math.ceil(words / 200));
93
94
  }
94
95
 
95
- /**
96
- * Extract table of contents entries from an mdast tree.
97
- *
98
- * @param {object} tree - Mdast AST
99
- * @returns {{ depth: number; text: string; id: string }[]}
100
- */
101
- function extractToc(tree: MdastNode) {
102
- /** @type {{ depth: number; text: string; id: string }[]} */
103
- const entries: TocEntry[] = [];
104
- visit(tree, "heading", (node: MdastNode) => {
105
- const text = mdastToString(node);
106
- const id = text
107
- .toLowerCase()
108
- .replaceAll(/[^\w\s-]/g, "")
109
- .replaceAll(/\s+/g, "-")
110
- .replaceAll(/-+/g, "-")
111
- .replaceAll(/^-|-$/g, "");
112
- entries.push({ depth: node.depth as number, id, text });
113
- });
114
- return entries;
115
- }
116
-
117
96
  /**
118
97
  * Extract first paragraph as a JX text string from an mdast tree.
119
98
  *
@@ -133,6 +112,30 @@ function extractExcerpt(tree: MdastNode) {
133
112
  return mdastToString(firstParagraph);
134
113
  }
135
114
 
115
+ /**
116
+ * Derive an entry slug from a file path. Without a source root (or for files directly at the root),
117
+ * the slug is the basename — the historical behavior every flat collection relies on. Files in
118
+ * subdirectories of the root get path-based slugs with POSIX separators and a trailing `/index`
119
+ * stripped, so `studio/canvas.md` and `studio/canvas/index.md` both yield `studio/canvas`.
120
+ *
121
+ * @param {string} filePath - Absolute path to the markdown file
122
+ * @param {string} [sourceRoot] - Resolved content-source root directory
123
+ * @returns {string} The entry slug
124
+ */
125
+ function deriveSlug(filePath: string, sourceRoot?: string): string {
126
+ if (sourceRoot) {
127
+ const rel = relative(sourceRoot, filePath).split("\\").join("/");
128
+ if (rel && !rel.startsWith("..") && rel.includes("/")) {
129
+ let slug = rel.slice(0, rel.length - extname(rel).length);
130
+ if (slug.endsWith("/index")) {
131
+ slug = slug.slice(0, -"/index".length);
132
+ }
133
+ return slug;
134
+ }
135
+ }
136
+ return basename(filePath, extname(filePath));
137
+ }
138
+
136
139
  /**
137
140
  * Process a single markdown source string into a MarkdownFileResult.
138
141
  *
@@ -143,6 +146,7 @@ function extractExcerpt(tree: MdastNode) {
143
146
  * @param {object} config - Processing options
144
147
  * @param {boolean} [config.directives] - Enable directive support
145
148
  * @param {unknown} [config.directiveOptions] - Directive plugin options
149
+ * @param {string} [config.sourceRoot] - Content-source root; files below it get path-based slugs
146
150
  * @returns {MarkdownFileResult}
147
151
  */
148
152
  export function processMarkdown(
@@ -151,6 +155,7 @@ export function processMarkdown(
151
155
  config: {
152
156
  directives?: boolean;
153
157
  directiveOptions?: unknown;
158
+ sourceRoot?: string;
154
159
  } = {},
155
160
  ) {
156
161
  let processor = (unified as unknown as () => UnifiedProcessor)()
@@ -171,9 +176,8 @@ export function processMarkdown(
171
176
  const frontmatter = (vfileData.frontmatter ?? {}) as Record<string, unknown>;
172
177
  const mdTree = tree as unknown as MdastNode;
173
178
  const plainText = mdastToString(mdTree);
174
- const toc = extractToc(tree);
175
179
  const excerpt = extractExcerpt(tree);
176
- const slug = basename(filePath, extname(filePath));
180
+ const slug = deriveSlug(filePath, config.sourceRoot);
177
181
 
178
182
  const bodyNodes = tree.children!.filter(
179
183
  (n: MdastNode) => n.type !== "yaml" && n.type !== "toml",
@@ -183,6 +187,13 @@ export function processMarkdown(
183
187
  | string
184
188
  )[];
185
189
 
190
+ // Build-time syntax highlighting: fence text becomes dual-theme token spans in place.
191
+ highlightCodeBlocks($children);
192
+
193
+ // One walk assigns deduplicated heading ids AND builds $toc, so rendered anchors and the
194
+ // Table of contents agree by construction (specs/parser.md).
195
+ const toc = assignHeadingIds($children);
196
+
186
197
  return {
187
198
  $children,
188
199
  $excerpt: excerpt,
package/src/transpile.ts CHANGED
@@ -19,7 +19,13 @@ import remarkGfm from "remark-gfm";
19
19
  import remarkDirective from "remark-directive";
20
20
  import { htmlToJx } from "@jxsuite/markup/html-to-jx";
21
21
  import type { MdastNode, UnifiedProcessor } from "./types.ts";
22
- import type { JsonValue, JxAttributeValue, JxDocument, JxElement } from "@jxsuite/schema/types";
22
+ import type {
23
+ JsonValue,
24
+ JxAttributeValue,
25
+ JxDocument,
26
+ JxElement,
27
+ TocEntry,
28
+ } from "@jxsuite/schema/types";
23
29
 
24
30
  export { htmlToJx } from "@jxsuite/markup/html-to-jx";
25
31
 
@@ -458,6 +464,16 @@ export function mdastNodeToJx(node: MdastNode) {
458
464
 
459
465
  case "table": {
460
466
  const rows = convertChildren(node.children ?? []);
467
+ // GFM's first table row is the header row, but mdast does not mark its cells — retag them
468
+ // To <th> so the <thead> carries real column headers (assistive tech relies on this).
469
+ const [headerRow] = rows;
470
+ if (headerRow && typeof headerRow === "object" && Array.isArray(headerRow.children)) {
471
+ for (const cell of headerRow.children) {
472
+ if (cell && typeof cell === "object" && (cell as JxElement).tagName === "td") {
473
+ (cell as JxElement).tagName = "th";
474
+ }
475
+ }
476
+ }
461
477
  const thead = rows.length > 0 ? { children: [rows[0]], tagName: "thead" } : null;
462
478
  const tbody = rows.length > 1 ? { children: rows.slice(1), tagName: "tbody" } : null;
463
479
  el.children = [thead, tbody].filter(Boolean) as JxElement[];
@@ -705,3 +721,88 @@ export function transpileJxMarkdown(source: string) {
705
721
 
706
722
  return doc;
707
723
  }
724
+
725
+ // ─── Heading anchors ─────────────────────────────────────────────────────────
726
+
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).
731
+ *
732
+ * @param {string} text
733
+ * @returns {string}
734
+ */
735
+ export function slugifyHeading(text: string) {
736
+ return text
737
+ .toLowerCase()
738
+ .replaceAll(/[^\w\s-]/g, "")
739
+ .replaceAll(/\s+/g, "-")
740
+ .replaceAll(/-+/g, "-")
741
+ .replaceAll(/^-|-$/g, "");
742
+ }
743
+
744
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
745
+
746
+ /**
747
+ * Concatenated text of a Jx node: own textContent plus string/element children, depth-first.
748
+ *
749
+ * @param {JxElement | string} node
750
+ * @returns {string}
751
+ */
752
+ function jxNodeText(node: JxElement | string): string {
753
+ if (typeof node === "string") {
754
+ return node;
755
+ }
756
+ let out = typeof node.textContent === "string" ? node.textContent : "";
757
+ if (Array.isArray(node.children)) {
758
+ for (const child of node.children) {
759
+ out += jxNodeText(child as JxElement | string);
760
+ }
761
+ }
762
+ return out;
763
+ }
764
+
765
+ /**
766
+ * Assign deduplicated slug ids to `h1`–`h6` elements (depth-first, document order) and return the
767
+ * matching table-of-contents entries. The first occurrence of a slug is unsuffixed; repeats get
768
+ * `-2`, `-3`, …. Pre-existing ids are respected and still claim their slug. Because ids and entries
769
+ * come from one walk, rendered anchors and `$toc` agree by construction.
770
+ *
771
+ * @param {(JxElement | string)[] | undefined} children - Jx nodes, mutated in place
772
+ * @returns {TocEntry[]}
773
+ */
774
+ export function assignHeadingIds(children?: (JxElement | string)[]): TocEntry[] {
775
+ const entries: TocEntry[] = [];
776
+ const seen = new Map<string, number>();
777
+
778
+ function walk(nodes: (JxElement | string)[]) {
779
+ for (const node of nodes) {
780
+ if (typeof node !== "object" || node === null) {
781
+ continue;
782
+ }
783
+ const tag = node.tagName;
784
+ if (tag && HEADING_TAGS.has(tag)) {
785
+ const text = jxNodeText(node).trim();
786
+ let id = typeof node.id === "string" && node.id.length > 0 ? node.id : "";
787
+ if (id) {
788
+ seen.set(id, (seen.get(id) ?? 0) + 1);
789
+ } else {
790
+ const base = slugifyHeading(text) || "section";
791
+ const count = seen.get(base) ?? 0;
792
+ id = count === 0 ? base : `${base}-${count + 1}`;
793
+ seen.set(base, count + 1);
794
+ node.id = id;
795
+ }
796
+ entries.push({ depth: Number(tag.slice(1)), id, text });
797
+ }
798
+ if (Array.isArray(node.children)) {
799
+ walk(node.children as (JxElement | string)[]);
800
+ }
801
+ }
802
+ }
803
+
804
+ if (children) {
805
+ walk(children);
806
+ }
807
+ return entries;
808
+ }