@jarenjs/md 0.56.0 → 0.67.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/README.md CHANGED
@@ -139,7 +139,9 @@ mdToVnode(doc, { headingIds: true, slugPrefix: 'user-content-' });
139
139
 
140
140
  Repeated headings are numbered the way GitHub numbers them (`setup`,
141
141
  `setup-1`, `setup-2`), and a heading with no slug-worthy text (`## ***`)
142
- lands on `section`. Ids are **off by default on purpose**: CommonMark
142
+ lands on `section`. Numbered ids are reserved too: headings `Foo`, `Foo`,
143
+ `Foo-1` receive `foo`, `foo-1`, `foo-1-1`, so every permalink remains unique.
144
+ Ids are **off by default on purpose**: CommonMark
143
145
  renders a heading as `<h1>Foo</h1>`, so emitting one by default would put
144
146
  the conformance score below at odds with what the package produces. The
145
147
  rules are normative in [MD-FORMAT.md](docs/MD-FORMAT.md) §4.5; the slug
@@ -315,7 +317,9 @@ createApp(appDoc, {
315
317
  - A document whose frontmatter declares a schema (`form:` or `$schema`)
316
318
  feeds [`@jarenjs/forms`](../../packages/forms) through `mdToForm(doc, forms)` —
317
319
  `{ schema, fields, data }`, with the forms module injected so this
318
- package stays dependency-free.
320
+ package stays dependency-free. When frontmatter omits `data`, the built
321
+ form model supplies schema defaults and `const` values; explicit data,
322
+ including `null`, is preserved.
319
323
  - In an [`@jarenjs/app`](../../packages/app) document, a view can be a JSLT
320
324
  stylesheet over a loaded MdDocument: register `loadMarkdown` as an
321
325
  async effect that dispatches the plain `MdDocument` into the state,
@@ -537,6 +541,25 @@ Measured, not claimed — `npm run benchmark:markdown`, <!--fact:md.measured-->2
537
541
  tag's attributes — is normalized away on every engine's output, not
538
542
  just this one's.
539
543
 
544
+ ## Exports
545
+
546
+ Every subpath a consumer can import, derived from the manifest by
547
+ `npm run docs:derive` (`npm run docs:check` fails when the two drift):
548
+
549
+ <!--fact:exports.md-->
550
+ | Import | Kind | Declarations |
551
+ |---|---|---|
552
+ | `@jarenjs/md` | JavaScript | declared |
553
+ | `@jarenjs/md/plugins` | JavaScript | declared |
554
+ | `@jarenjs/md/html` | JavaScript | declared |
555
+ | `@jarenjs/md/mdx` | JavaScript | declared |
556
+ | `@jarenjs/md/directives` | JavaScript | declared |
557
+ | `@jarenjs/md/component` | JavaScript | declared |
558
+ | `@jarenjs/md/styles/md.css` | asset | — |
559
+ | `@jarenjs/md/schemas/jaren-md-ast.schema.json` | schema | — |
560
+ | `@jarenjs/md/package.json` | metadata | — |
561
+ <!--/fact-->
562
+
540
563
  ## Development
541
564
 
542
565
  Tests live in the repository root: [`test/md/`](../../test/md)
@@ -128,12 +128,12 @@ export declare function frontmatterExternals(frontmatter: any): Record<string, a
128
128
  *
129
129
  * @param {MdDocument} doc
130
130
  * @param {{ buildFormModel: (schema: any) => any,
131
- * createInitialData: (schema: any) => any }} forms
131
+ * createInitialData: (fields: any) => any }} forms
132
132
  * @returns {{ schema: any, fields: any, data: any } | null}
133
133
  */
