@jarenjs/md 0.34.2 → 0.43.3

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
@@ -276,6 +276,29 @@ createApp(appDoc, {
276
276
  source string (or the same parsed document) returns the same vnode
277
277
  reference, so the view patcher skips an unchanged article in O(1) —
278
278
  the derivation contract `@jarenjs/app` viewModels rely on.
279
+ - `md.view(sourceOrDoc, policy)` renders that one call under a
280
+ **rendering policy**, for a host whose documents do not all come from
281
+ the same place. Heading ids are the live case: ids are a page's
282
+ namespace, so markdown the host authored may mint them bare while
283
+ markdown from anywhere else renders with a `slugPrefix` —
284
+
285
+ ```js
286
+ const TRUSTED = { slugPrefix: '' }; // your own documents
287
+ const UNTRUSTED = { slugPrefix: 'user-content-' }; // everyone else's
288
+
289
+ md.view(readme, TRUSTED); // id="setup"
290
+ md.view(reply, UNTRUSTED); // id="user-content-setup"
291
+ ```
292
+
293
+ A policy may name `headingIds`, `slugPrefix`, `headingAnchors`,
294
+ `footnotesLabel`, `html` and `keyed` — the options that shape the
295
+ emitted vnode; anything else (`plugins`, `sanitizeUrl` — construction-
296
+ time decisions, and `plugins` changes the *parse*) is **refused**, not
297
+ ignored. The projection is memoized per source **and** policy, so
298
+ alternating provenances cannot thrash the cache and each stays
299
+ reference-stable; the parse and the hydratable index are shared, which
300
+ is why this is one component and not two. A call that names no policy
301
+ renders under the construction-time options, unchanged.
279
302
  - `md.effects['md-load']` resolves any URL through the loader (cache,
280
303
  abort, streaming) and dispatches the plain `MdDocument` as the
281
304
  action payload; `md-parse` does the same for an in-state source
@@ -342,7 +365,7 @@ an editable data pane.
342
365
  ### Directives — a number a machine derives and a human reads
343
366
 
344
367
  ```markdown
345
- Jaren is <!--bm:jsonpath.ctsRatio-->23.1<!--/bm-->x faster on the CTS mean.
368
+ Jaren is <!--bm:jsonpath.ctsRatio-->8.8<!--/bm-->x faster across the CTS queries.
346
369
  ```
347
370
 
348
371
  Every markdown renderer on earth drops HTML comments, so GitHub, an editor
@@ -416,7 +439,13 @@ as markup or as a live URL.
416
439
 
417
440
  `options.sanitizeUrl` — `(url) => string | null` — replaces the URL policy
418
441
  wholesale when a host needs a custom scheme in trusted content. It is the
419
- whole guard, so widen it deliberately. Plugin `render` functions shadow the
442
+ whole guard, so widen it deliberately.
443
+
444
+ Heading ids are the other half of the same question, and it is settled per
445
+ *document* rather than per emitter: a host that renders its own documents
446
+ beside documents it did not author passes the rendering policy to
447
+ `md.view(source, policy)` (above), so untrusted text gets a `slugPrefix`
448
+ and the host's own documents do not. Plugin `render` functions shadow the
420
449
  core emitter and own the rule for URLs they emit; `ctx.sanitizeUrl` is the
421
450
  active policy ([PLUGINS.md](docs/PLUGINS.md) §5, [MD-FORMAT.md](docs/MD-FORMAT.md) §4.3).
422
451
 
@@ -26,17 +26,45 @@ export type MdComponentOptions = MdCompileOptions & {
26
26
  fetch?: typeof globalThis.fetch;
27
27
  cache?: any;
28
28
  memoLimit?: number;
29
+ policyLimit?: number;
29
30
  onHydrateError?: (err: any) => void;
30
31
  };
32
+ export type MdRenderPolicy = {
33
+ /**
34
+ * mint an `id` on every heading
35
+ */
36
+ headingIds?: boolean;
37
+ /**
38
+ * prepended to every emitted heading id
39
+ * and to the anchor href beside it (`''` opts out)
40
+ */
41
+ slugPrefix?: string;
42
+ /**
43
+ * emit the copy-a-link anchor
44
+ */
45
+ headingAnchors?: boolean;
46
+ /**
47
+ * the footnote section's heading
48
+ */
49
+ footnotesLabel?: string;
50
+ /**
51
+ * what to do with raw HTML
52
+ */
53
+ html?: 'skip' | 'text';
54
+ /**
55
+ * emit patcher keys on block children
56
+ */
57
+ keyed?: boolean;
58
+ };
31
59
  export type MdComponent = {
32
60
  /**
33
61
  * the compiled-in plugin set
34
62
  */
35
63
  plugins: any[];
36
64
  /**
37
- * memoized vnode projection
65
+ * memoized vnode projection, per source AND policy
38
66
  */
39
- view: (sourceOrDoc: any) => any;
67
+ view: (sourceOrDoc: any, policy?: MdRenderPolicy) => any;
40
68
  /**
41
69
  * memoized compile
42
70
  */
@@ -62,14 +90,38 @@ export type MdComponent = {
62
90
  * fetch?: typeof globalThis.fetch,
63
91
  * cache?: any,
64
92
  * memoLimit?: number,
93
+ * policyLimit?: number,
65
94
  * onHydrateError?: (err: any) => void,
66
95
  * }} MdComponentOptions
67
96
  */
97
+ /**
98
+ * The rendering policy a single `view()` call may name, for a host that
99
+ * renders documents of different PROVENANCE through one component. Only
100
+ * the options that shape the emitted vnode belong here — the parse is
101
+ * provenance-independent, and `plugins`/`sanitizeUrl` are the
102
+ * construction-time decisions a per-call override would quietly undo.
103
+ *
104
+ * The live case is heading ids: repo-authored Markdown is trusted with
105
+ * the ids it mints, and text from anywhere else must not mint bare ids
106
+ * into a page that owns ids of its own — so it renders with a
107
+ * `slugPrefix`. Footnote ids already default to `user-content-`
108
+ * (MD-FORMAT §4.6) and are unaffected.
109
+ *
110
+ * @typedef {object} MdRenderPolicy
111
+ * @property {boolean} [headingIds] mint an `id` on every heading
112
+ * @property {string} [slugPrefix] prepended to every emitted heading id
113
+ * and to the anchor href beside it (`''` opts out)
114
+ * @property {boolean} [headingAnchors] emit the copy-a-link anchor
115
+ * @property {string} [footnotesLabel] the footnote section's heading
116
+ * @property {'skip'|'text'} [html] what to do with raw HTML
117
+ * @property {boolean} [keyed] emit patcher keys on block children
118
+ */
68
119
  /**
69
120
  * The component bundle.
70
121
  * @typedef {object} MdComponent
71
122
  * @property {any[]} plugins the compiled-in plugin set
72
- * @property {(sourceOrDoc: any) => any} view memoized vnode projection
123
+ * @property {(sourceOrDoc: any, policy?: MdRenderPolicy) => any} view
124
+ * memoized vnode projection, per source AND policy
73
125
  * @property {(source: string) => CompiledMd} compile memoized compile
74
126
  * @property {Record<string, (props: any, dispatch: any) => any>} effects
75
127
  * `md-load` and `md-parse` for `createApp({ effects })`
package/docs/MD-FORMAT.md CHANGED
@@ -305,7 +305,10 @@ this package's conformance score at odds with the documents it produces.
305
305
 
306
306
  `mdToVnode` implements this as `headingIds`, `slugPrefix` and
307
307
  `headingAnchors`; the slug transform itself is `slugify` from
308
- `@jarenjs/core/string`, the suite's only one.
308
+ `@jarenjs/core/string`, the suite's only one. A host whose documents do
309
+ not all share one provenance names the prefix per render rather than per
310
+ emitter — the visual component takes the rendering policy as `view()`'s
311
+ second argument (README §"The visual component").
309
312
 
310
313
  ### 4.6 Footnotes (normative)
311
314
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/md",
3
3
  "private": false,
4
- "version": "0.34.2",
4
+ "version": "0.43.3",
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.34.2",
77
- "@jarenjs/mermaid": "^0.34.2",
78
- "@jarenjs/view": "^0.34.2"
76
+ "@jarenjs/core": "^0.43.3",
77
+ "@jarenjs/mermaid": "^0.43.3",
78
+ "@jarenjs/view": "^0.43.3"
79
79
  }
80
80
  }
@@ -20,9 +20,11 @@
20
20
  * ARCHITECTURE.md §"Engine and component".
21
21
  */
22
22
 
23
+ import { createBoundedCache } from '@jarenjs/core/cache';
23
24
  import { createProjectionMemo } from '@jarenjs/view/helpers';
24
25
  import { buildPluginTables } from '../parser.js';
25
26
  import { compileMarkdown } from '../compiler.js';
27
+ import { mdToVnode } from '../to-vnode.js';
26
28
  import { loadMarkdown } from '../loader.js';
27
29
  import { walkAst } from '../ast.js';
28
30
  import { hashContent } from '../utils.js';
@@ -39,14 +41,38 @@ import { highlightPlugin } from '../plugins/highlight.js';
39
41
  * fetch?: typeof globalThis.fetch,
40
42
  * cache?: any,
41
43
  * memoLimit?: number,
44
+ * policyLimit?: number,
42
45
  * onHydrateError?: (err: any) => void,
43
46
  * }} MdComponentOptions
