@memorysmithapp/markdown-renderer 0.1.0 → 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.
@@ -1,3 +1,5 @@
1
+ import type { PluggableList } from 'unified';
2
+ import { type FootnoteRef } from './footnotes.js';
1
3
  /**
2
4
  * One run of a note, parsed and drawn: the pipeline of MemorySmith's reading page.
3
5
  *
@@ -16,5 +18,18 @@ export interface MarkdownProps {
16
18
  readonly source?: string;
17
19
  readonly onToggleTask?: (ordinal: number) => void;
18
20
  readonly writable?: boolean;
21
+ /**
22
+ * How this run takes part in the footnotes of a note drawn in several runs
23
+ * (`footnotes.ts`): the number and id of each of its references, or
24
+ * `'section'` for the pass that draws the note's one section.
25
+ */
26
+ readonly footnotes?: {
27
+ readonly refs: readonly FootnoteRef[];
28
+ } | 'section';
19
29
  }
20
- export declare function Markdown({ children, source, onToggleTask, writable }: MarkdownProps): import("react").JSX.Element;
30
+ /**
31
+ * The remark plugins of the reading surface, in their order. `planFootnotes` reads a note
32
+ * through the same list, so it counts the references this surface draws and no other.
33
+ */
34
+ export declare function readingPlugins(titleOf: (kind: string) => string | undefined): PluggableList;
35
+ export declare function Markdown({ children, source, onToggleTask, writable, footnotes }: MarkdownProps): import("react").JSX.Element;
package/dist/Markdown.js CHANGED
@@ -16,6 +16,7 @@ import { toUnixNewlines } from './markdown.js';
16
16
  import { MermaidDiagram } from './MermaidDiagram.js';
17
17
  import { remarkCallouts } from './remark-callouts.js';
18
18
  import { remarkBlockIds, remarkComments, remarkHighlight, remarkMathDollarRule } from './remark-ring.js';
19
+ import { CLOBBER_PREFIX, rehypeFootnoteRun, rehypeFootnoteSection } from './footnotes.js';
19
20
  import { ordinalAt } from './tasklist.js';