134
134
  export declare function mdToForm(doc: MdDocument, forms: {
135
135
  buildFormModel: (schema: any) => any;
136
- createInitialData: (schema: any) => any;
136
+ createInitialData: (fields: any) => any;
137
137
  }): {
138
138
  schema: any;
139
139
  fields: any;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `loadMarkdown` resolves any URL the platform `fetch` accepts into a
5
5
  * compiled document. The shared LRU cache keys on normalized URL +
6
- * plugin set, remembers `ETag`/`Last-Modified` validators, shares
6
+ * compile options, remembers `ETag`/`Last-Modified` validators, shares
7
7
  * in-flight fetches, and revalidates stale entries in the background.
8
8
  * When the response body is a `ReadableStream` the parser runs
9
9
  * block-by-block as chunks arrive — `streamMarkdown` exposes that as
@@ -20,8 +20,10 @@ export { hashContent, fnv1a, FNV1A_OFFSET_BASIS, slugify } from '@jarenjs/core/s
20
20
  *
21
21
  * Both emitters mint ids, so the rule lives here once: slug the text,
22
22
  * substitute `section` when nothing slug-worthy survives, number
23
- * repeats the way GitHub numbers them (`setup`, `setup-1`, `setup-2`)
24
- * and prefix the result. The COUNTER belongs to the caller — one map per
23
+ * repeats the way GitHub numbers them (`setup`, `setup-1`, `setup-2`),
24
+ * reserving every emitted slug so a literal `setup-1` cannot collide
25
+ * with a numbered `setup`, and prefix the result. The COUNTER belongs
26
+ * to the caller — one map per
25
27
  * emission, never shared with another numbering (a block key's hash and
26
28
  * a slug share a namespace only by accident, and a collision there would
27
29
  * shift an unrelated heading's number).
package/docs/LOADER.md CHANGED
@@ -32,14 +32,23 @@ remembers the response's `ETag` and `Last-Modified`; a cache hit
32
32
  resolves immediately with the cached compiled document, and a
33
33
  revalidating fetch (`If-None-Match`/`If-Modified-Since`) only replaces
34
34
  the entry when the origin answers with new content (`200` with a
35
- different validator). Entries also key on the compile-relevant options
36
- (plugin names), so the same URL compiled with different plugin sets
37
- does not alias.
35
+ different validator). Entries also key on every compile-relevant option:
36
+ GFM and frontmatter parsing, source retention, HTML handling, heading and
37
+ footnote policies, and vnode keys. Plugins and the injected TOML/URL
38
+ callbacks key by identity, so equally named plugins with different
39
+ implementations never alias. Treat plugin objects and callbacks as
40
+ immutable for the lifetime of a compiled document.
38
41
 
39
42
  - `createMdCache(limit = 64)` — an LRU cache instance; the default
40
43
  shared instance is exported as `defaultMdCache`.
41
44
  - `cache: false` — bypass entirely.
42
- - `cache.delete(url)` / `cache.clear()` manual invalidation.
45
+ - `cache.delete(url)` invalidate the normalized URL's default-options
46
+ entry; `cache.clear()` invalidates every entry, including option variants.
47
+
48
+ Invalidation also revokes a pending load or background revalidation's
49
+ right to update that entry. Its original caller still receives its
50
+ response, but an older completion cannot restore a cleared entry,
51
+ overwrite a newer document, or evict a newer load on failure.
43
52
 
44
53
  Concurrent `loadMarkdown` calls for the same key share one in-flight
45
54
  fetch (the promise itself is cached), so a burst of loads costs one
package/docs/MD-FORMAT.md CHANGED
@@ -101,11 +101,15 @@ The built-in parser handles, from scratch and dependency-free:
101
101
  `yes`/`no`, sexagesimals, hex ints and timestamps — is a **string**.
102
102
  - **Quoted strings** — double quotes with JSON-style escapes
103
103
  (`\n \t \r \b \f \0 \\ \" \uXXXX`), single quotes with `''` → `'`.
104
+ An opening quote requires a closing quote; an unterminated string is
105
+ a located frontmatter error.
104
106
  - **Block maps and sequences** by indentation (spaces only). A sequence
105
107
  MAY sit at the same indent as its parent key. `- key: value` opens an
106
108
  inline map item.
107
109
  - **Flow collections** — `[a, b]` and `{a: 1}`, nesting freely, spanning
108
- multiple lines while brackets remain open.
110
+ multiple lines while brackets remain open. Plain values retain internal
111
+ colons (`[a:b]`, `{url: http://host}`); shorthand map entries inside
112
+ sequences (`[a: b]`) are outside the subset and are rejected.
109
113
  - **Block scalars** — literal `|` and folded `>`, each with the `-`
110
114
  chomp (drop the final newline). The `+` chomp and explicit indent
111
115
  indicators are not supported.
@@ -133,7 +137,10 @@ with dotted paths, bare/quoted/dotted keys, basic and literal strings,
133
137
  integers (decimal/hex/octal/binary with `_`), floats, booleans, flow
134
138
  arrays (multi-line), inline tables and comments. Datetimes and any other
135
139
  unrecognized value are kept as verbatim **strings**; multi-line strings
136
- are not supported.
140
+ are not supported. Comments end at each physical line, including within
141
+ multi-line arrays. Dotted keys in an inline table preserve sibling
142
+ members, and table names such as `__proto__` are ordinary own properties.
143
+ An unterminated quoted string is a located frontmatter error.
137
144
 
138
145
  ### 3.4 Frontmatter as ambient variables
139
146
 
@@ -288,8 +295,9 @@ GitHub, in an editor preview and in this renderer:
288
295
  The result MAY be empty (`## ***`); an emitter MUST then substitute
289
296
  `section`, because a heading with no landing place cannot be linked.
290
297
  Within one document, the **second** heading yielding a given identifier
291
- MUST get `-1` appended, the third `-2`, and so on, counted over the
292
- substituted value so `***` twice yields `section` and `section-1`. The
298
+ MUST get `-1` appended, the third `-2`, and so on, skipping any identifier
299
+ already emitted (including numbered identifiers from another heading).
300
+ Count over the substituted value so `***` twice yields `section` and `section-1`. The
293
301
  counter is per emission and MUST NOT be shared with any other numbering
294
302
  in the emitter.
295
303
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/md",
3
3
  "private": false,
4
- "version": "0.56.0",
4
+ "version": "0.67.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -73,8 +73,8 @@
73
73
  "prepack": "npm run build:types"
74
74
  },
