@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 +31 -2
- package/dist/types/component/index.d.ts +55 -3
- package/docs/MD-FORMAT.md +4 -1
- package/package.json +4 -4
- package/src/component/index.js +117 -2
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-->
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
77
|
-
"@jarenjs/mermaid": "^0.
|
|
78
|
-
"@jarenjs/view": "^0.
|
|
76
|
+
"@jarenjs/core": "^0.43.3",
|
|
77
|
+
"@jarenjs/mermaid": "^0.43.3",
|
|
78
|
+
"@jarenjs/view": "^0.43.3"
|
|
79
79
|
}
|
|
80
80
|
}
|
package/src/component/index.js
CHANGED
|
@@ -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
|
|
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)
|