@memorysmithapp/markdown-renderer 0.0.0-stage → 0.1.1

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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -2
  3. package/dist/CopyButton.d.ts +27 -0
  4. package/dist/CopyButton.js +69 -0
  5. package/dist/LinkChoice.d.ts +28 -0
  6. package/dist/LinkChoice.js +181 -0
  7. package/dist/Markdown.d.ts +35 -0
  8. package/dist/Markdown.js +283 -0
  9. package/dist/MermaidDiagram.d.ts +16 -0
  10. package/dist/MermaidDiagram.js +70 -0
  11. package/dist/NoteBody.d.ts +15 -0
  12. package/dist/NoteBody.js +92 -0
  13. package/dist/Transclusion.d.ts +11 -0
  14. package/dist/Transclusion.js +66 -0
  15. package/dist/address.d.ts +53 -0
  16. package/dist/address.js +69 -0
  17. package/dist/delegated.d.ts +39 -0
  18. package/dist/delegated.js +63 -0
  19. package/dist/footnotes.d.ts +72 -0
  20. package/dist/footnotes.js +137 -0
  21. package/dist/highlight.d.ts +54 -0
  22. package/dist/highlight.js +96 -0
  23. package/dist/host.d.ts +135 -0
  24. package/dist/host.js +49 -0
  25. package/dist/image-dimensions.d.ts +33 -0
  26. package/dist/image-dimensions.js +42 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.js +8 -0
  29. package/dist/markdown.d.ts +68 -0
  30. package/dist/markdown.js +395 -0
  31. package/dist/remark-callouts.d.ts +40 -0
  32. package/dist/remark-callouts.js +108 -0
  33. package/dist/remark-ring.d.ts +68 -0
  34. package/dist/remark-ring.js +178 -0
  35. package/dist/strings.d.ts +55 -0
  36. package/dist/strings.js +64 -0
  37. package/dist/tasklist.d.ts +25 -0
  38. package/dist/tasklist.js +80 -0
  39. package/dist/transclusion.d.ts +76 -0
  40. package/dist/transclusion.js +161 -0
  41. package/dist/wikilink-address.d.ts +8 -0
  42. package/dist/wikilink-address.js +13 -0
  43. package/package.json +75 -3
  44. package/styles.css +865 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Which addresses the reading surface will follow.