44
47
  */
48
+ /**
49
+ * The rendering policy a single `view()` call may name, for a host that
50
+ * renders documents of different PROVENANCE through one component. Only
51
+ * the options that shape the emitted vnode belong here — the parse is
52
+ * provenance-independent, and `plugins`/`sanitizeUrl` are the
53
+ * construction-time decisions a per-call override would quietly undo.
54
+ *
55
+ * The live case is heading ids: repo-authored Markdown is trusted with
56
+ * the ids it mints, and text from anywhere else must not mint bare ids
57
+ * into a page that owns ids of its own — so it renders with a
58
+ * `slugPrefix`. Footnote ids already default to `user-content-`
59
+ * (MD-FORMAT §4.6) and are unaffected.
60
+ *
61
+ * @typedef {object} MdRenderPolicy
62
+ * @property {boolean} [headingIds] mint an `id` on every heading
63
+ * @property {string} [slugPrefix] prepended to every emitted heading id
64
+ * and to the anchor href beside it (`''` opts out)
65
+ * @property {boolean} [headingAnchors] emit the copy-a-link anchor
66
+ * @property {string} [footnotesLabel] the footnote section's heading
67
+ * @property {'skip'|'text'} [html] what to do with raw HTML
68
+ * @property {boolean} [keyed] emit patcher keys on block children
69
+ */
45
70
  /**
46
71
  * The component bundle.
47
72
  * @typedef {object} MdComponent
48
73
  * @property {any[]} plugins the compiled-in plugin set
49
- * @property {(sourceOrDoc: any) => any} view memoized vnode projection
74
+ * @property {(sourceOrDoc: any, policy?: MdRenderPolicy) => any} view
75
+ * memoized vnode projection, per source AND policy
50
76
  * @property {(source: string) => CompiledMd} compile memoized compile
51
77
  * @property {Record<string, (props: any, dispatch: any) => any>} effects
52
78
  * `md-load` and `md-parse` for `createApp({ effects })`
@@ -98,7 +124,7 @@ export function createMdComponent(options = {}) {
98
124
  });
99
125
  };
100
126
 
101
- const { compile, view } = createProjectionMemo({
127
+ const { compile, view: viewDefault } = createProjectionMemo({
102
128
  memoLimit,
103
129
  compile: (source) => {
104
130
  const compiled = compileMarkdown(source, compileOptions);
@@ -113,6 +139,63 @@ export function createMdComponent(options = {}) {
113
139
  },
114
140
  });
115
141
 
142
+ // One projection memo per named policy, on top of the one compile
143
+ // memo above: parsing is provenance-independent, so a second policy
144
+ // costs a second vnode and nothing else. Bounded because a caller
145
+ // that mints a policy per call would otherwise retain every variant
146
+ // it ever rendered; a host names two or three provenances, not
147
+ // hundreds.
148
+ /** @type {import('@jarenjs/core/cache').BoundedCache<string, any>} */
149
+ const variants = createBoundedCache(options.policyLimit ?? 8);
150
+
151
+ /**
152
+ * The projection memo for one policy. Reference stability is the whole
153
+ * contract, so the vnode is cached against the compiled document the
154
+ * shared memo already returns — `mdToVnode` is called once per
155
+ * (document, policy), never once per render.
156
+ * @param {MdRenderPolicy} policy
157
+ */
158
+ const buildVariant = (policy) => {
159
+ const renderOptions = {
160
+ plugins: compileOptions.plugins,
161
+ html: compileOptions.html,
162
+ sanitizeUrl: compileOptions.sanitizeUrl,
163
+ headingIds: compileOptions.headingIds,
164
+ slugPrefix: compileOptions.slugPrefix,
165
+ headingAnchors: compileOptions.headingAnchors,
166
+ footnotesLabel: compileOptions.footnotesLabel,
167
+ keyed: compileOptions.keyed,
168
+ ...policy,
169
+ };
170
+ /** @type {WeakMap<any, any>} compiled document → its vnode */
171
+ const vnodes = new WeakMap();
172
+ const project = (/** @type {any} */ compiled) => {
173
+ let vnode = vnodes.get(compiled);
174
+ if (vnode === undefined) {
175
+ vnode = mdToVnode(compiled, renderOptions);
176
+ vnodes.set(compiled, vnode);
177
+ }
178
+ return vnode;
179
+ };
180
+ return createProjectionMemo({
181
+ memoLimit,
182
+ compile,
183
+ toVnode: project,
184
+ docToVnode: (doc) => {
185
+ indexHydratable(doc);
186
+ return project(compileMarkdown(doc, compileOptions));
187
+ },
188
+ });
189
+ };
190
+
191
+ /** @type {MdComponent['view']} */
192
+ const view = (sourceOrDoc, policy) => {
193
+ if (policy === undefined || policy === null) return viewDefault(sourceOrDoc);
194
+ const key = policyKey(policy);
195
+ if (key === '') return viewDefault(sourceOrDoc);
196
+ return variants.getOrCreate(key, () => buildVariant(policy)).view(sourceOrDoc);
197
+ };
198
+
116
199
  /** @type {MdComponent} */