75
75
  "dependencies": {
76
- "@jarenjs/core": "^0.56.0",
77
- "@jarenjs/mermaid": "^0.56.0",
78
- "@jarenjs/view": "^0.56.0"
76
+ "@jarenjs/core": "^0.67.0",
77
+ "@jarenjs/mermaid": "^0.67.0",
78
+ "@jarenjs/view": "^0.67.0"
79
79
  }
80
80
  }
package/src/compiler.js CHANGED
@@ -152,7 +152,7 @@ export function frontmatterExternals(frontmatter) {
152
152
  *
153
153
  * @param {MdDocument} doc
154
154
  * @param {{ buildFormModel: (schema: any) => any,
155
- * createInitialData: (schema: any) => any }} forms
155
+ * createInitialData: (fields: any) => any }} forms
156
156
  * @returns {{ schema: any, fields: any, data: any } | null}
157
157
  */
158
158
  export function mdToForm(doc, forms) {
@@ -162,6 +162,7 @@ export function mdToForm(doc, forms) {
162
162
  ? fm.form
163
163
  : typeof fm.$schema === 'object' && fm.$schema !== null ? fm.$schema : null;
164
164
  if (schema === null) return null;
165
- const data = fm.data !== undefined ? fm.data : forms.createInitialData(schema);
166
- return { schema, fields: forms.buildFormModel(schema), data };
165
+ const fields = forms.buildFormModel(schema);
166
+ const data = fm.data !== undefined ? fm.data : forms.createInitialData(fields);
167
+ return { schema, fields, data };
167
168
  }
@@ -403,7 +403,7 @@ function splitYamlKey(line, indent, lineNo) {
403
403
  if (key.length > 1) {
404
404
  const q = key.charCodeAt(0);
405
405
  if ((q === 0x22 || q === 0x27) && key.charCodeAt(key.length - 1) === q) {
406
- key = String(parseQuoted(key, 0, /** @type {'"'|"'"} */ (key[0])).value);
406
+ key = String(parseQuoted(key, 0, /** @type {'"'|"'"} */ (key[0]), lineNo).value);
407
407
  }
408
408
  }
409
409
  let restColumn = indent + colon + 1;
@@ -462,7 +462,7 @@ function parseYamlFlowOrScalar(state, rest, lineNo) {
462
462
  const flow = parseFlowValue(text, 0, lineNo);
463
463
  return flow.value;
464
464
  }
465
- return parseYamlScalar(stripComment(rest));
465
+ return parseYamlScalar(stripComment(rest), lineNo);
466
466
  }
467
467
 
468
468
  /**
@@ -509,13 +509,14 @@ function stripComment(text) {
509
509
  /**
510
510
  * Parse a scalar: quoted string, null, boolean, number, or plain string.
511
511
  * @param {string} text trimmed scalar text
512
+ * @param {number} lineNo
512
513
  * @returns {any}
513
514
  */
514
- function parseYamlScalar(text) {
515
+ function parseYamlScalar(text, lineNo) {
515
516
  if (text === '') return null;
516
517
  const c0 = text.charCodeAt(0);
517
518
  if (c0 === 0x22 || c0 === 0x27) {
518
- return parseQuoted(text, 0, /** @type {'"'|"'"} */ (text[0])).value;
519
+ return parseQuoted(text, 0, /** @type {'"'|"'"} */ (text[0]), lineNo).value;
519
520
  }
520
521
  switch (text) {
521
522
  case 'null': case 'Null': case 'NULL': case '~': return null;
@@ -533,9 +534,10 @@ function parseYamlScalar(text) {
533
534
  * @param {string} text
534
535
  * @param {number} pos
535
536
  * @param {'"'|"'"} quote
537
+ * @param {number} lineNo
536
538
  * @returns {{ value: string, end: number }}
537
539
  */
538
- function parseQuoted(text, pos, quote) {
540
+ function parseQuoted(text, pos, quote, lineNo) {
539
541
  let out = '';
540
542
  let i = pos + 1;
541
543
  while (i < text.length) {
@@ -569,7 +571,7 @@ function parseQuoted(text, pos, quote) {
569
571
  out += ch;
570
572
  i++;
571
573
  }
572
- return { value: out, end: i };
574
+ throw new MdFrontmatterError('unterminated quoted string', lineNo);
573
575
  }
574
576
 
575
577
  /**
@@ -578,26 +580,29 @@ function parseQuoted(text, pos, quote) {
578
580
  * @param {string} text
579
581
  * @param {number} pos
580
582
  * @param {number} lineNo
583
+ * @param {boolean} [key=false] whether a colon ends a map key
581
584
  * @returns {{ value: any, end: number }}
582
585
  */
583
- function parseFlowValue(text, pos, lineNo) {
586
+ function parseFlowValue(text, pos, lineNo, key = false) {
584
587
  while (pos < text.length && text.charCodeAt(pos) === 0x20) pos++;
585
588
  const c = text.charCodeAt(pos);
586
589
  if (c === 0x5B /* [ */) return parseFlowSeq(text, pos, lineNo);
587
590
  if (c === 0x7B /* { */) return parseFlowMap(text, pos, lineNo);
588
591
  if (c === 0x22 || c === 0x27) {
589
- return parseQuoted(text, pos, /** @type {'"'|"'"} */ (text[pos]));
592
+ return parseQuoted(text, pos, /** @type {'"'|"'"} */ (text[pos]), lineNo);
590
593
  }
591
594
  let end = pos;
592
595
  let depth = 0;
593
596
  while (end < text.length) {
594
597
  const cc = text.charCodeAt(end);
595
- if (depth === 0 && (cc === 0x2C || cc === 0x5D || cc === 0x7D || cc === 0x3A)) break;
598
+ if (depth === 0 && (cc === 0x2C || cc === 0x5D || cc === 0x7D)) break;
599
+ if (depth === 0 && cc === 0x3A
600
+ && (key || end + 1 === text.length || /[\s,[\]{}]/.test(text[end + 1]))) break;
596
601
  if (cc === 0x5B || cc === 0x7B) depth++;
597
602
  else if (cc === 0x5D || cc === 0x7D) depth--;
598
603
  end++;
599
604
  }
600
- return { value: parseYamlScalar(text.slice(pos, end).trim()), end };
605
+ return { value: parseYamlScalar(text.slice(pos, end).trim(), lineNo), end };
601
606
  }
602
607
 
603
608
  /**
@@ -620,6 +625,7 @@ function parseFlowArray(text, pos, lineNo, parseItem, unterminated) {
620
625
  if (i >= text.length) throw new MdFrontmatterError(unterminated, lineNo);
621
626
  if (text.charCodeAt(i) === 0x5D /* ] */) return { value: out, end: i + 1 };
622
627
  const item = parseItem(text, i, lineNo);
628
+ if (item.end <= i) throw new MdFrontmatterError('expected a flow array item', lineNo);
623
629
  out.push(item.value);
624
630
  i = item.end;
625
631
  }
@@ -651,7 +657,7 @@ function parseFlowMap(text, pos, lineNo) {
651
657
  while (i < text.length && (text.charCodeAt(i) === 0x20 || text.charCodeAt(i) === 0x2C)) i++;
652
658
  if (i >= text.length) throw new MdFrontmatterError('unterminated flow map', lineNo);
653
659
  if (text.charCodeAt(i) === 0x7D /* } */) return { value: out, end: i + 1 };
654
- const key = parseFlowValue(text, i, lineNo);
660
+ const key = parseFlowValue(text, i, lineNo, true);
655
661
  i = key.end;
656
662
  while (i < text.length && text.charCodeAt(i) === 0x20) i++;
657
663
  if (text.charCodeAt(i) !== 0x3A /* : */) {
@@ -699,16 +705,15 @@ export function parseTomlSubset(text) {
699
705
  const eq = findTomlEquals(line);
700
706
  if (eq === -1) throw new MdFrontmatterError(`expected 'key = value', got '${line}'`, no);
701
707
  const path = parseTomlKeyPath(line.slice(0, eq), no);
702
- let valueText = line.slice(eq + 1).trim();
703
- // Multi-line flow arrays / inline tables: join lines while open.
704
- while (flowDepth(stripTomlComment(valueText)) > 0 && no + 1 < lines.length) {
705
- valueText += ' ' + lines[++no].trim();
708
+ let valueText = stripTomlComment(line.slice(eq + 1)).trim();
709
+ // Comments end at each physical line, before continuation lines join.
710
+ while (flowDepth(valueText) > 0 && no + 1 < lines.length) {
711
+ valueText += ' ' + stripTomlComment(lines[++no]).trim();
706
712
  }
707
- valueText = stripTomlComment(valueText).trim();
708
713
  let target = table;
709
714
  for (let i = 0; i < path.length - 1; i++) {
710
715
  const step = path[i];
711
- if (!(step in target) || typeof target[step] !== 'object') {
716
+ if (!Object.hasOwn(target, step) || typeof target[step] !== 'object') {
712
717
  const next = {};
713
718
  setObjectMember(target, step, next);
714
719
  target = next;
@@ -772,7 +777,7 @@ function parseTomlKeyPath(text, no) {
772
777
  while (i < text.length && text.charCodeAt(i) === 0x20) i++;
773
778
  const c = text.charCodeAt(i);
774
779
  if (c === 0x22 || c === 0x27) {
775
- const q = parseQuoted(text, i, /** @type {'"'|"'"} */ (text[i]));
780
+ const q = parseQuoted(text, i, /** @type {'"'|"'"} */ (text[i]), no);
776
781
  out.push(q.value);
777
782
  i = q.end;
778
783
  }
@@ -812,7 +817,7 @@ function descendTomlTable(root, path, isArray, no) {
812
817
  let target = root;
813
818
  for (let i = 0; i < path.length - 1; i++) {
814
819
  const step = path[i];
815
- let next = target[step];
820
+ let next = Object.hasOwn(target, step) ? target[step] : undefined;
816
821
  if (next === undefined) {
817
822
  next = {};
818
823
  setObjectMember(target, step, next);
@@ -827,7 +832,7 @@ function descendTomlTable(root, path, isArray, no) {
827
832
  }
828
833
  const leaf = path[path.length - 1];
829
834
  if (isArray) {
830
- let arr = target[leaf];
835
+ let arr = Object.hasOwn(target, leaf) ? target[leaf] : undefined;
831
836
  if (arr === undefined) {
832
837
  arr = [];
833
838
  setObjectMember(target, leaf, arr);
@@ -837,7 +842,7 @@ function descendTomlTable(root, path, isArray, no) {
837
842
  arr.push(fresh);
838
843
  return fresh;
839
844
  }
840
- let next = target[leaf];
845
+ let next = Object.hasOwn(target, leaf) ? target[leaf] : undefined;
841
846
  if (next === undefined) {
842
847
  next = {};
843
848
  setObjectMember(target, leaf, next);
@@ -861,7 +866,7 @@ function parseTomlValue(text, no) {
861
866
  if (text === '') throw new MdFrontmatterError('missing value', no);
862
867
  const c0 = text.charCodeAt(0);
863
868
  if (c0 === 0x22 || c0 === 0x27) {
864
- return parseQuoted(text, 0, /** @type {'"'|"'"} */ (text[0])).value;
869
+ return parseQuoted(text, 0, /** @type {'"'|"'"} */ (text[0]), no).value;
865
870
  }
866
871
  if (c0 === 0x5B /* [ */) return parseTomlArray(text, no).value;
867
872
  if (c0 === 0x7B /* { */) return parseTomlInline(text, no).value;
@@ -907,8 +912,14 @@ function parseTomlInline(text, no) {
907
912
  const item = parseTomlItem(text, end + 1, no);
908
913
  let target = out;
909
914
  for (let p = 0; p < path.length - 1; p++) {
910
- const next = {};
911
- setObjectMember(target, path[p], next);
915
+ const step = path[p];
916
+ let next = Object.hasOwn(target, step) ? target[step] : undefined;
917
+ if (next === undefined) {
918
+ next = {};
919
+ setObjectMember(target, step, next);
920
+ }
921
+ if (next === null || typeof next !== 'object' || Array.isArray(next))
922
+ throw new MdFrontmatterError(`'${step}' is not a table`, no);
912
923
  target = next;
913
924
  }
914
925
  setObjectMember(target, path[path.length - 1], item.value);
@@ -935,7 +946,7 @@ function parseTomlItem(text, pos, no) {
935
946
  return { value: inner.value, end: pos + inner.end };
936
947
  }
937
948
  if (c === 0x22 || c === 0x27) {
938
- return parseQuoted(text, pos, /** @type {'"'|"'"} */ (text[pos]));
949
+ return parseQuoted(text, pos, /** @type {'"'|"'"} */ (text[pos]), no);
939
950
  }
940
951
  let end = pos;
941
952
  while (end < text.length) {
package/src/loader.js CHANGED
Binary file
package/src/utils.js CHANGED
@@ -25,8 +25,10 @@ export { hashContent, fnv1a, FNV1A_OFFSET_BASIS, slugify } from '@jarenjs/core/s
25
25
  *
26
26
  * Both emitters mint ids, so the rule lives here once: slug the text,
27
27
  * substitute `section` when nothing slug-worthy survives, number
28
- * repeats the way GitHub numbers them (`setup`, `setup-1`, `setup-2`)
29
- * and prefix the result. The COUNTER belongs to the caller — one map per
28
+ * repeats the way GitHub numbers them (`setup`, `setup-1`, `setup-2`),
29
+ * reserving every emitted slug so a literal `setup-1` cannot collide
30
+ * with a numbered `setup`, and prefix the result. The COUNTER belongs
31
+ * to the caller — one map per
30
32
  * emission, never shared with another numbering (a block key's hash and
31
33
  * a slug share a namespace only by accident, and a collision there would
32
34
  * shift an unrelated heading's number).
@@ -38,9 +40,12 @@ export { hashContent, fnv1a, FNV1A_OFFSET_BASIS, slugify } from '@jarenjs/core/s
38
40
  */
39
41
  export function headingId(text, seen, prefix) {
40
42
  const base = slugify(text) || 'section';
41
- const count = seen.get(base) ?? 0;
43
+ let count = seen.get(base) ?? 0;
44
+ let slug = count === 0 ? base : base + '-' + count;
45
+ while (seen.has(slug)) slug = base + '-' + ++count;
42
46
  seen.set(base, count + 1);
43
- return prefix + (count === 0 ? base : base + '-' + count);
47
+ if (slug !== base) seen.set(slug, 1);
48
+ return prefix + slug;
44
49
  }
45
50
 
46
51
  /**