@memorysmithapp/markdown-renderer 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -74,6 +74,24 @@ const mine = defineTheme({ name: 'mine', components: { properties: () => null }
74
74
 
75
75
  Import `structure.css` instead of `styles.css` for a theme that draws everything itself. A theme names faces; your page loads them.
76
76
 
77
+ ## Diagnose
78
+
79
+ Every note is valid, and nothing here refuses one. What `diagnose` tells a host is where the page will not show what was most likely typed, and what the note lacks that the specification reserves a place for: the notices of [`SPEC.md` §7.13](https://github.com/memorysmithapp/memorysmithapp-markdown/blob/main/packages/markdown-spec/SPEC.md#713-notices). It imports no React and needs no DOM, so it runs in an editor, on a server, or in a tool that writes a note.
80
+
81
+ ```ts
82
+ import { diagnose } from '@memorysmithapp/markdown-renderer/diagnose';
83
+
84
+ const notices = await diagnose(note, {
85
+ resolve: (name) => addressOfTheOneNoteNamed(name), // or reaches; leave both out and no link is pending
86
+ keeps: (name) => notebookKeepsAFile(name),
87
+ });
88
+ // [{ code: 'heading-skipped-level', severity: 'notice', section: '3.3',
89
+ // message: 'This heading skips a level below the heading before it.',
90
+ // line: 12, column: 1, endLine: 12, endColumn: 14 }, …]
91
+ ```
92
+
93
+ A `notice` says the page will differ from the text — a fence never closed, a formula KaTeX cannot typeset, a diagram Mermaid cannot read, raw HTML, a footnote with no definition or no reference, a single-tilde strikethrough, a frontmatter never closed, a `name` that leaves the note unnamed or none at all, `aliases` or `tags` that are not a list, a body that does not start at `#` or skips a level. A `hint` says nothing is drawn wrong: no `aliases`, no `tags`, a link or an embed no note answers yet. Lines and columns count from 1 over the whole note, frontmatter included, and the `message` is English; a host words it in its own language from the `code`, which is stable. Mermaid is loaded only for a note that holds a diagram, and `diagrams: false` skips it.
94
+
77
95
  The host contract, the stylesheet's properties, the slots and the decisions behind them are in the repository's [`ARCHITECTURE.md`](https://github.com/memorysmithapp/memorysmithapp-markdown/blob/main/ARCHITECTURE.md).
78
96
 
79
97
  ## Licence
@@ -1,5 +1,4 @@
1
1
  import { type AnchorHTMLAttributes, type ReactNode } from 'react';
2
- import type { PluggableList } from 'unified';
3
2
  import { type FootnoteRef } from './footnotes.js';
4
3
  /**
5
4
  * One run of a note, parsed and drawn: the pipeline of MemorySmith's reading page.
@@ -38,10 +37,5 @@ type WithNode<T> = T & {
38
37
  node?: unknown;
39
38
  };
40
39
  export declare function MarkdownAnchor({ href, children, node: _node, ...rest }: WithNode<AnchorHTMLAttributes<HTMLAnchorElement>>): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null | undefined;
41
- /**
42
- * The remark plugins of the reading surface, in their order. `planFootnotes` reads a note
43
- * through the same list, so it counts the references this surface draws and no other.
44
- */
45
- export declare function readingPlugins(titleOf: (kind: string) => string | undefined): PluggableList;
46
40
  export declare function Markdown({ children, source, onToggleTask, writable, footnotes }: MarkdownProps): import("react").JSX.Element;
47
41
  export {};
package/dist/Markdown.js CHANGED
@@ -4,8 +4,6 @@ import { isValidElement, } from 'react';
4
4
  import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
5
5
  import ReactMarkdown from 'react-markdown';
6
6
  import rehypeKatex from 'rehype-katex';
7
- import remarkGfm from 'remark-gfm';
8
- import remarkMath from 'remark-math';
9
7
  import { followable } from './address.js';
10
8
  import { CopyButton } from './CopyButton.js';
11
9
  import { highlightLanguage, highlightTree } from './highlight.js';
@@ -14,8 +12,7 @@ import { readDimensions, readImageAlt } from './image-dimensions.js';
14
12
  import { choiceTargetOf, LinkChoice } from './LinkChoice.js';
15
13
  import { toUnixNewlines } from './markdown.js';
16
14
  import { MermaidDiagram } from './MermaidDiagram.js';
17
- import { remarkCallouts } from './remark-callouts.js';
18
- import { remarkBlockIds, remarkComments, remarkHighlight, remarkMathDollarRule } from './remark-ring.js';
15
+ import { readingPlugins } from './reading.js';
19
16
  import { CLOBBER_PREFIX, rehypeFootnoteRun, rehypeFootnoteSection } from './footnotes.js';
20
17
  import { drawSlot, headings, InBlock, MarkdownBlockquote, MarkdownDiv, MarkdownSection, MarkdownTable, useInBlock, useSlot } from './slots.js';
21
18
  import { ordinalAt } from './tasklist.js';
@@ -145,7 +142,7 @@ function MarkdownCode({ className, children, node: _node, ...rest }) {
145
142
  const inBlock = useInBlock();
146
143
  const inlineSlot = useSlot('inlineCode');
147
144
  if (className?.includes('language-mermaid')) {
148
- return _jsx(MermaidDiagram, { code: String(children).trim(), scheme: scheme });
145
+ return _jsx(MermaidDiagram, { code: String(children ?? '').trim(), scheme: scheme });
149
146
  }
150
147
  if (!inBlock && inlineSlot)
151
148
  return drawSlot(inlineSlot, { children });
@@ -154,7 +151,8 @@ function MarkdownCode({ className, children, node: _node, ...rest }) {
154
151
  // The source is read and never rewritten: what is on the page is the
155
152
  // bytes, split into spans. React-markdown hands the children of a fence as
156
153
  // a single string ending in the newline before the closing fence.
157
- const code = String(children).replace(/\n$/, '');
154
+ // An empty fence has no children at all, and is no code rather than the word `undefined`.
155
+ const code = String(children ?? '').replace(/\n$/, '');
158
156
  return (_jsx("code", { className: className, ...rest, children: toJsxRuntime(highlightTree(code, language), { Fragment, jsx, jsxs }) }));
159
157
  }
160
158
  return (_jsx("code", { className: className, ...rest, children: children }));
@@ -251,33 +249,6 @@ function dropBox(children) {
251
249
  return children;
252
250
  return children.filter((child) => !(isValidElement(child) && child.type === 'input'));
253
251
  }
254
- /**
255
- * The remark plugins of the reading surface, in their order. `planFootnotes` reads a note
256
- * through the same list, so it counts the references this surface draws and no other.
257
- */
258
- export function readingPlugins(titleOf) {
259
- return [
260
- /**
261
- * `singleTilde: false` is a decision and not a default.
262
- *
263
- * GFM specifies strikethrough as `~~x~~`; GitHub also accepts a
264
- * single tilde, outside its own specification. Here that extension
265
- * actively misrenders a notation the specification leaves out:
266
- * somebody writing `H~2~O` for a subscript would get `H` struck-out
267
- * `2` `O`, which is a worse answer than nothing. With it off the
268
- * characters stay on the page, and the author can see they got what
269
- * the specification says they get, which is nothing.
270
- */
271
- [remarkGfm, { singleTilde: false }],
272
- [remarkCallouts, { titleOf }],
273
- remarkHighlight,
274
- remarkComments,
275
- remarkBlockIds,
276
- remarkMath,
277
- // After remark-math, and it gives back what was never a formula.
278
- remarkMathDollarRule,
279
- ];
280
- }
281
252
  export function Markdown({ children, source, onToggleTask, writable = false, footnotes }) {
282
253
  const text = toUnixNewlines(children);
283
254
  const { strings } = useHost();
package/dist/NoteBody.js CHANGED
@@ -2,8 +2,9 @@ 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
4
  import { planFootnotes } from './footnotes.js';
5
- import { Markdown, readingPlugins } from './Markdown.js';
6
- import { bodyStart, remoteImageHosts, resolveWikilinks } from './markdown.js';
5
+ import { Markdown } from './Markdown.js';
6
+ import { readingPlugins } from './reading.js';
7
+ import { bodyStart, hideComments, remoteImageHosts, resolveWikilinks } from './markdown.js';
7
8
  import { readProperties } from './properties.js';
8
9
  import { Properties } from './PropertiesBlock.js';
9
10
  import { taskBoxes } from './tasklist.js';
@@ -25,7 +26,8 @@ export function NoteBody({ markdown, writable = false, onToggleTask, ...host })
25
26
  // runs start after it. Everything below counts task items from there.
26
27
  const split = bodyStart(markdown);
27
28
  const properties = useMemo(() => readProperties(markdown), [markdown]);
28
- const body = markdown.slice(split);
29
+ // What a comment holds is never read (§7.6): it leaves before the embeds and the links are found.
30
+ const body = hideComments(markdown.slice(split)).text;
29
31
  // The body is cut into runs of text and the embeds between them; each run
30
32
  // is parsed on its own, as on MemorySmith's reading page.
31
33
  const segments = splitEmbeds(body, resolved.keeps).map((segment) => segment.kind === 'text'
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
3
3
  import { useHost } from './host.js';
4
4
  import { Markdown } from './Markdown.js';
5
5
  import { drawSlot, useSlot } from './slots.js';
6
- import { bodyStart, resolveWikilinks } from './markdown.js';
6
+ import { bodyStart, hideComments, resolveWikilinks } from './markdown.js';
7
7
  import { blockOf, demoteEmbeds, isBlockAnchor, sectionOf } from './transclusion.js';
8
8
  import { wikilinkAddress } from './wikilink-address.js';
9
9
  /** The shape of what is coming, in the position it will occupy, while the host answers. */
@@ -49,7 +49,7 @@ export function Transclusion({ target, anchor }) {
49
49
  return slot ? drawSlot(slot, { state: 'loading', target, anchor, caption: null, children: skeleton }) : skeleton;
50
50
  }
51
51
  const { note } = loaded;
52
- const whole = note.markdown.slice(bodyStart(note.markdown));
52
+ const whole = hideComments(note.markdown.slice(bodyStart(note.markdown))).text;
53
53
  // The caption names the note the content came from, and a note may have no
54
54
  // name.
55
55
  const sourceLabel = note.name ?? strings.unnamedNote;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The notices of a note (§7.13): where what is drawn will not be what was most likely typed.
3
+ *
4
+ * There is no invalid note (§6.4), so nothing here accepts or refuses: it reads a note the way the
5
+ * reading surface reads it — the same frontmatter cut, its comments taken out (`hideComments`), the
6
+ * same parser and the same remark plugins (`reading.ts`) — and reports, by a stable code, the places where the page will differ from the
7
+ * text, or where the note lacks something the specification reserves a place for. A notice never
8
+ * blocks a save and never changes a byte.
9
+ *
10
+ * It runs with no DOM and no React, so a host can call it on a server, from a tool that writes a
11
+ * note, or beside an editor. KaTeX checks a formula as `rehype-katex` would typeset it, and Mermaid
12
+ * reads a diagram as the page would draw it, loaded only when a note carries one.
13
+ */
14
+ import type { NoteHost } from './host.js';
15
+ /** The codes of §7.13, which `spec.json` declares as `notices`. */
16
+ export type NoticeCode = 'fence-unclosed' | 'math-unclosed' | 'formula-invalid' | 'diagram-invalid' | 'frontmatter-unclosed' | 'name-missing' | 'name-unusable' | 'reserved-not-a-list' | 'aliases-missing' | 'tags-missing' | 'heading-first-level' | 'heading-skipped-level' | 'footnote-undefined' | 'footnote-unreferenced' | 'raw-html' | 'strikethrough-single-tilde' | 'link-pending' | 'embed-pending';
17
+ /** `notice`: the page will differ from what was typed. `hint`: nothing is drawn wrong. */
18
+ export type NoticeSeverity = 'notice' | 'hint';
19
+ /** One place in a note, by a code of §7.13. Lines and columns count from 1 over the whole note. */
20
+ export interface Notice {
21
+ readonly code: NoticeCode;
22
+ readonly severity: NoticeSeverity;
23
+ /** The section of the specification that decides what happens there. */
24
+ readonly section: string;
25
+ /** What a reader gets, in English; a host words it in its own language from `code`. */
26
+ readonly message: string;
27
+ readonly line: number;
28
+ readonly column: number;
29
+ readonly endLine: number;
30
+ readonly endColumn: number;
31
+ }
32
+ /**
33
+ * What the notebook answers, as `NoteBody` asks it. With neither `resolve` nor `reaches`, nothing
34
+ * is known about the notebook and no link is reported as pending.
35
+ */
36
+ export interface DiagnoseOptions extends Pick<NoteHost, 'resolve' | 'reaches' | 'keeps'> {
37
+ /** Whether to read each `mermaid` block with Mermaid, which is loaded for it. Defaults to true. */
38
+ readonly diagrams?: boolean;
39
+ }
40
+ /** The table of §7.13, as `spec.json` carries it: a test holds the two equal. */
41
+ export declare const NOTICES: Readonly<Record<NoticeCode, {
42
+ readonly severity: NoticeSeverity;
43
+ readonly section: string;
44
+ readonly message: string;
45
+ }>>;
46
+ /**
47
+ * The notices of a note, in the order of the note. `markdown` is the note as a host keeps it,
48
+ * frontmatter included; the positions are into it.
49
+ */
50
+ export declare function diagnose(markdown: string, options?: DiagnoseOptions): Promise<Notice[]>;
@@ -0,0 +1,304 @@
1
+ /**
2
+ * The notices of a note (§7.13): where what is drawn will not be what was most likely typed.
3
+ *
4
+ * There is no invalid note (§6.4), so nothing here accepts or refuses: it reads a note the way the
5
+ * reading surface reads it — the same frontmatter cut, its comments taken out (`hideComments`), the
6
+ * same parser and the same remark plugins (`reading.ts`) — and reports, by a stable code, the places where the page will differ from the
7
+ * text, or where the note lacks something the specification reserves a place for. A notice never
8
+ * blocks a save and never changes a byte.
9
+ *
10
+ * It runs with no DOM and no React, so a host can call it on a server, from a tool that writes a
11
+ * note, or beside an editor. KaTeX checks a formula as `rehype-katex` would typeset it, and Mermaid
12
+ * reads a diagram as the page would draw it, loaded only when a note carries one.
13
+ */
14
+ import katex from 'katex';
15
+ import remarkParse from 'remark-parse';
16
+ import { unified } from 'unified';
17
+ import { bodyStart, codeRegions, hideComments, insideCode, toUnixNewlines } from './markdown.js';
18
+ import { readProperties } from './properties.js';
19
+ import { readingPlugins } from './reading.js';
20
+ /** The table of §7.13, as `spec.json` carries it: a test holds the two equal. */
21
+ export const NOTICES = {
22
+ 'fence-unclosed': { severity: 'notice', section: '3.7', message: 'This fence is never closed: it runs to the end of its block, and everything after it is code.' },
23
+ 'math-unclosed': { severity: 'notice', section: '7.8', message: 'This $$ block is never closed: it runs to the end of its block, and everything after it is a formula.' },
24
+ 'formula-invalid': { severity: 'notice', section: '7.8', message: 'This formula cannot be typeset, and is shown as its source.' },
25
+ 'diagram-invalid': { severity: 'notice', section: '7.2', message: 'This diagram cannot be read, and is shown as its source or its error.' },
26
+ 'frontmatter-unclosed': { severity: 'notice', section: '6.1', message: 'This frontmatter is never closed by a ---: the note has no properties, and its lines are body text.' },
27
+ 'name-missing': { severity: 'notice', section: '6.5', message: 'The note has no name: a link reaches it only through an alias.' },
28
+ 'name-unusable': { severity: 'notice', section: '6.5', message: 'This name is not a single text value, or carries one of #, [, ] and |, so the note is unnamed.' },
29
+ 'reserved-not-a-list': { severity: 'notice', section: '6.4', message: 'This value is not a list, so it is an ordinary attribute.' },
30
+ 'aliases-missing': { severity: 'hint', section: '6.4', message: 'The note has no aliases: only its name catches a link.' },
31
+ 'tags-missing': { severity: 'hint', section: '6.4', message: 'The note has no tags: it has no subjects to be filtered by.' },
32
+ 'heading-first-level': { severity: 'notice', section: '3.3', message: 'The first heading of the body is not level one.' },
33
+ 'heading-skipped-level': { severity: 'notice', section: '3.3', message: 'This heading skips a level below the heading before it.' },
34
+ 'footnote-undefined': { severity: 'notice', section: '7.12', message: 'This footnote has no definition, and is drawn as its characters.' },
35
+ 'footnote-unreferenced': { severity: 'notice', section: '7.12', message: 'Nothing references this footnote, so it is not drawn.' },
36
+ 'raw-html': { severity: 'notice', section: '7.9', message: 'Raw HTML is drawn as text, tags and all.' },
37
+ 'strikethrough-single-tilde': { severity: 'notice', section: '4.3', message: 'One tilde strikes nothing: the tildes stay on the page. Strikethrough is ~~two~~.' },
38
+ 'link-pending': { severity: 'hint', section: '7.4', message: 'No note answers this link yet, so it is drawn as pending.' },
39
+ 'embed-pending': { severity: 'hint', section: '7.3', message: 'No note and no kept file answers this embed, so it is drawn as a pending link.' },
40
+ };
41
+ /** A footnote reference left as text, which is one with no definition (§7.12). */
42
+ const UNDEFINED_REFERENCE = /\[\^[^\]\s]+\]/g;
43
+ /** A run between single tildes, which GFM as read here leaves as characters (§4.3). */
44
+ const SINGLE_TILDE = /(?<![~\\])~(?!~)(?=\S)[^~\n]*?\S~(?!~)/g;
45
+ /** A wikilink, with the bang of an embed: the shape `resolveWikilinks` reads. */
46
+ const WIKILINK = /(!)?\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
47
+ /** A closing fence or `$$`, once the markers of its container are taken off. */
48
+ const CONTAINER_PREFIX = /^[ \t>]*/;
49
+ const KEY = /^([A-Za-z0-9_-]+)\s*:/;
50
+ /**
51
+ * The notices of a note, in the order of the note. `markdown` is the note as a host keeps it,
52
+ * frontmatter included; the positions are into it.
53
+ */
54
+ export async function diagnose(markdown, options = {}) {
55
+ const text = toUnixNewlines(markdown);
56
+ const lineStarts = [0];
57
+ for (let index = 0; index < text.length; index++)
58
+ if (text[index] === '\n')
59
+ lineStarts.push(index + 1);
60
+ const pointOf = (offset) => {
61
+ let low = 0;
62
+ let high = lineStarts.length - 1;
63
+ while (low < high) {
64
+ const middle = (low + high + 1) >> 1;
65
+ if ((lineStarts[middle] ?? 0) <= offset)
66
+ low = middle;
67
+ else
68
+ high = middle - 1;
69
+ }
70
+ return { line: low + 1, column: offset - (lineStarts[low] ?? 0) + 1 };
71
+ };
72
+ const found = [];
73
+ const report = (code, start, end = start, detail) => {
74
+ const from = pointOf(start);
75
+ const to = pointOf(end);
76
+ const { severity, section, message } = NOTICES[code];
77
+ found.push({
78
+ at: start,
79
+ notice: {
80
+ code,
81
+ severity,
82
+ section,
83
+ message: detail ? `${message} (${detail})` : message,
84
+ line: from.line,
85
+ column: from.column,
86
+ endLine: to.line,
87
+ endColumn: to.column,
88
+ },
89
+ });
90
+ };
91
+ const split = bodyStart(text);
92
+ frontmatter(text, split, report);
93
+ // A comment is never drawn, so nothing it holds raises a notice; positions are mapped back over it.
94
+ const hidden = hideComments(text.slice(split));
95
+ const body = hidden.text;
96
+ const processor = unified().use(remarkParse).use(readingPlugins(() => undefined));
97
+ const parsed = processor.parse(body);
98
+ // The text as the parser found it, before a plugin cut a run of it and dropped its position.
99
+ const read = structuredClone(parsed);
100
+ const tree = processor.runSync(parsed, body);
101
+ const span = (node) => {
102
+ const start = node.position?.start.offset;
103
+ const end = node.position?.end.offset;
104
+ return start === undefined || end === undefined ? null : [split + hidden.original(start), split + hidden.original(end, true)];
105
+ };
106
+ const diagrams = [];
107
+ const referenced = new Set();
108
+ const definitions = [];
109
+ walk(tree, (node) => {
110
+ const at = span(node);
111
+ if (!at)
112
+ return;
113
+ const source = text.slice(at[0], at[1]);
114
+ if (node.type === 'code' && /^\s*(`{3,}|~{3,})/.test(source)) {
115
+ if (!closes(source, /^(`{3,}|~{3,})/))
116
+ report('fence-unclosed', at[0], at[1]);
117
+ else if (node.lang === 'mermaid')
118
+ diagrams.push({ source: node.value ?? '', at });
119
+ }
120
+ else if (node.type === 'math') {
121
+ if (!closes(source, /^(\${2,})/))
122
+ report('math-unclosed', at[0], at[1]);
123
+ else if (!typesets(node.value ?? '', true))
124
+ report('formula-invalid', at[0], at[1]);
125
+ }
126
+ else if (node.type === 'inlineMath') {
127
+ if (!typesets(node.value ?? '', false))
128
+ report('formula-invalid', at[0], at[1]);
129
+ }
130
+ else if (node.type === 'html') {
131
+ report('raw-html', at[0], at[1]);
132
+ }
133
+ else if (node.type === 'footnoteReference') {
134
+ referenced.add(String(node.identifier ?? '').toUpperCase());
135
+ }
136
+ else if (node.type === 'footnoteDefinition') {
137
+ definitions.push(node);
138
+ }
139
+ });
140
+ for (const definition of definitions) {
141
+ const at = span(definition);
142
+ if (at && !referenced.has(String(definition.identifier ?? '').toUpperCase())) {
143
+ report('footnote-unreferenced', at[0], at[1]);
144
+ }
145
+ }
146
+ outline(tree, span, report);
147
+ // What stays characters on the page is found in the text the parser read.
148
+ walk(read, (node) => {
149
+ if (node.type !== 'text')
150
+ return;
151
+ const start = node.position?.start.offset;
152
+ const end = node.position?.end.offset;
153
+ if (start === undefined || end === undefined)
154
+ return;
155
+ const source = body.slice(start, end);
156
+ for (const [pattern, code] of [
157
+ [UNDEFINED_REFERENCE, 'footnote-undefined'],
158
+ [SINGLE_TILDE, 'strikethrough-single-tilde'],
159
+ ]) {
160
+ for (const match of source.matchAll(pattern)) {
161
+ const from = start + match.index;
162
+ report(code, split + hidden.original(from), split + hidden.original(from + match[0].length, true));
163
+ }
164
+ }
165
+ });
166
+ pending(hidden, split, options, report);
167
+ if (diagrams.length > 0 && options.diagrams !== false) {
168
+ const mermaid = (await import('mermaid')).default;
169
+ for (const diagram of diagrams) {
170
+ try {
171
+ await mermaid.parse(diagram.source);
172
+ }
173
+ catch (error) {
174
+ if (unreadable(error))
175
+ report('diagram-invalid', diagram.at[0], diagram.at[1]);
176
+ }
177
+ }
178
+ }
179
+ return found.sort((a, b) => a.at - b.at).map(({ notice }) => notice);
180
+ }
181
+ function walk(node, visit) {
182
+ visit(node);
183
+ for (const child of node.children ?? [])
184
+ walk(child, visit);
185
+ }
186
+ /**
187
+ * Whether a fenced block or a `$$` block was closed: its last line, once its container's markers
188
+ * are off, is a run of the opening character at least as long as the opening one, and nothing else.
189
+ */
190
+ function closes(source, opening) {
191
+ const lines = source.split('\n');
192
+ const run = opening.exec((lines[0] ?? '').replace(CONTAINER_PREFIX, ''))?.[1] ?? '';
193
+ if (lines.length < 2 || run.length === 0)
194
+ return false;
195
+ const last = (lines[lines.length - 1] ?? '').replace(CONTAINER_PREFIX, '').trimEnd();
196
+ const closing = new RegExp(`^\\${run[0]}{${run.length},}$`);
197
+ return closing.test(last);
198
+ }
199
+ /**
200
+ * Whether Mermaid refused a diagram for what it says, rather than for where it runs. Its parsers
201
+ * answer with a parse error, and an unknown diagram type with its own error; with no DOM, reading a
202
+ * label of some diagrams fails on the sanitiser instead, and that failure is no notice: a diagram
203
+ * Mermaid could not finish reading here may well be drawn on the page.
204
+ */
205
+ function unreadable(error) {
206
+ if (typeof error !== 'object' || error === null)
207
+ return false;
208
+ const failure = error;
209
+ return (typeof failure.hash === 'object' ||
210
+ failure.name === 'UnknownDiagramError' ||
211
+ failure.constructor?.name === 'MermaidParseError');
212
+ }
213
+ /** Whether KaTeX typesets a formula, as `rehype-katex` would. */
214
+ function typesets(formula, display) {
215
+ try {
216
+ katex.renderToString(formula, { displayMode: display, throwOnError: true, strict: 'ignore' });
217
+ return true;
218
+ }
219
+ catch {
220
+ return false;
221
+ }
222
+ }
223
+ /** The reserved keys of the frontmatter (§6.4, §6.5), and a block that never closes (§6.1). */
224
+ function frontmatter(text, split, report) {
225
+ const opened = text.startsWith('---');
226
+ if (opened && text.indexOf('\n---', 3) === -1)
227
+ report('frontmatter-unclosed', 0, text.indexOf('\n') === -1 ? 3 : text.indexOf('\n'));
228
+ // Where each key was written, to place a notice on it.
229
+ const keys = new Map();
230
+ if (opened && split > 0) {
231
+ let at = text.indexOf('\n') + 1;
232
+ for (const line of text.slice(at, split).split('\n')) {
233
+ const key = KEY.exec(line)?.[1];
234
+ if (key && !keys.has(key))
235
+ keys.set(key, at);
236
+ at += line.length + 1;
237
+ }
238
+ }
239
+ const lineEnd = (at) => {
240
+ const end = text.indexOf('\n', at);
241
+ return end === -1 ? text.length : end;
242
+ };
243
+ const properties = readProperties(text);
244
+ const name = keys.get('name');
245
+ if (name === undefined)
246
+ report('name-missing', 0);
247
+ else if (properties.name === null)
248
+ report('name-unusable', name, lineEnd(name));
249
+ for (const [key, code] of [
250
+ ['aliases', 'aliases-missing'],
251
+ ['tags', 'tags-missing'],
252
+ ]) {
253
+ const at = keys.get(key);
254
+ const entry = properties.entries.find((candidate) => candidate.key === key);
255
+ if (at === undefined || !entry)
256
+ report(code, 0);
257
+ else if (entry.shape === 'empty')
258
+ report(code, at, lineEnd(at));
259
+ else if (entry.shape !== 'list')
260
+ report('reserved-not-a-list', at, lineEnd(at), key);
261
+ }
262
+ }
263
+ /** The outline of the body (§3.3): the headings at its top level, outside code. */
264
+ function outline(tree, span, report) {
265
+ let previous = null;
266
+ for (const node of tree.children ?? []) {
267
+ if (node.type !== 'heading' || node.depth === undefined)
268
+ continue;
269
+ const at = span(node);
270
+ if (at) {
271
+ if (previous === null && node.depth > 1)
272
+ report('heading-first-level', at[0], at[1]);
273
+ else if (previous !== null && node.depth > previous + 1)
274
+ report('heading-skipped-level', at[0], at[1]);
275
+ }
276
+ previous = node.depth;
277
+ }
278
+ }
279
+ /**
280
+ * The wikilinks and embeds no note answers, when the host says which notes exist: a link reaches
281
+ * a note, and an embed a note or a kept file (§5.2, §5.8). Read on the body outside code, as
282
+ * `resolveWikilinks` reads it.
283
+ */
284
+ function pending(hidden, split, options, report) {
285
+ const body = hidden.text;
286
+ const { resolve, reaches, keeps = () => false } = options;
287
+ if (!resolve && !reaches)
288
+ return;
289
+ const answered = (name) => reaches ? reaches(name.normalize('NFC')) > 0 : resolve(name.normalize('NFC')) !== null;
290
+ const code = codeRegions(body);
291
+ for (const match of body.matchAll(WIKILINK)) {
292
+ if (insideCode(code, match.index))
293
+ continue;
294
+ const name = (match[2] ?? '').split('#')[0]?.trim().replace(/\\$/, '').trim() ?? '';
295
+ // `[[#Section]]` is a link within the note itself.
296
+ if (!name)
297
+ continue;
298
+ const embed = match[1] !== undefined;
299
+ if (answered(name) || (embed && keeps(name)))
300
+ continue;
301
+ const end = match.index + match[0].length;
302
+ report(embed ? 'embed-pending' : 'link-pending', split + hidden.original(match.index), split + hidden.original(end, true), name);
303
+ }
304
+ }
@@ -41,6 +41,26 @@ export declare function insideCode(regions: Array<[number, number]>, at: number)
41
41
  export declare function tableRegions(body: string): Array<[number, number]>;
42
42
  /** Whether an offset falls inside one of them. */
43
43
  export declare function insideTable(regions: Array<[number, number]>, at: number): boolean;
44
+ /**
45
+ * The comments of a text (§7.6), as half-open ranges: from a `%%` outside code to the next `%%`
46
+ * outside code, across inline markup and blank lines alike. A `%%` with no partner opens nothing.
47
+ *
48
+ * A comment that is all of its lines takes the line break after it too, so a comment written on
49
+ * lines of its own leaves no empty line behind to loosen a list or split a paragraph.
50
+ *
51
+ * MemorySmith's reading page hides a comment inside one run of text only, so one that holds a
52
+ * link, a tag, emphasis or a blank line reaches the page whole; this is found on the text, before
53
+ * anything is parsed, so nothing a comment holds is ever read.
54
+ */
55
+ export declare function commentRegions(text: string): Array<[number, number]>;
56
+ /** A text with its comments taken out, and where an offset into it falls in the text it came from. */
57
+ export interface HiddenComments {
58
+ readonly text: string;
59
+ /** The offset in the original text of an offset into `text`; an `end` stays before a comment it meets. */
60
+ readonly original: (offset: number, end?: boolean) => number;
61
+ }
62
+ /** The text a reader is shown: its comments taken out (§7.6). The note itself is never changed. */
63
+ export declare function hideComments(text: string): HiddenComments;
44
64
  /** Runs a rewrite over everything except code, which is copied through. */
45
65
  export declare function outsideCode(body: string, rewrite: (text: string) => string): string;
46
66
  export declare function resolveWikilinks(body: string, resolve: (name: string) => string | null,
package/dist/markdown.js CHANGED
@@ -254,6 +254,54 @@ export function tableRegions(body) {
254
254
  export function insideTable(regions, at) {
255
255
  return regions.some(([start, end]) => at >= start && at < end);
256
256
  }
257
+ /**
258
+ * The comments of a text (§7.6), as half-open ranges: from a `%%` outside code to the next `%%`
259
+ * outside code, across inline markup and blank lines alike. A `%%` with no partner opens nothing.
260
+ *
261
+ * A comment that is all of its lines takes the line break after it too, so a comment written on
262
+ * lines of its own leaves no empty line behind to loosen a list or split a paragraph.
263
+ *
264
+ * MemorySmith's reading page hides a comment inside one run of text only, so one that holds a
265
+ * link, a tag, emphasis or a blank line reaches the page whole; this is found on the text, before
266
+ * anything is parsed, so nothing a comment holds is ever read.
267
+ */
268
+ export function commentRegions(text) {
269
+ const code = codeRegions(text);
270
+ const markers = [...text.matchAll(/%%/g)].map((match) => match.index).filter((at) => !insideCode(code, at));
271
+ const regions = [];
272
+ for (let index = 0; index + 1 < markers.length; index += 2) {
273
+ let start = markers[index];
274
+ let end = markers[index + 1] + 2;
275
+ const lineStart = text.lastIndexOf('\n', start - 1) + 1;
276
+ const lineEnd = text.indexOf('\n', end) === -1 ? text.length : text.indexOf('\n', end);
277
+ if (/^[ \t]*$/.test(text.slice(lineStart, start)) && /^[ \t]*$/.test(text.slice(end, lineEnd))) {
278
+ start = lineStart;
279
+ end = lineEnd === text.length ? lineEnd : lineEnd + 1;
280
+ }
281
+ regions.push([start, end]);
282
+ }
283
+ return regions;
284
+ }
285
+ /** The text a reader is shown: its comments taken out (§7.6). The note itself is never changed. */
286
+ export function hideComments(text) {
287
+ const regions = commentRegions(text);
288
+ if (regions.length === 0)
289
+ return { text, original: (offset) => offset };
290
+ let shown = '';
291
+ let cursor = 0;
292
+ // Where each comment was, in the shown text, and how much of the original it took.
293
+ const cuts = [];
294
+ for (const [start, end] of regions) {
295
+ shown += text.slice(cursor, start);
296
+ cuts.push({ at: shown.length, length: end - start });
297
+ cursor = end;
298
+ }
299
+ shown += text.slice(cursor);
300
+ return {
301
+ text: shown,
302
+ original: (offset, end = false) => offset + cuts.filter((cut) => (end ? cut.at < offset : cut.at <= offset)).reduce((total, cut) => total + cut.length, 0),
303
+ };
304
+ }
257
305
  /** Runs a rewrite over everything except code, which is copied through. */
258
306
  export function outsideCode(body, rewrite) {
259
307
  let out = '';
@@ -29,7 +29,10 @@ export interface PropertyEntry {
29
29
  }
30
30
  /** The frontmatter, read. */
31
31
  export interface NoteProperties {
32
- /** The name of the note: `name:` when it is a single text value (§6.5), else `null`. */
32
+ /**
33
+ * The name of the note: `name:` when it is a single text value (§6.5) that carries none of
34
+ * `#`, `[`, `]` and `|` (§5.3), else `null`.
35
+ */
33
36
  readonly name: string | null;
34
37
  /** Every other entry, in the order written. */
35
38
  readonly entries: readonly PropertyEntry[];
@@ -14,6 +14,8 @@
14
14
  * resolved by the host as a link of the body is. That is a decision of the
15
15
  * reading surface; it adds no edge, and what an Indexer reads is unchanged.
16
16
  */
17
+ /** The four delimiters of the form that addresses a note: a name carrying one is no name (§5.3). */
18
+ const UNADDRESSABLE = /[#[\]|]/;
17
19
  /** Above this a single value is prose rather than a category (§6.3). */
18
20
  const CATEGORY_CEILING = 40;
19
21
  const KEY = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/;
@@ -117,9 +119,11 @@ export function readProperties(markdown) {
117
119
  const read = [];
118
120
  for (const { key, raw, items } of entries) {
119
121
  if (key === 'name') {
120
- // Only a single text value names a note; any other shape leaves it unnamed (§6.5).
122
+ // Only a single text value names a note; any other shape leaves it unnamed (§6.5), and so
123
+ // does one carrying a delimiter of the form that addresses it (§5.3). The subset has no
124
+ // comments (§6.2): `name: Incident #4711` is that whole value, and it carries a `#`.
121
125
  const value = items === null ? unquote(raw) : '';
122
- name = value.length > 0 ? value : null;
126
+ name = value.length > 0 && !UNADDRESSABLE.test(value) ? value : null;
123
127
  continue;
124
128
  }
125
129
  // A list written with no item, `[]`, is an empty value, never a list (§6.3).
@@ -0,0 +1,6 @@
1
+ import type { PluggableList } from 'unified';
2
+ /**
3
+ * The remark plugins of the reading surface, in their order. `planFootnotes` reads a note
4
+ * through the same list, so it counts the references this surface draws and no other.
5
+ */
6
+ export declare function readingPlugins(titleOf: (kind: string) => string | undefined): PluggableList;
@@ -0,0 +1,32 @@
1
+ // The remark plugins of the reading surface, apart from React: the drawing, the footnote plan
2
+ // and the diagnostic read a note through the same list, so a notice never disagrees with the page.
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkMath from 'remark-math';
5
+ import { remarkCallouts } from './remark-callouts.js';
6
+ import { remarkBlockIds, remarkHighlight, remarkMathDollarRule } from './remark-ring.js';
7
+ /**
8
+ * The remark plugins of the reading surface, in their order. `planFootnotes` reads a note
9
+ * through the same list, so it counts the references this surface draws and no other.
10
+ */
11
+ export function readingPlugins(titleOf) {
12
+ return [
13
+ /**
14
+ * `singleTilde: false` is a decision and not a default.
15
+ *
16
+ * GFM specifies strikethrough as `~~x~~`; GitHub also accepts a
17
+ * single tilde, outside its own specification. Here that extension
18
+ * actively misrenders a notation the specification leaves out:
19
+ * somebody writing `H~2~O` for a subscript would get `H` struck-out
20
+ * `2` `O`, which is a worse answer than nothing. With it off the
21
+ * characters stay on the page, and the author can see they got what
22
+ * the specification says they get, which is nothing.
23
+ */
24
+ [remarkGfm, { singleTilde: false }],
25
+ [remarkCallouts, { titleOf }],
26
+ remarkHighlight,
27
+ remarkBlockIds,
28
+ remarkMath,
29
+ // After remark-math, and it gives back what was never a formula.
30
+ remarkMathDollarRule,
31
+ ];
32
+ }
@@ -18,8 +18,12 @@
18
18
  * reads: a `[!note]` inside a code fence is not a callout, and only the parser
19
19
  * can tell the difference.
20
20
  */
21
- /** `[!type]`, an optional fold marker, and whatever titles it. */
22
- const MARKER = /^\[!([A-Za-z][\w-]*)\]([+-]?)[ \t]*(.*)$/;
21
+ /**
22
+ * `[!type]`, an optional fold marker, and whatever titles it. The type is a word
23
+ * in any script (§7.1): it opens with a letter and goes on in letters, marks,
24
+ * digits, `_` and `-`, so `[!atenção]` is a type as much as `[!warning]` is.
25
+ */
26
+ const MARKER = /^\[!(\p{L}[\p{L}\p{M}\p{N}_-]*)\]([+-]?)[ \t]*(.*)$/u;
23
27
  export function remarkCallouts(options = {}) {
24
28
  return (tree) => descend(tree, options);
25
29
  }
@@ -41,7 +45,7 @@ function rewrite(quote, options) {
41
45
  const marker = MARKER.exec(broke === -1 ? lead.value : lead.value.slice(0, broke));
42
46
  if (!marker)
43
47
  return;
44
- const kind = (marker[1] ?? '').toLowerCase();
48
+ const kind = (marker[1] ?? '').normalize('NFC').toLowerCase();
45
49
  const { title, body } = split(marker[3] ?? '', broke === -1 ? null : lead.value.slice(broke + 1), inline);
46
50
  quote.data = {
47
51
  hName: 'div',
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * The rest of the MemorySmith notation, on the reading surface: marked text,
3
- * comments, block identifiers (§7.5, §7.6, §7.7) and the dollar rule of
4
- * mathematics (§7.8).
3
+ * block identifiers (§7.5, §7.7) and the dollar rule of mathematics (§7.8).
5
4
  *
6
- * All three are remark plugins rather than passes over the string, for the
5
+ * They are remark plugins rather than passes over the string, for the
7
6
  * reason `remark-callouts.ts` already gives: a `==` inside a code fence is not
8
7
  * a highlight, and only the parser can tell the difference. Rewriting the text
9
8
  * before it is parsed is how a notation starts firing inside examples.
10
9
  *
10
+ * Comments (§7.6) are the one exception, and they are taken out before the
11
+ * parser by `hideComments` (`markdown.ts`), which finds code first: a comment
12
+ * may hold markup and blank lines, and once parsed it is spread over nodes no
13
+ * plugin on a run of text can see whole.
14
+ *
11
15
  * None of them touches the bytes. The reading surface is a rendering of the
12
16
  * note and never a rewriting of it, so what a host stores and returns is
13
- * unaffected by everything here — which is the whole of what makes
14
- * `%%comment%%` an asymmetry that has to be declared rather than a deletion.
17
+ * unaffected by everything here.
15
18
  */
16
19
  interface MdastNode {
17
20
  type: string;
@@ -23,7 +26,6 @@ interface MdastNode {
23
26
  };
24
27
  }
25
28
  export declare function remarkHighlight(): (tree: MdastNode) => void;
26
- export declare function remarkComments(): (tree: MdastNode) => void;
27
29
  export declare function remarkBlockIds(): (tree: MdastNode) => void;
28
30
  /**
29
31
  * The `$` that is not math (§7.8).
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * The rest of the MemorySmith notation, on the reading surface: marked text,
3
- * comments, block identifiers (§7.5, §7.6, §7.7) and the dollar rule of
4
- * mathematics (§7.8).
3
+ * block identifiers (§7.5, §7.7) and the dollar rule of mathematics (§7.8).
5
4
  *
6
- * All three are remark plugins rather than passes over the string, for the
5
+ * They are remark plugins rather than passes over the string, for the
7
6
  * reason `remark-callouts.ts` already gives: a `==` inside a code fence is not
8
7
  * a highlight, and only the parser can tell the difference. Rewriting the text
9
8
  * before it is parsed is how a notation starts firing inside examples.
10
9
  *
10
+ * Comments (§7.6) are the one exception, and they are taken out before the
11
+ * parser by `hideComments` (`markdown.ts`), which finds code first: a comment
12
+ * may hold markup and blank lines, and once parsed it is spread over nodes no
13
+ * plugin on a run of text can see whole.
14
+ *
11
15
  * None of them touches the bytes. The reading surface is a rendering of the
12
16
  * note and never a rewriting of it, so what a host stores and returns is
13
- * unaffected by everything here — which is the whole of what makes
14
- * `%%comment%%` an asymmetry that has to be declared rather than a deletion.
17
+ * unaffected by everything here.
15
18
  */
16
19
  /** Walks every node, letting the visitor rewrite a parent's children. */
17
20
  function walk(node, visit) {
@@ -45,37 +48,21 @@ export function remarkHighlight() {
45
48
  });
46
49
  });
47
50
  }
48
- /**
49
- * `%%comment%%` leaves the page and stays in the file (§7.6).
50
- *
51
- * The asymmetry is the decision and it is declared, not discovered: the bytes
52
- * are untouched, so whatever returns the note returns the comment, and an
53
- * agent therefore sees what a person on the page does not. Text the
54
- * author did not want *read on the page* is still text the author wrote, and
55
- * deleting bytes to make a page tidier is not something this product does.
56
- */
57
- const COMMENT = /%%([\s\S]*?)%%/g;
58
- export function remarkComments() {
59
- return (tree) => walk(tree, (parent) => {
60
- parent.children = (parent.children ?? []).flatMap((child) => {
61
- if (!isText(child) || !child.value?.includes('%%'))
62
- return [child];
63
- const stripped = child.value.replace(COMMENT, '');
64
- // A text that was nothing but a comment leaves no empty text node
65
- // behind. The paragraph that held it stays, empty, as it does on
66
- // MemorySmith's reading page.
67
- return stripped.length === 0 ? [] : [{ ...child, value: stripped }];
68
- });
69
- });
70
- }
71
51
  /**
72
52
  * `^identifier` at the end of a block names that block (§7.7), and is never
73
53
  * rendered as text. What it names is resolved by the embed, not here: this
74
54
  * plugin only keeps the marker off the page.
75
55
  */
76
56
  const BLOCK_ID = /[ \t]*\^([A-Za-z0-9-]+)[ \t]*$/;
57
+ /**
58
+ * The nodes a block ends in. The last text of a link, an emphasis or any other
59
+ * inline is not the end of a block: `[[Note#^id]]` keeps its `^id` on the label.
60
+ */
61
+ const BLOCKS = new Set(['paragraph', 'heading', 'tableCell']);
77
62
  export function remarkBlockIds() {
78
63
  return (tree) => walk(tree, (parent) => {
64
+ if (!BLOCKS.has(parent.type))
65
+ return;
79
66
  const children = parent.children ?? [];
80
67
  const last = children[children.length - 1];
81
68
  if (!last || !isText(last) || !last.value?.includes('^'))
package/dist/slots.js CHANGED
@@ -21,9 +21,17 @@ export function drawSlot(slot, props) {
21
21
  /** Whether a code element sits in a fenced block, which is what tells a block from a span. */
22
22
  export const InBlock = createContext(false);
23
23
  // ── Callouts ─────────────────────────────────────────────────────────────────
24
+ /**
25
+ * The words of a title, in one element: the title is laid out as a row beside
26
+ * its icon, and every text node and every `<strong>` of it would otherwise be
27
+ * an item of that row of its own, drawn as columns. One run wraps as a sentence.
28
+ */
29
+ function CalloutTitleText({ children }) {
30
+ return _jsx("span", { className: "callout-title-text", children: children });
31
+ }
24
32
  /** The title `remarkCallouts` built, carried to the callout that holds it. */
25
33
  function CalloutTitle({ children }) {
26
- return _jsx("div", { className: "callout-title", children: children });
34
+ return (_jsx("div", { className: "callout-title", children: _jsx(CalloutTitleText, { children: children }) }));
27
35
  }
28
36
  function Callout({ kind, fold, children }) {
29
37
  const { theme } = useHost();
@@ -42,7 +50,7 @@ function Callout({ kind, fold, children }) {
42
50
  const title = at === -1 ? null : parts[at].props.children;
43
51
  const titled = icon === null ? (_jsx(CalloutTitle, { children: title })) : (
44
52
  // An icon of the theme's takes the place of the one the stylesheet draws.
45
- _jsxs("div", { className: "callout-title has-icon", children: [_jsx("span", { className: "callout-icon", "aria-hidden": "true", children: icon }), title] }));
53
+ _jsxs("div", { className: "callout-title has-icon", children: [_jsx("span", { className: "callout-icon", "aria-hidden": "true", children: icon }), _jsx(CalloutTitleText, { children: title })] }));
46
54
  if (slot) {
47
55
  const body = parts.filter((_, index) => index !== at);
48
56
  return drawSlot(slot, { kind, fold, title, icon, children: body });
package/dist/tasklist.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * by design, because it flattens lists for display, and rebuilding the
12
12
  * document from it would hand the notebook a file nobody typed.
13
13
  */
14
- import { fenceRegions } from './markdown.js';
14
+ import { commentRegions, fenceRegions } from './markdown.js';
15
15
  /**
16
16
  * A task item, as GFM defines it, at the start of a list item — after any
17
17
  * block quote markers in front of it, because a callout is a block quote and a
@@ -25,7 +25,7 @@ import { fenceRegions } from './markdown.js';
25
25
  */
26
26
  const TASK = /^(?:[ \t]*>[ \t]?)*[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[([ xX])\]/gm;
27
27
  /**
28
- * Blanks out fenced blocks while keeping every offset intact. A task item
28
+ * Blanks out fenced blocks and comments while keeping every offset intact. A task item
29
29
  * inside a fence is an example, not a box, and counting it would shift the
30
30
  * index and toggle a different item: the worst possible defect here, because
31
31
  * it is silent and lands in the wrong place.
@@ -37,7 +37,11 @@ const TASK = /^(?:[ \t]*>[ \t]?)*[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[([ xX])\]/gm;
37
37
  function maskFences(text) {
38
38
  let masked = '';
39
39
  let cursor = 0;
40
- for (const [start, end] of fenceRegions(text)) {
40
+ // A comment is never drawn (§7.6), so a box inside one is no box, on either side of the count.
41
+ const hidden = [...fenceRegions(text), ...commentRegions(text)].sort((a, b) => a[0] - b[0]);
42
+ for (const [start, end] of hidden) {
43
+ if (start < cursor)
44
+ continue;
41
45
  masked += text.slice(cursor, start) + text.slice(start, end).replace(/[^\n]/g, ' ');
42
46
  cursor = end;
43
47
  }
@@ -72,5 +72,8 @@ export declare function isBlockAnchor(anchor: string): boolean;
72
72
  * The section of a document, cut syntactically: from the heading whose slug
73
73
  * matches the anchor to the next heading of equal or higher level. No notebook
74
74
  * convention takes part in this: the cut is syntactic.
75
+ *
76
+ * A line inside a fence is code and never a heading, so the `# Step 1` comment
77
+ * of a shell block neither opens a section nor ends one.
75
78
  */
76
79
  export declare function sectionOf(markdown: string, anchor: string): string | null;
@@ -16,7 +16,7 @@
16
16
  * and never blocks (§4.1). Wherever expansion cannot happen the embed becomes
17
17
  * a link and is never dropped, which is what `demoteEmbeds` does.
18
18
  */
19
- import { codeRegions, headingKey, insideCode, insideTable, outsideCode, tableRegions } from './markdown.js';
19
+ import { codeRegions, fenceRegions, headingKey, insideCode, insideTable, outsideCode, tableRegions } from './markdown.js';
20
20
  /** Same shape the link extractor matches, plus the leading `!`. */
21
21
  const EMBED = /!\[\[([^\]|#]+?)(?:#([^\]|]+?))?(?:\|[^\]]*?)?\]\]/g;
22
22
  /**
@@ -136,13 +136,22 @@ function escapeRegExp(value) {
136
136
  * The section of a document, cut syntactically: from the heading whose slug
137
137
  * matches the anchor to the next heading of equal or higher level. No notebook
138
138
  * convention takes part in this: the cut is syntactic.
139
+ *
140
+ * A line inside a fence is code and never a heading, so the `# Step 1` comment
141
+ * of a shell block neither opens a section nor ends one.
139
142
  */
140
143
  export function sectionOf(markdown, anchor) {
141
144
  const wanted = headingKey(anchor);
142
145
  const lines = markdown.split('\n');
146
+ const fences = fenceRegions(markdown);
143
147
  let start = -1;
144
148
  let level = 0;
149
+ let at = 0;
145
150
  for (let index = 0; index < lines.length; index++) {
151
+ const offset = at;
152
+ at += (lines[index] ?? '').length + 1;
153
+ if (insideCode(fences, offset))
154
+ continue;
146
155
  const heading = HEADING.exec(lines[index] ?? '');
147
156
  if (!heading)
148
157
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorysmithapp/markdown-renderer",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -42,6 +42,10 @@
42
42
  "types": "./dist/index.d.ts",
43
43
  "default": "./dist/index.js"
44
44
  },
45
+ "./diagnose": {
46
+ "types": "./dist/diagnose.d.ts",
47
+ "default": "./dist/diagnose.js"
48
+ },
45
49
  "./styles.css": "./styles.css",
46
50
  "./structure.css": "./structure.css",
47
51
  "./theme-default.css": "./theme-default.css"
@@ -63,7 +67,7 @@
63
67
  "unified": "11.0.5"
64
68
  },
65
69
  "devDependencies": {
66
- "@memorysmithapp/markdown-spec": "0.2.0",
70
+ "@memorysmithapp/markdown-spec": "0.3.1",
67
71
  "@types/hast": "3.0.5",
68
72
  "@types/react": "19.2.18",
69
73
  "@types/react-dom": "19.2.4",
package/structure.css CHANGED
@@ -37,6 +37,13 @@
37
37
  border: 0;
38
38
  }
39
39
 
40
+ /* The words of a callout's title are one item of its row, so a title that
41
+ carries emphasis wraps as one sentence beside the icon, never as columns. */
42
+ .markdown .callout-title-text {
43
+ flex: 1 1 auto;
44
+ min-width: 0;
45
+ }
46
+
40
47
  /* An icon a theme draws as an element takes the place of the one the
41
48
  stylesheet draws as a mask, at the size of the title it sits in. */
42
49
  .markdown .callout-title.has-icon::before {
package/theme-default.css CHANGED
@@ -360,6 +360,10 @@
360
360
  /* Room for the button, so the first line is never under it. */
361
361
  padding-right: 6.5rem;
362
362
  }
363
+ /* An empty block keeps the height of one line, so its button fits inside it. */
364
+ .markdown .code-block > pre > code:empty::before {
365
+ content: '\200b';
366
+ }
363
367
  .markdown pre code {
364
368
  background: none;
365
369
  color: var(--ms-text);