117
200
  const component = {
118
201
  plugins,
@@ -183,6 +266,38 @@ export function createMdComponent(options = {}) {
183
266
  return component;
184
267
  }
185
268
 
269
+ /**
270
+ * The option names a per-call {@link MdRenderPolicy} may carry. Anything
271
+ * else is refused rather than ignored: a policy naming `plugins` would
272
+ * silently render under the construction-time set, which is exactly the
273
+ * kind of quiet disagreement the per-call policy exists to end.
274
+ */
275
+ const POLICY_OPTIONS = new Set([
276
+ 'headingIds', 'slugPrefix', 'headingAnchors', 'footnotesLabel', 'html', 'keyed',
277
+ ]);
278
+
279
+ /**
280
+ * A policy's cache key: its members in name order, so two spellings of
281
+ * the same policy share one memo. An empty key means the policy named
282
+ * nothing and the construction-time defaults answer it.
283
+ * @param {MdRenderPolicy} policy
284
+ * @returns {string}
285
+ * @throws {TypeError} when the policy names an option it may not set.
286
+ */
287
+ function policyKey(policy) {
288
+ let key = '';
289
+ for (const name of Object.keys(policy).sort()) {
290
+ if (!POLICY_OPTIONS.has(name)) {
291
+ throw new TypeError(`md view policy: '${name}' is not a rendering option `
292
+ + `(${[...POLICY_OPTIONS].join(', ')})`);
293
+ }
294
+ const value = /** @type {any} */ (policy)[name];
295
+ if (value === undefined) continue;
296
+ key += `${name}=${JSON.stringify(value)};`;
297
+ }
298
+ return key;
299
+ }
300
+
186
301
  /**
187
302
  * The component's default plugin set: syntax highlighting on. A single
188
303
  * shared array so `buildPluginTables` (and every memo hanging off it)