20
21
  /**
21
22
  * An image, with the dimensions the specification puts in its alt text
@@ -42,8 +43,13 @@ function MarkdownImage({ alt, node: _node, ...rest }) {
42
43
  function InPageAnchor({ href, children, ...rest }) {
43
44
  const follow = (event) => {
44
45
  const id = decodeURIComponent(href.slice(1));
45
- const note = event.currentTarget.closest('.markdown');
46
- const target = note?.querySelector(`[id="${CSS.escape(id)}"]`);
46
+ // The note is the root `NoteBody` draws, since a footnote's way back may
47
+ // cross runs; inside an embed it is the embed's own run. A match inside
48
+ // an embed answers only a link inside that same embed.
49
+ const link = event.currentTarget;
50
+ const embed = link.closest('.embed');
51
+ const note = embed ? link.closest('.markdown') : (link.closest('.markdown[data-theme]') ?? link.closest('.markdown'));
52
+ const target = [...(note?.querySelectorAll(`[id="${CSS.escape(id)}"]`) ?? [])].find((candidate) => candidate.closest('.embed') === embed);
47
53
  if (!(target instanceof HTMLElement))
48
54
  return;
49
55
  event.preventDefault();
@@ -209,42 +215,58 @@ function dropBox(children) {
209
215
  return children;
210
216
  return children.filter((child) => !(isValidElement(child) && child.type === 'input'));
211
217
  }
212
- export function Markdown({ children, source, onToggleTask, writable = false }) {
218
+ /**
219
+ * The remark plugins of the reading surface, in their order. `planFootnotes` reads a note
220
+ * through the same list, so it counts the references this surface draws and no other.
221
+ */
222
+ export function readingPlugins(titleOf) {
223
+ return [
224
+ /**
225
+ * `singleTilde: false` is a decision and not a default.
226
+ *
227
+ * GFM specifies strikethrough as `~~x~~`; GitHub also accepts a
228
+ * single tilde, outside its own specification. Here that extension
229
+ * actively misrenders a notation the specification leaves out:
230
+ * somebody writing `H~2~O` for a subscript would get `H` struck-out
231
+ * `2` `O`, which is a worse answer than nothing. With it off the
232
+ * characters stay on the page, and the author can see they got what
233
+ * the specification says they get, which is nothing.
234
+ */
235
+ [remarkGfm, { singleTilde: false }],
236
+ [remarkCallouts, { titleOf }],
237
+ remarkHighlight,
238
+ remarkComments,
239
+ remarkBlockIds,
240
+ remarkMath,
241
+ // After remark-math, and it gives back what was never a formula.
242
+ remarkMathDollarRule,
243
+ ];
244
+ }
245
+ export function Markdown({ children, source, onToggleTask, writable = false, footnotes }) {
213
246
  const text = toUnixNewlines(children);
214
247
  const { strings } = useHost();
215
248
  const calloutTitle = (kind) => Object.hasOwn(strings.callouts, kind) ? strings.callouts[kind] : undefined;
216
- return (_jsx("div", { className: "markdown", children: _jsx(ReactMarkdown, { remarkPlugins: [
217
- /**
218
- * `singleTilde: false` is a decision and not a default.
219
- *
220
- * GFM specifies strikethrough as `~~x~~`; GitHub also accepts a
221
- * single tilde, outside its own specification. Here that extension
222
- * actively misrenders a notation the specification leaves out:
223
- * somebody writing `H~2~O` for a subscript would get `H` struck-out
224
- * `2` `O`, which is a worse answer than nothing. With it off the
225
- * characters stay on the page, and the author can see they got what
226
- * the specification says they get, which is nothing.
227
- */
228
- [remarkGfm, { singleTilde: false }],
229
- [remarkCallouts, { titleOf: calloutTitle }],
230
- remarkHighlight,
231
- remarkComments,
232
- remarkBlockIds,
233
- remarkMath,
234
- // After remark-math, and it gives back what was never a formula.
235
- remarkMathDollarRule,
236
- ],
249
+ return (_jsx("div", { className: "markdown", children: _jsx(ReactMarkdown, { remarkPlugins: readingPlugins(calloutTitle),
237
250
  // Raw HTML is NOT enabled, and its absence is the point: no
238
251
  // `rehype-raw` is loaded, so a note carrying `<script>` is text. It is
239
252
  // a security boundary rather than a rendering preference, because a
240
253
  // notebook is written by several people and by agents (§7.9).
241
254
  // A formula KaTeX cannot typeset shows its source, never an error (§7.8).
242
- rehypePlugins: [[rehypeKatex, { throwOnError: false }]],
255
+ rehypePlugins: [
256
+ [rehypeKatex, { throwOnError: false }],
257
+ ...(footnotes === undefined
258
+ ? []
259
+ : footnotes === 'section'
260
+ ? [rehypeFootnoteSection]
261
+ : [[rehypeFootnoteRun, footnotes]]),
262
+ ],
243
263
  // Footnotes (§7.12): the words around them are the reader's, and the
244
264
  // label heads the notes gathered at the end of the note, where a
245
265
  // reader sees it — GFM hides it, and a stylesheet that hides nothing
246
266
  // shows it as a visible heading, so it is in the reader's language.
247
267
  remarkRehypeOptions: {
268
+ // remark-rehype's own default, stated: `footnotes.ts` renumbers by it.
269
+ clobberPrefix: CLOBBER_PREFIX,
248
270
  footnoteLabel: strings.footnotes,
249
271
  footnoteLabelProperties: { className: ['footnotes-label'] },
250
272
  footnoteBackLabel: (referenceIndex) => strings.footnoteBack(referenceIndex + 1),
package/dist/NoteBody.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from 'react';
3
3
  import { HOST_KEYS, HostProvider, resolveHost } from './host.js';
4
- import { Markdown } from './Markdown.js';
4
+ import { planFootnotes } from './footnotes.js';
5
+ import { Markdown, readingPlugins } from './Markdown.js';
5
6
  import { bodyStart, remoteImageHosts, resolveWikilinks } from './markdown.js';
6
7
  import { taskBoxes } from './tasklist.js';
7
8
  import { Transclusion } from './Transclusion.js';
@@ -64,16 +65,28 @@ export function NoteBody({ markdown, writable = false, onToggleTask, ...host })
64
65
  * every note would be a thing to scroll past rather than a thing to read.
65
66
  */
66
67
  const hosts = resolved.remoteNotice ? remoteImageHosts(body) : [];
68
+ // The footnotes of a note an embed cuts (§7.12): each run is handed the
69
+ // definitions of the others and the numbers of the note, and the note's one
70
+ // section is drawn at its end, below the last embed too. Null, and nothing
71
+ // changes, for a note no embed cuts or with no footnote to carry.
72
+ const runs = segments.flatMap((segment) => ('rendered' in segment ? [segment.rendered] : []));
73
+ const footnotes = useMemo(() => planFootnotes(runs, runs.length < segments.length, readingPlugins(() => undefined)),
74
+ // The runs and whether an embed cut them are the whole input.
75
+ [runs.join('\u0000'), runs.length < segments.length]);
76
+ let run = 0;
67
77
  return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: "markdown", "data-theme": resolved.theme, children: [segments.map((segment, index) => {
68
78
  if (!('rendered' in segment)) {
69
79
  return _jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index);
70
80
  }
71
81
  const base = seen;
72
82
  seen += taskBoxes(segment.rendered).length;
83
+ const planned = footnotes?.runs[run++];
73
84
  return (_jsx(Markdown, {
74
85
  // The text the ordinal is counted over, stated rather than left to
75
86
  // a default: it is the one the parser reports offsets into, and
76
87
  // `mappable` above is what makes it safe to write back through.
77
- source: segment.rendered, onToggleTask: onToggleTask && ((ordinal) => onToggleTask(base + ordinal)), writable: writable && mappable, children: segment.rendered }, index));
78
- }), hosts.length > 0 && _jsx("p", { className: "remote-notice", children: resolved.strings.remoteImages(hosts) })] }) }));
88
+ // A planned run is the same text with definitions appended after
89
+ // it, so every box keeps the offset it had.
90
+ source: planned?.text ?? segment.rendered, onToggleTask: onToggleTask && ((ordinal) => onToggleTask(base + ordinal)), writable: writable && mappable, ...(planned ? { footnotes: { refs: planned.refs } } : {}), children: planned?.text ?? segment.rendered }, index));
91
+ }), footnotes && _jsx(Markdown, { footnotes: "section", children: footnotes.section }), hosts.length > 0 && _jsx("p", { className: "remote-notice", children: resolved.strings.remoteImages(hosts) })] }) }));
79
92
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorysmithapp/markdown-renderer",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A React component that draws a MemorySmith note exactly as MemorySmith's reading page does.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -54,10 +54,12 @@
54
54
  "refractor": "5.0.0",
55
55
  "rehype-katex": "7.0.1",
56
56
  "remark-gfm": "4.0.1",
57
- "remark-math": "6.0.0"
57
+ "remark-math": "6.0.0",
58
+ "remark-parse": "11.0.0",
59
+ "unified": "11.0.5"
58
60
  },
59
61
  "devDependencies": {
60
- "@memorysmithapp/markdown-spec": "0.1.0",
62
+ "@memorysmithapp/markdown-spec": "0.1.1",
61
63
  "@types/hast": "3.0.5",
62
64
  "@types/react": "19.2.18",
63
65
  "@types/react-dom": "19.2.4",