3
+ *
4
+ * `react-markdown` normally passes every URL through a filter that keeps the
5
+ * safe schemes and drops the rest, and the reading page once had it **switched
6
+ * off** — `urlTransform={(url) => url}`. There was a reason: an unresolved
7
+ * wikilink is rendered as `[text](pending:target)`, and the stock filter does
8
+ * not know `pending:`, so it erased the pending link from the page. A whole
9
+ * protection was traded for one scheme.
10
+ *
11
+ * This is that trade undone. The list says what passes, never what is
12
+ * refused, because a notebook is written by several people and by agents, and a
13
+ * denylist is a promise to have thought of everything.
14
+ *
15
+ * **What passes, and why:**
16
+ *
17
+ * - `pending:` — ours, and the reason the filter was off in the first place;
18
+ * and `attachment:` and `choice:`, ours too.
19
+ * - A path of the application (`/`), a relative one (`./`, `../`) and an
20
+ * anchor of the page (`#`).
21
+ * - `http:` and `https:` — the web, which is what a note cites.
22
+ * - `mailto:` — a person, which is what a note cites next most often.
23
+ *
24
+ * **What does not, and why each one:**
25
+ *
26
+ * - **`obsidian://`, and any other scheme of an editor. Decided, not
27
+ * overlooked.** It opens the notebook of whoever has that editor installed and
28
+ * that notebook on that machine, so the link works for the person who wrote it
29
+ * and does nothing at all for everybody else who opens the note — a promise
30
+ * the host cannot keep on behalf of a tool it does not run. A notebook is
31
+ * served to whoever reads it, and an address only its author can follow is
32
+ * not an address.
33
+ * - `data:` — a whole page carried inside the address. `data:text/html` is a
34
+ * script running in the reader's session, offered by whoever wrote the note.
35
+ * - `javascript:` and `vbscript:` — script written where an address goes.
36
+ * React blocks the first one on its own; that it does is not a reason to
37
+ * hand it the chance.
38
+ * - `file:` — the disk of whoever is reading, addressed by somebody else.
39
+ *
40
+ * A refused address is not silently swallowed: the text stays on the page and
41
+ * stops being clickable, which is what the anchor of the surface does with an
42
+ * empty href. An author sees they got nothing, the same answer the
43
+ * specification gives to a notation it does not implement.
44
+ */
45
+ /**
46
+ * `pending:`, `attachment:` and `choice:` are this surface's own: a wikilink
47
+ * that matched no note, a reference to a file the notebook keeps beside its
48
+ * notes, and a wikilink several notes may answer. None of them leaves the page
49
+ * — the anchor of the surface turns each into what it is — and they are listed
50
+ * here so the filter does not empty them into the refusal case, which says
51
+ * something else entirely.
52
+ */
53
+ const ALLOWED_SCHEME = /^(https?|mailto|pending|attachment|choice):/i;
54
+ /** Anything of the shape `word:`, which is what makes an address absolute. */
55
+ const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
56
+ /**
57
+ * The address as it will be written, or the empty string when this surface
58
+ * will not follow it.
59
+ *
60
+ * An address with no scheme is a path, an anchor or a relative target, and it
61
+ * cannot reach outside the page on its own: those pass untouched, which is
62
+ * what keeps `./note.md`, `/v/a-notebook/note/x` and `#a-heading` working.
63
+ */
64
+ export function followable(url) {
65
+ const trimmed = url.trim();
66
+ if (!HAS_SCHEME.test(trimmed))
67
+ return url;
68
+ return ALLOWED_SCHEME.test(trimmed) ? url : '';
69
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The entries of the specification whose rendering this renderer does NOT
3
+ * assert, because the base parser is what produces them.
4
+ *
5
+ * **This list is the renderer's decision, and it used to be a field.** Until
6
+ * specification v0.4.0 every entry carried a `ring`, and the reading-surface
7
+ * expectations were scoped to everything outside the `base` one. The
8
+ * specification dropped the tier for a good reason: an implementation is asked
9
+ * for the notation the document lists and not for a specification in full. But
10
+ * the reason the guard was scoped did not go away with the field, so the scope
11
+ * is written here instead of being inferred from data that no longer says it.
12
+ *
13
+ * The reason: asserting that emphasis renders as `<em>` is a claim about
14
+ * react-markdown and not about this surface. CommonMark is the floor every
15
+ * renderer already stands on; what the conformance test proves is what the
16
+ * specification adds on top of it.
17
+ *
18
+ * **Two things this list is not.**
19
+ *
20
+ * It is not a claim that these forms are untested: the crossings — where the
21
+ * specification changes what a base form MEANS, such as a link inside a code
22
+ * span, an embed that looks like an image, or `---` under a paragraph — are
23
+ * asserted by hand in the conformance test.
24
+ *
25
+ * And it is not allowed to silently absorb a new entry. The conformance test
26
+ * asserts that every `reading-surface` entry is either expected there or listed
27
+ * here, so a form the specification adds in a later version fails the build
28
+ * until somebody classifies it.
29
+ */
30
+ export declare const DELEGATED_TO_THE_BASE_PARSER: ReadonlySet<string>;
31
+ /**
32
+ * The `reading-surface` entries of a specification that are neither asserted
33
+ * nor delegated: the ones a renderer would silently not implement. Empty is the
34
+ * only answer a conforming renderer gives.
35
+ */
36
+ export declare function unclassified(notations: ReadonlyArray<{
37
+ readonly id: string;
38
+ readonly reader: string;
39
+ }>, asserted: ReadonlySet<string>, delegated?: ReadonlySet<string>): string[];
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The entries of the specification whose rendering this renderer does NOT
3
+ * assert, because the base parser is what produces them.
4
+ *
5
+ * **This list is the renderer's decision, and it used to be a field.** Until
6
+ * specification v0.4.0 every entry carried a `ring`, and the reading-surface
7
+ * expectations were scoped to everything outside the `base` one. The
8
+ * specification dropped the tier for a good reason: an implementation is asked
9
+ * for the notation the document lists and not for a specification in full. But
10
+ * the reason the guard was scoped did not go away with the field, so the scope
11
+ * is written here instead of being inferred from data that no longer says it.
12
+ *
13
+ * The reason: asserting that emphasis renders as `<em>` is a claim about
14
+ * react-markdown and not about this surface. CommonMark is the floor every
15
+ * renderer already stands on; what the conformance test proves is what the
16
+ * specification adds on top of it.
17
+ *
18
+ * **Two things this list is not.**
19
+ *
20
+ * It is not a claim that these forms are untested: the crossings — where the
21
+ * specification changes what a base form MEANS, such as a link inside a code
22
+ * span, an embed that looks like an image, or `---` under a paragraph — are
23
+ * asserted by hand in the conformance test.
24
+ *
25
+ * And it is not allowed to silently absorb a new entry. The conformance test
26
+ * asserts that every `reading-surface` entry is either expected there or listed
27
+ * here, so a form the specification adds in a later version fails the build
28
+ * until somebody classifies it.
29
+ */
30
+ export const DELEGATED_TO_THE_BASE_PARSER = new Set([
31
+ 'paragraph',
32
+ 'backslash-escape',
33
+ 'character-reference',
34
+ 'heading-atx',
35
+ 'heading-setext',
36
+ 'thematic-break',
37
+ 'block-quote',
38
+ 'list-bullet',
39
+ 'list-ordered',
40
+ 'code-fenced',
41
+ 'code-indented',
42
+ 'link-reference-definition',
43
+ 'code-span',
44
+ 'emphasis',
45
+ 'strong',
46
+ 'strong-emphasis',
47
+ 'link-inline',
48
+ 'link-reference',
49
+ 'image',
50
+ 'autolink',
51
+ 'hard-line-break',
52
+ ]);
53
+ /**
54
+ * The `reading-surface` entries of a specification that are neither asserted
55
+ * nor delegated: the ones a renderer would silently not implement. Empty is the
56
+ * only answer a conforming renderer gives.
57
+ */
58
+ export function unclassified(notations, asserted, delegated = DELEGATED_TO_THE_BASE_PARSER) {
59
+ return notations
60
+ .filter((entry) => entry.reader === 'reading-surface')
61
+ .map((entry) => entry.id)
62
+ .filter((id) => !asserted.has(id) && !delegated.has(id));
63
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Footnotes across the runs of a note (§7.12).
3
+ *
4
+ * `NoteBody` draws the text between two embeds as runs of their own, each
5
+ * through its own parser, and a footnote is resolved inside one parse: GFM
6
+ * reads `[^label]` as a reference only when the same document defines
7
+ * `label`. A reference above an embed and its definition below it therefore
8
+ * never met, and the reference reached the page as its characters. MemorySmith's
9
+ * reading page does the same; this renderer does not carry it.
10
+ *
11
+ * The runs stay runs — they are what keeps the task ordinals and the layout
12
+ * of every other note as MemorySmith draws them — and the note is drawn as one
13
+ * document would draw it:
14
+ *
15
+ * - every run is handed the definitions written in the OTHER runs, appended
16
+ * after its own text, so its references parse; appending after the text
17
+ * moves no offset the task boxes are counted from;
18
+ * - each run's own footnotes section is dropped, and each reference is
19
+ * renumbered in the order of the note, with the id one document would give
20
+ * its n-th use of a label;
21
+ * - one section is drawn at the end of the note, after its last embed too,
22
+ * from every reference of the note in order and every definition, so its
23
+ * numbers and its ways back are the ones a single document would have.
24
+ *
25
+ * Only a definition written at the top level of a run travels. A note with
26
+ * one inside a quote or a list, with no embed, or with no definition, is
27
+ * planned as nothing and drawn exactly as before.
28
+ */
29
+ import { type PluggableList } from 'unified';
30
+ /** The prefix remark-rehype gives every footnote id, set explicitly where `Markdown` draws. */
31
+ export declare const CLOBBER_PREFIX = "user-content-";
32
+ interface HastNode {
33
+ type: string;
34
+ tagName?: string;
35
+ value?: string;
36
+ properties?: Record<string, unknown>;
37
+ children?: HastNode[];
38
+ }
39
+ /** One reference as the note numbers it: its footnote's number, and which use of the label it is. */
40
+ export interface FootnoteRef {
41
+ readonly number: number;
42
+ readonly occurrence: number;
43
+ }
44
+ export interface FootnotePlan {
45
+ /** For each run, in order: the text its parser reads, and its references in the order it draws them. */
46
+ readonly runs: readonly {
47
+ readonly text: string;
48
+ readonly refs: readonly FootnoteRef[];
49
+ }[];
50
+ /** The text the one footnotes section of the note is drawn from. */
51
+ readonly section: string;
52
+ }
53
+ /**
54
+ * How the runs of a note draw their footnotes, or null when nothing has to
55
+ * change: a note no embed cuts, or one with no footnote to carry. `cut` says
56
+ * whether an embed cut it — a note of one run followed by an embed is cut
57
+ * too, because its notes belong below the embed, at the end of the note.
58
+ *
59
+ * `plugins` are the remark plugins of `Markdown`, so a reference the reading
60
+ * surface would not draw — inside a comment, say — is not counted either.
61
+ */
62
+ export declare function planFootnotes(runs: readonly string[], cut: boolean, plugins: PluggableList): FootnotePlan | null;
63
+ /**
64
+ * The rehype side of a run: its own section dropped, and each reference given
65
+ * the number and the id the note gives it.
66
+ */
67
+ export declare function rehypeFootnoteRun(options: {
68
+ refs: readonly FootnoteRef[];
69
+ }): (tree: HastNode) => void;
70
+ /** The rehype side of the section pass: the section and nothing else. */
71
+ export declare function rehypeFootnoteSection(): (tree: HastNode) => void;
72
+ export {};
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Footnotes across the runs of a note (§7.12).
3
+ *
4
+ * `NoteBody` draws the text between two embeds as runs of their own, each
5
+ * through its own parser, and a footnote is resolved inside one parse: GFM
6
+ * reads `[^label]` as a reference only when the same document defines
7
+ * `label`. A reference above an embed and its definition below it therefore
8
+ * never met, and the reference reached the page as its characters. MemorySmith's
9
+ * reading page does the same; this renderer does not carry it.
10
+ *
11
+ * The runs stay runs — they are what keeps the task ordinals and the layout
12
+ * of every other note as MemorySmith draws them — and the note is drawn as one
13
+ * document would draw it:
14
+ *
15
+ * - every run is handed the definitions written in the OTHER runs, appended
16
+ * after its own text, so its references parse; appending after the text
17
+ * moves no offset the task boxes are counted from;
18
+ * - each run's own footnotes section is dropped, and each reference is
19
+ * renumbered in the order of the note, with the id one document would give
20
+ * its n-th use of a label;
21
+ * - one section is drawn at the end of the note, after its last embed too,
22
+ * from every reference of the note in order and every definition, so its
23
+ * numbers and its ways back are the ones a single document would have.
24
+ *
25
+ * Only a definition written at the top level of a run travels. A note with
26
+ * one inside a quote or a list, with no embed, or with no definition, is
27
+ * planned as nothing and drawn exactly as before.
28
+ */
29
+ import remarkParse from 'remark-parse';
30
+ import { unified } from 'unified';
31
+ /** The prefix remark-rehype gives every footnote id, set explicitly where `Markdown` draws. */
32
+ export const CLOBBER_PREFIX = 'user-content-';
33
+ /** What one document would key a label by: remark-rehype's own normalisation. */
34
+ const keyOf = (node) => String(node.identifier ?? '').toUpperCase();
35
+ /**
36
+ * How the runs of a note draw their footnotes, or null when nothing has to
37
+ * change: a note no embed cuts, or one with no footnote to carry. `cut` says
38
+ * whether an embed cut it — a note of one run followed by an embed is cut
39
+ * too, because its notes belong below the embed, at the end of the note.
40
+ *
41
+ * `plugins` are the remark plugins of `Markdown`, so a reference the reading
42
+ * surface would not draw — inside a comment, say — is not counted either.
43
+ */
44
+ export function planFootnotes(runs, cut, plugins) {
45
+ if (!cut || !runs.some((run) => run.includes('[^')))
46
+ return null;
47
+ const processor = unified().use(remarkParse).use(plugins);
48
+ const read = (text) => processor.runSync(processor.parse(text), text);
49
+ // The definitions, the first of each label winning, as in one document.
50
+ const definitions = new Map();
51
+ let nested = false;
52
+ runs.forEach((run, index) => {
53
+ const tree = processor.parse(run);
54
+ const below = (node) => (node.children ?? []).some((child) => child.type === 'footnoteDefinition' || below(child));
55
+ nested ||= (tree.children ?? []).some(below);
56
+ for (const child of tree.children ?? []) {
57
+ const start = child.position?.start?.offset;
58
+ const end = child.position?.end?.offset;
59
+ if (child.type !== 'footnoteDefinition' || start === undefined || end === undefined)
60
+ continue;
61
+ if (!definitions.has(keyOf(child)))
62
+ definitions.set(keyOf(child), { source: run.slice(start, end), run: index });
63
+ }
64
+ });
65
+ // A definition inside a quote or a list cannot be lifted into the section
66
+ // without its markers, so a note that has one is drawn as before.
67
+ if (definitions.size === 0 || nested)
68
+ return null;
69
+ const order = [];
70
+ const uses = new Map();
71
+ const labels = [];
72
+ const planned = runs.map((run, index) => {
73
+ const others = [...definitions.values()].filter((definition) => definition.run !== index);
74
+ const text = others.length === 0 ? run : `${run}\n\n${others.map((definition) => definition.source).join('\n\n')}\n`;
75
+ const refs = [];
76
+ const walk = (node) => {
77
+ // A reference inside a definition is drawn in the section, after every
78
+ // reference of the body, which is where the section pass meets it.
79
+ if (node.type === 'footnoteDefinition')
80
+ return;
81
+ if (node.type === 'footnoteReference') {
82
+ const key = keyOf(node);
83
+ if (!order.includes(key))
84
+ order.push(key);
85
+ const occurrence = (uses.get(key) ?? 0) + 1;
86
+ uses.set(key, occurrence);
87
+ labels.push(node.label ?? node.identifier ?? '');
88
+ refs.push({ number: order.indexOf(key) + 1, occurrence });
89
+ }
90
+ for (const child of node.children ?? [])
91
+ walk(child);
92
+ };
93
+ walk(read(text));
94
+ return { text, refs };
95
+ });
96
+ if (order.length === 0)
97
+ return null;
98
+ const section = `${labels.map((label) => `[^${label}]`).join(' ')}\n\n${[...definitions.values()]
99
+ .map((definition) => definition.source)
100
+ .join('\n\n')}\n`;
101
+ return { runs: planned, section };
102
+ }
103
+ function isElement(node, tagName) {
104
+ return node.type === 'element' && node.tagName === tagName;
105
+ }
106
+ const isSection = (node) => isElement(node, 'section') && node.properties?.dataFootnotes !== undefined;
107
+ /**
108
+ * The rehype side of a run: its own section dropped, and each reference given
109
+ * the number and the id the note gives it.
110
+ */
111
+ export function rehypeFootnoteRun(options) {
112
+ return (tree) => {
113
+ tree.children = (tree.children ?? []).filter((child) => !isSection(child));
114
+ let index = 0;
115
+ const walk = (node) => {
116
+ if (isElement(node, 'a') && node.properties?.dataFootnoteRef !== undefined) {
117
+ const ref = options.refs[index++];
118
+ const href = String(node.properties.href ?? '');
119
+ if (ref && href.startsWith(`#${CLOBBER_PREFIX}fn-`)) {
120
+ const safe = href.slice(`#${CLOBBER_PREFIX}fn-`.length);
121
+ node.properties.id = `${CLOBBER_PREFIX}fnref-${safe}${ref.occurrence > 1 ? `-${ref.occurrence}` : ''}`;
122
+ node.children = [{ type: 'text', value: String(ref.number) }];
123
+ }
124
+ return;
125
+ }
126
+ for (const child of node.children ?? [])
127
+ walk(child);
128
+ };
129
+ walk(tree);
130
+ };
131
+ }
132
+ /** The rehype side of the section pass: the section and nothing else. */
133
+ export function rehypeFootnoteSection() {
134
+ return (tree) => {
135
+ tree.children = (tree.children ?? []).filter(isSection);
136
+ };
137
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Syntax highlighting for a fenced block that names its language.
3
+ *
4
+ * **This is presentation and nothing else.** The specification attaches a rendering
5
+ * rule to exactly one info string, `mermaid`, and says of every other one only
6
+ * that it names the language by convention. So highlighting is a product
7
+ * decision rather than a conformance obligation — §8 is explicit that an
8
+ * implementation MAY render an undescribed form however it likes — and the
9
+ * four rules below are the ones the specification *does* impose on anything sitting
10
+ * this close to the body of a note.
11
+ *
12
+ * **The bytes never change.** Highlighting is display. §7.6 sets the principle
13
+ * for comments — the bytes stay in the file and a tool that returns the note
14
+ * returns them — and §7.11 states it as behaviour, where a task toggle writes
15
+ * back exactly the one character that changed. Nothing here touches the
16
+ * source: the tokens are read from it and the string is not rewritten.
17
+ *
18
+ * **An unknown language renders as plain code, never as nothing.** The specification
19
+ * sets that pattern twice, for the diagram it cannot draw (§7.2) and for the
20
+ * mathematics it cannot typeset (§7.8): show the source, never hide it. A
21
+ * language `refractor` does not carry falls through to exactly what was
22
+ * rendered before this existed.
23
+ *
24
+ * **`mermaid` keeps its branch**, and it is checked before this is reached. It
25
+ * is the one info string with a rendering rule, and a highlighter installed in
26
+ * front of that check would swallow a diagram.
27
+ *
28
+ * **The markup arrives as elements and never as an HTML string.** §7.9 refuses
29
+ * raw HTML as a security boundary — a notebook is written by several people and
30
+ * by agents, and a page that renders arbitrary HTML out of one is a script
31
+ * injection whose trigger is written by whoever wrote the note. A highlighter
32
+ * returning HTML to be injected reopens that door with the note body as its
33
+ * input. `refractor` produces a hast tree, which is converted to React
34
+ * elements, so nothing is ever parsed as markup.
35
+ *
36
+ * **The languages are enumerated rather than bundled.** Prism carries close to
37
+ * three hundred grammars and a knowledge notebook writes in a handful. The list
38
+ * is what a notebook of MemorySmith plausibly holds — the languages of its own
39
+ * repository, the two query languages, the three configuration formats and the
40
+ * two shells — and adding one is a line, which is the point: a bundle nobody
41
+ * chose is a bundle nobody can defend. It is MemorySmith's list, unchanged, so
42
+ * a fence is highlighted here exactly when it is highlighted there.
43
+ */
44
+ import type { Root } from 'hast';
45
+ /**
46
+ * The language of a fenced block, from the class react-markdown puts on it.
47
+ *
48
+ * `null` for a block with no info string, for one whose language is not
49
+ * carried, and for `mermaid` — which never reaches here, and is refused
50
+ * explicitly so that a future caller cannot make it.
51
+ */
52
+ export declare function highlightLanguage(className: string | undefined): string | null;
53
+ /** The tokens of `code` in `language`, as a hast tree. Never an HTML string. */
54
+ export declare function highlightTree(code: string, language: string): Root;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Syntax highlighting for a fenced block that names its language.
3
+ *
4
+ * **This is presentation and nothing else.** The specification attaches a rendering
5
+ * rule to exactly one info string, `mermaid`, and says of every other one only
6
+ * that it names the language by convention. So highlighting is a product
7
+ * decision rather than a conformance obligation — §8 is explicit that an
8
+ * implementation MAY render an undescribed form however it likes — and the
9
+ * four rules below are the ones the specification *does* impose on anything sitting
10
+ * this close to the body of a note.
11
+ *
12
+ * **The bytes never change.** Highlighting is display. §7.6 sets the principle
13
+ * for comments — the bytes stay in the file and a tool that returns the note
14
+ * returns them — and §7.11 states it as behaviour, where a task toggle writes
15
+ * back exactly the one character that changed. Nothing here touches the
16
+ * source: the tokens are read from it and the string is not rewritten.
17
+ *
18
+ * **An unknown language renders as plain code, never as nothing.** The specification
19
+ * sets that pattern twice, for the diagram it cannot draw (§7.2) and for the
20
+ * mathematics it cannot typeset (§7.8): show the source, never hide it. A
21
+ * language `refractor` does not carry falls through to exactly what was
22
+ * rendered before this existed.
23
+ *
24
+ * **`mermaid` keeps its branch**, and it is checked before this is reached. It
25
+ * is the one info string with a rendering rule, and a highlighter installed in
26
+ * front of that check would swallow a diagram.
27
+ *
28
+ * **The markup arrives as elements and never as an HTML string.** §7.9 refuses
29
+ * raw HTML as a security boundary — a notebook is written by several people and
30
+ * by agents, and a page that renders arbitrary HTML out of one is a script
31
+ * injection whose trigger is written by whoever wrote the note. A highlighter
32
+ * returning HTML to be injected reopens that door with the note body as its
33
+ * input. `refractor` produces a hast tree, which is converted to React
34
+ * elements, so nothing is ever parsed as markup.
35
+ *
36
+ * **The languages are enumerated rather than bundled.** Prism carries close to
37
+ * three hundred grammars and a knowledge notebook writes in a handful. The list
38
+ * is what a notebook of MemorySmith plausibly holds — the languages of its own
39
+ * repository, the two query languages, the three configuration formats and the
40
+ * two shells — and adding one is a line, which is the point: a bundle nobody
41
+ * chose is a bundle nobody can defend. It is MemorySmith's list, unchanged, so
42
+ * a fence is highlighted here exactly when it is highlighted there.
43
+ */
44
+ import { refractor } from 'refractor/core';
45
+ import bash from 'refractor/bash';
46
+ import css from 'refractor/css';
47
+ import diff from 'refractor/diff';
48
+ import graphql from 'refractor/graphql';
49
+ import ini from 'refractor/ini';
50
+ import java from 'refractor/java';
51
+ import javascript from 'refractor/javascript';
52
+ import json from 'refractor/json';
53
+ import markdown from 'refractor/markdown';
54
+ import python from 'refractor/python';
55
+ import sql from 'refractor/sql';
56
+ import toml from 'refractor/toml';
57
+ import tsx from 'refractor/tsx';
58
+ import typescript from 'refractor/typescript';
59
+ import yaml from 'refractor/yaml';
60
+ for (const language of [
61
+ bash,
62
+ css,
63
+ diff,
64
+ graphql,
65
+ ini,
66
+ java,
67
+ javascript,
68
+ json,
69
+ markdown,
70
+ python,
71
+ sql,
72
+ toml,
73
+ tsx,
74
+ typescript,
75
+ yaml,
76
+ ]) {
77
+ refractor.register(language);
78
+ }
79
+ /**
80
+ * The language of a fenced block, from the class react-markdown puts on it.
81
+ *
82
+ * `null` for a block with no info string, for one whose language is not
83
+ * carried, and for `mermaid` — which never reaches here, and is refused
84
+ * explicitly so that a future caller cannot make it.
85
+ */
86
+ export function highlightLanguage(className) {
87
+ const match = /(?:^|\s)language-([A-Za-z0-9#+._-]+)/.exec(className ?? '');
88
+ const named = match?.[1]?.toLowerCase();
89
+ if (!named || named === 'mermaid')
90
+ return null;
91
+ return refractor.registered(named) ? named : null;
92
+ }
93
+ /** The tokens of `code` in `language`, as a hast tree. Never an HTML string. */
94
+ export function highlightTree(code, language) {
95
+ return refractor.highlight(code, language);
96
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type NoteStrings } from './strings.js';
3
+ /**
4
+ * The host contract: everything the renderer needs from the notebook a note lives in, given by
5
+ * whoever draws it.
6
+ *
7
+ * The package knows no API, no router, no i18n library and no query cache. What MemorySmith reads
8
+ * from those reaches the component as these props: functions that answer about the notebook, and
9
+ * the words the page shows. The host decides; the package draws.
10
+ *
11
+ * **Every default is the safe one.** With no `resolve`, every link is pending; with no `keeps`, no
12
+ * embed reaches a file; with no `renderLink`, an internal address is a plain anchor; with no
13
+ * `strings`, the words are MemorySmith's English.
14
+ */
15
+ export interface NoteHost {
16
+ /**
17
+ * The address of the one note a link target names, compared after NFC, or `null` when no single
18
+ * note answers it. The target is a name, literal: never slugified, never stripped of an extension.
19
+ */
20
+ readonly resolve?: (name: string) => string | null;
21
+ /**
22
+ * How many notes a target reaches, by name or, when no name matches, by alias (§5.2). One is a
23
+ * link; more than one is a choice; none is pending. Defaults to one when `resolve` answers and
24
+ * none otherwise.
25
+ */
26
+ readonly reaches?: (name: string) => number;
27
+ /**
28
+ * The notes a target no single note answers may lead to, asked when its link is clicked. With it,
29
+ * such a link opens the choice in place — or goes straight to the note, when one answers by
30
+ * alias. Without it, the link is pending and asks nothing.
31
+ */
32
+ readonly choose?: (name: string) => Promise<LinkChoices>;
33
+ /** The address of the host's page about a target, which a choice link carries for a new tab. */
34
+ readonly targetAddress?: (name: string) => string | null;
35
+ /** Goes to an internal address, through the host's router. Defaults to `location.assign`. */
36
+ readonly navigate?: (address: string) => void;
37
+ /**
38
+ * The note a target names, for an embed to draw in place: its body as the host keeps it, its name
39
+ * and its address. Asked only for a target exactly one note reaches; `null` draws the embed as
40
+ * pending. Without it, every embed of a note is pending. The package never fetches.
41
+ */
42
+ readonly loadNote?: (name: string) => Promise<EmbeddedNote | null>;
43
+ /**
44
+ * Whether the reader may tick the task boxes of this note. Defaults to `false`: every box shows
45
+ * its state and none answers a click.
46
+ */
47
+ readonly writable?: boolean;
48
+ /**
49
+ * A task box was ticked. The ordinal counts the boxes of the note as the host gave it, frontmatter
50
+ * included; the host applies it with `toggleTaskAt`, a one-character change of its bytes. The
51
+ * package never writes the note.
52
+ */
53
+ readonly onToggleTask?: (ordinal: number) => void;
54
+ /** Whether the notebook keeps a file under that name. Only an embed reaches a file. */
55
+ readonly keeps?: (name: string) => boolean;
56
+ /**
57
+ * Draws a file the notebook keeps where a note embeds it: an image, audio, video, or a card. The
58
+ * dimensions are the ones the embed declared, in CSS pixels, or `null`.
59
+ */
60
+ readonly renderAttachment?: (file: AttachmentRequest) => ReactNode;
61
+ /** Draws a link to an internal address, through the host's router. */
62
+ readonly renderLink?: (link: LinkRequest) => ReactNode;
63
+ /** The words the page shows, in the reader's language; each one left out is MemorySmith's English. */
64
+ readonly strings?: Partial<NoteStrings>;
65
+ /** The theme the host draws in, which picks the theme of a Mermaid diagram. Defaults to `light`. */
66
+ readonly theme?: 'light' | 'dark';
67
+ /**
68
+ * Whether a note that fetched images from other sites says so, naming them, under the body
69
+ * (§7.10). Defaults to `true`: the specification forbids only silence. A host that discloses it
70
+ * elsewhere turns it off and reads the hosts with `remoteImageHosts`.
71
+ */
72
+ readonly remoteNotice?: boolean;
73
+ }
74
+ /** What a target that no single note answers may lead to. */
75
+ export interface LinkChoices {
76
+ /** What matched: the names of notes, their aliases, or nothing. */
77
+ readonly by: 'name' | 'alias' | null;
78
+ readonly notes: readonly LinkChoiceNote[];
79
+ }
80
+ /** A note a choice offers. */
81
+ export interface LinkChoiceNote {
82
+ /** Its name, or `null` for a note that has none. */
83
+ readonly name: string | null;
84
+ /** Its address, which the host follows. */
85
+ readonly address: string;
86
+ /** The folders from the root down to the one that holds it. */
87
+ readonly trail: readonly string[];
88
+ /** What the folder that holds it is for, in the notebook's words. */
89
+ readonly folderDescription: string;
90
+ }
91
+ /** A note an embed draws in place. */
92
+ export interface EmbeddedNote {
93
+ /** The note as the host keeps it, frontmatter included. */
94
+ readonly markdown: string;
95
+ /** Its name, or `null` for a note that has none. */
96
+ readonly name: string | null;
97
+ /** Its address, which the caption under the embed links to, or `null`. */
98
+ readonly address: string | null;
99
+ }
100
+ /** A file to draw: its name, and the dimensions the embed declared. */
101
+ export interface AttachmentRequest {
102
+ readonly name: string;
103
+ readonly width: number | null;
104
+ readonly height: number | null;
105
+ }
106
+ /** A link to draw through the host's router. */
107
+ export interface LinkRequest {
108
+ readonly href: string;
109
+ readonly className?: string;
110
+ readonly children: ReactNode;
111
+ }
112
+ /**
113
+ * Every member of the contract, by name. The `satisfies` is what makes it complete: a member added
114
+ * to `NoteHost` and not listed here does not compile.
115
+ */
116
+ export declare const HOST_KEYS: (keyof NoteHost)[];
117
+ /** The host contract with every default applied. */
118
+ export interface ResolvedHost {
119
+ readonly resolve: (name: string) => string | null;
120
+ readonly reaches: (name: string) => number;
121
+ readonly choose: ((name: string) => Promise<LinkChoices>) | null;
122
+ readonly targetAddress: (name: string) => string | null;
123
+ readonly navigate: (address: string) => void;
124
+ readonly remoteNotice: boolean;
125
+ readonly loadNote: ((name: string) => Promise<EmbeddedNote | null>) | null;
126
+ readonly keeps: (name: string) => boolean;
127
+ readonly renderAttachment: ((file: AttachmentRequest) => ReactNode) | null;
128
+ readonly renderLink: (link: LinkRequest) => ReactNode;
129
+ readonly strings: NoteStrings;
130
+ readonly theme: 'light' | 'dark';
131
+ }
132
+ export declare function resolveHost(host: NoteHost): ResolvedHost;
133
+ export declare const HostProvider: import("react").Provider<ResolvedHost>;
134
+ /** The host the note is drawn under, read by every part of the surface. */
135
+ export declare function useHost(): ResolvedHost;