@memorysmithapp/markdown-renderer 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,178 @@
1
+ /**
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).
5
+ *
6
+ * All three are remark plugins rather than passes over the string, for the
7
+ * reason `remark-callouts.ts` already gives: a `==` inside a code fence is not
8
+ * a highlight, and only the parser can tell the difference. Rewriting the text
9
+ * before it is parsed is how a notation starts firing inside examples.
10
+ *
11
+ * None of them touches the bytes. The reading surface is a rendering of the
12
+ * 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.
15
+ */
16
+ /** Walks every node, letting the visitor rewrite a parent's children. */
17
+ function walk(node, visit) {
18
+ if (!node.children)
19
+ return;
20
+ visit(node);
21
+ for (const child of node.children)
22
+ walk(child, visit);
23
+ }
24
+ /** Text nodes carry the raw characters; nothing else is rewritten. */
25
+ function isText(node) {
26
+ return node.type === 'text';
27
+ }
28
+ /**
29
+ * `==highlight==` becomes marked text (§7.5).
30
+ *
31
+ * It carries no meaning beyond emphasis: no edge, no attribute, no index
32
+ * entry. The `==` never reaches the screen.
33
+ */
34
+ const HIGHLIGHT = /==([^=]+)==/g;
35
+ export function remarkHighlight() {
36
+ return (tree) => walk(tree, (parent) => {
37
+ parent.children = (parent.children ?? []).flatMap((child) => {
38
+ if (!isText(child) || !child.value?.includes('=='))
39
+ return [child];
40
+ return split(child.value, HIGHLIGHT, (inner) => ({
41
+ type: 'emphasis',
42
+ data: { hName: 'mark' },
43
+ children: [{ type: 'text', value: inner }],
44
+ }));
45
+ });
46
+ });
47
+ }
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
+ /**
72
+ * `^identifier` at the end of a block names that block (§7.7), and is never
73
+ * rendered as text. What it names is resolved by the embed, not here: this
74
+ * plugin only keeps the marker off the page.
75
+ */
76
+ const BLOCK_ID = /[ \t]*\^([A-Za-z0-9-]+)[ \t]*$/;
77
+ export function remarkBlockIds() {
78
+ return (tree) => walk(tree, (parent) => {
79
+ const children = parent.children ?? [];
80
+ const last = children[children.length - 1];
81
+ if (!last || !isText(last) || !last.value?.includes('^'))
82
+ return;
83
+ const stripped = last.value.replace(BLOCK_ID, '');
84
+ if (stripped === last.value)
85
+ return;
86
+ if (stripped.length === 0)
87
+ children.pop();
88
+ else
89
+ last.value = stripped;
90
+ });
91
+ }
92
+ /**
93
+ * The `$` that is not math (§7.8).
94
+ *
95
+ * `remark-math` opens a formula at any `$` and closes it at the next one, so
96
+ * **two prices in one paragraph become a formula**: `costs $30 a month, and
97
+ * the plan $60` renders `30 a month, and the plan` as mathematics. That is not
98
+ * an exotic case, it is a sentence somebody writes on an ordinary day.
99
+ *
100
+ * The specification states four prohibitions, and **each one has an outside edge and
101
+ * an inside edge**. A `$` does not OPEN a formula when it is followed by
102
+ * whitespace, nor when it is immediately preceded by an alphanumeric; it does
103
+ * not CLOSE one when it is preceded by whitespace, nor when it is immediately
104
+ * followed by a digit.
105
+ *
106
+ * The inside edges arrived with specification v0.4.0, and they are what keeps a
107
+ * price whole. `R$ 100` is safe on the outside edge alone, because of the
108
+ * space. `R$100 e o frete R$200` is not: neither `$` sits next to a space, so
109
+ * with only the outside edge the sentence loses its middle — `100 e o frete R`
110
+ * typeset as mathematics, in italic serif, with the `R` and the `200` stranded
111
+ * either side of it. It is an ordinary sentence in pt-BR, where a price is
112
+ * written `R$`.
113
+ *
114
+ * **The cost is deliberate and the specification names it.** An inline formula
115
+ * written immediately after a word character no longer opens, so `2$x$` is
116
+ * text and `2 $x$` is the form that works. A `$` written for any other purpose
117
+ * belongs in a code span, which is opaque to everything — and for `$HOME` and
118
+ * `$PATH` in one sentence that is the only thing that protects them, because
119
+ * both delimiters look legal by every local rule.
120
+ *
121
+ * This runs after `remark-math` and gives back to the text any inline formula
122
+ * that breaks the rule, reading the source the parser read: the delimiters are
123
+ * gone from the node, and the position is what still knows where they were.
124
+ * The characters ON either side of the range are what the inside edges need,
125
+ * which is why the slice reaches one character past each end.
126
+ *
127
+ * Block math (`$$…$$`) is untouched: its delimiters are unambiguous.
128
+ */
129
+ export function remarkMathDollarRule() {
130
+ return (tree, file) => {
131
+ const source = typeof file.value === 'string' ? file.value : String(file.value ?? '');
132
+ if (source.length === 0)
133
+ return;
134
+ walk(tree, (parent) => {
135
+ parent.children = (parent.children ?? []).map((child) => {
136
+ if (child.type !== 'inlineMath')
137
+ return child;
138
+ const { start, end } = child.position ?? {};
139
+ if (start?.offset === undefined || end?.offset === undefined)
140
+ return child;
141
+ const raw = source.slice(start.offset, end.offset);
142
+ const inner = raw.replace(/^\$+/, '').replace(/\$+$/, '');
143
+ if (inner.length === 0)
144
+ return { type: 'text', value: raw };
145
+ // The outside edges: what sits just inside each delimiter.
146
+ if (/^\s/.test(inner) || /\s$/.test(inner))
147
+ return { type: 'text', value: raw };
148
+ // The inside edges: what sits just OUTSIDE each delimiter, which is
149
+ // the half `R$100 e o frete R$200` is decided by.
150
+ const before = start.offset > 0 ? (source[start.offset - 1] ?? '') : '';
151
+ const after = source[end.offset] ?? '';
152
+ if (before !== '' && /[0-9A-Za-z]/.test(before))
153
+ return { type: 'text', value: raw };
154
+ if (after !== '' && /[0-9]/.test(after))
155
+ return { type: 'text', value: raw };
156
+ return child;
157
+ });
158
+ });
159
+ };
160
+ }
161
+ /** Cuts a text node on a pattern, keeping the parts between the matches. */
162
+ function split(value, pattern, make) {
163
+ const parts = [];
164
+ let cursor = 0;
165
+ pattern.lastIndex = 0;
166
+ for (const match of value.matchAll(pattern)) {
167
+ const at = match.index ?? 0;
168
+ if (at > cursor)
169
+ parts.push({ type: 'text', value: value.slice(cursor, at) });
170
+ parts.push(make(match[1] ?? ''));
171
+ cursor = at + match[0].length;
172
+ }
173
+ if (parts.length === 0)
174
+ return [{ type: 'text', value }];
175
+ if (cursor < value.length)
176
+ parts.push({ type: 'text', value: value.slice(cursor) });
177
+ return parts;
178
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The words the reading surface shows of its own, around what a note says.
3
+ *
4
+ * The package carries no i18n library: the host gives the words in the language of whoever
5
+ * reads, and every word it leaves out falls back to the English MemorySmith ships. A callout
6
+ * kind with no title in `callouts` titles itself, capitalised, as the vault editors do.
7
+ */
8
+ export interface NoteStrings {
9
+ /** The title of an untitled callout, by kind (`note`, `warning`, …). */
10
+ readonly callouts: Readonly<Record<string, string>>;
11
+ /** The heading of the footnotes gathered at the end of a note. */
12
+ readonly footnotes: string;
13
+ /** The label of the way back from a footnote to its reference, counted from 1. */
14
+ readonly footnoteBack: (number: number) => string;
15
+ /** The title of a link no note answers yet. */
16
+ readonly pendingLink: string;
17
+ /** The title of an address the surface does not follow. */
18
+ readonly refusedLink: string;
19
+ /** The title of an embed naming a file the notebook does not keep. */
20
+ readonly attachmentMissing: string;
21
+ /** The verb of the button that copies a block of code. */
22
+ readonly copy: string;
23
+ /** What that button says once the block is copied. */
24
+ readonly copied: string;
25
+ /** The notice under a note that fetched images from other sites, naming them. */
26
+ readonly remoteImages: (hosts: readonly string[]) => string;
27
+ /** The accessible name of the choice a link opens. */
28
+ readonly linkChoice: (target: string) => string;
29
+ /** The heading of the choice when no note answers the target yet. */
30
+ readonly linkLeadsNowhere: string;
31
+ /** The heading of the choice when several notes answer it. */
32
+ readonly linkLeadsToSeveral: string;
33
+ /** What the choice says when no note answers the target. */
34
+ readonly targetPending: (target: string) => string;
35
+ /** What it says when notes are named the target. */
36
+ readonly targetNamed: (target: string, count: number) => string;
37
+ /** What it says when notes carry the target as an alias. */
38
+ readonly targetAliased: (target: string, count: number) => string;
39
+ /** What an embed says when the section or block it names is not in the note. */
40
+ readonly embedSectionMissing: (section: string) => string;
41
+ /** The caption of a note that has no name. */
42
+ readonly unnamedNote: string;
43
+ /** What a region says while it waits for the host. */
44
+ readonly loading: string;
45
+ /** What a region says when the host failed to answer. */
46
+ readonly failed: string;
47
+ /** The control that closes the choice. */
48
+ readonly close: string;
49
+ /** The button that dismisses the choice. */
50
+ readonly cancel: string;
51
+ }
52
+ /** The words MemorySmith shows in English, used for every word a host does not give. */
53
+ export declare const ENGLISH: NoteStrings;
54
+ /** The host's words over the English ones, key by key. */
55
+ export declare function withDefaults(strings: Partial<NoteStrings> | undefined): NoteStrings;
@@ -0,0 +1,64 @@
1
+ /** The callout titles of MemorySmith, in English. */
2
+ const CALLOUTS = {
3
+ note: 'Note',
4
+ info: 'Info',
5
+ todo: 'To do',
6
+ abstract: 'Abstract',
7
+ summary: 'Summary',
8
+ tldr: 'TL;DR',
9
+ tip: 'Tip',
10
+ hint: 'Hint',
11
+ important: 'Important',
12
+ success: 'Success',
13
+ check: 'Check',
14
+ done: 'Done',
15
+ question: 'Question',
16
+ help: 'Help',
17
+ faq: 'FAQ',
18
+ warning: 'Warning',
19
+ caution: 'Caution',
20
+ attention: 'Attention',
21
+ failure: 'Failure',
22
+ fail: 'Fail',
23
+ missing: 'Missing',
24
+ danger: 'Danger',
25
+ error: 'Error',
26
+ bug: 'Bug',
27
+ example: 'Example',
28
+ quote: 'Quote',
29
+ cite: 'Cite',
30
+ };
31
+ /** The words MemorySmith shows in English, used for every word a host does not give. */
32
+ export const ENGLISH = {
33
+ callouts: CALLOUTS,
34
+ footnotes: 'Notes',
35
+ footnoteBack: (number) => `Back to reference ${number}`,
36
+ pendingLink: 'Pending link: the target note does not exist yet.',
37
+ refusedLink: 'This address is not one the reading surface follows, so the text stays and the link does not.',
38
+ attachmentMissing: 'This notebook keeps no file under that name.',
39
+ copy: 'Copy',
40
+ copied: 'Copied',
41
+ remoteImages: (hosts) => hosts.length === 1
42
+ ? `Opening this note fetched an image from ${hosts.join(', ')}, so that site was told your address and the moment you read this.`
43
+ : `Opening this note fetched images from ${hosts.length} other sites — ${hosts.join(', ')} — so each of them was told your address and the moment you read this.`,
44
+ linkChoice: (target) => `The notes the link “${target}” may lead to`,
45
+ linkLeadsNowhere: 'This link leads nowhere yet',
46
+ linkLeadsToSeveral: 'This link leads to more than one note',
47
+ targetPending: (target) => `No note carries the name “${target}” yet. The link is kept and resolves on its own when one does.`,
48
+ targetNamed: (target, count) => count === 1
49
+ ? `“${target}” is the name of one note.`
50
+ : `“${target}” is the name of ${count} notes, each in its own folder. Choose which one to open.`,
51
+ targetAliased: (target, count) => count === 1
52
+ ? `No note is named “${target}”. One note carries it as an alias.`
53
+ : `No note is named “${target}”, so it matched ${count} notes that carry it as an alias — and one of them gives the link up the day somebody writes a note under this name.`,
54
+ embedSectionMissing: (section) => `The section "${section}" is not in this note.`,
55
+ unnamedNote: 'Unnamed note',
56
+ loading: 'Loading…',
57
+ failed: 'Something went wrong.',
58
+ close: 'Close',
59
+ cancel: 'Cancel',
60
+ };
61
+ /** The host's words over the English ones, key by key. */
62
+ export function withDefaults(strings) {
63
+ return { ...ENGLISH, ...strings };
64
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Clicking a task box, on the reading surface.
3
+ *
4
+ * The package never writes the note: a click reports the ordinal of the box,
5
+ * and the host applies it with `toggleTaskAt`, which these functions are
6
+ * exported for. The toggle is POSITIONAL over the original text, never a
7
+ * re-serialisation. Exactly one character between brackets changes, and
8
+ * everything else in the document reaches the host's store as the author
9
+ * wrote it: frontmatter, spacing, line breaks and the rest of the item line
10
+ * included. That is a requirement, not elegance: `splitFrontmatter` is lossy
11
+ * by design, because it flattens lists for display, and rebuilding the
12
+ * document from it would hand the notebook a file nobody typed.
13
+ */
14
+ /** Offset of the `[` of every real task box, in document order. */
15
+ export declare function taskBoxes(text: string): number[];
16
+ /** How many task items start before this offset: the ordinal of that item. */
17
+ export declare function ordinalAt(text: string, offset: number): number;
18
+ /**
19
+ * Flips the n-th task box. Returns null when there is no such item, which is
20
+ * what a stale render looks like, and null is the honest answer: writing the
21
+ * wrong box would be worse than not writing.
22
+ */
23
+ export declare function toggleTaskAt(raw: string, ordinal: number): string | null;
24
+ /** Whether the n-th box is checked, for the optimistic state on the screen. */
25
+ export declare function taskCheckedAt(raw: string, ordinal: number): boolean;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Clicking a task box, on the reading surface.
3
+ *
4
+ * The package never writes the note: a click reports the ordinal of the box,
5
+ * and the host applies it with `toggleTaskAt`, which these functions are
6
+ * exported for. The toggle is POSITIONAL over the original text, never a
7
+ * re-serialisation. Exactly one character between brackets changes, and
8
+ * everything else in the document reaches the host's store as the author
9
+ * wrote it: frontmatter, spacing, line breaks and the rest of the item line
10
+ * included. That is a requirement, not elegance: `splitFrontmatter` is lossy
11
+ * by design, because it flattens lists for display, and rebuilding the
12
+ * document from it would hand the notebook a file nobody typed.
13
+ */
14
+ import { fenceRegions } from './markdown.js';
15
+ /**
16
+ * A task item, as GFM defines it, at the start of a list item — after any
17
+ * block quote markers in front of it, because a callout is a block quote and a
18
+ * checklist is what a `[!todo]` callout holds.
19
+ *
20
+ * MemorySmith's reading page counts without them, so a box inside a callout
21
+ * reported the ordinal of the next box outside it, and ticking it ticked that
22
+ * other box. Counting the quoted boxes on both sides — the text the parser saw
23
+ * and the text the host writes — is what keeps the ordinal of every box its
24
+ * own.
25
+ */
26
+ const TASK = /^(?:[ \t]*>[ \t]?)*[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[([ xX])\]/gm;
27
+ /**
28
+ * Blanks out fenced blocks while keeping every offset intact. A task item
29
+ * inside a fence is an example, not a box, and counting it would shift the
30
+ * index and toggle a different item: the worst possible defect here, because
31
+ * it is silent and lands in the wrong place.
32
+ *
33
+ * The fences are the ones the reading surface finds, by the rules of the
34
+ * specification: a longer outer fence holds a shorter inner one, so a box
35
+ * written after the inner fence closes is still an example.
36
+ */
37
+ function maskFences(text) {
38
+ let masked = '';
39
+ let cursor = 0;
40
+ for (const [start, end] of fenceRegions(text)) {
41
+ masked += text.slice(cursor, start) + text.slice(start, end).replace(/[^\n]/g, ' ');
42
+ cursor = end;
43
+ }
44
+ return masked + text.slice(cursor);
45
+ }
46
+ /** Offset of the `[` of every real task box, in document order. */
47
+ export function taskBoxes(text) {
48
+ const masked = maskFences(text);
49
+ const found = [];
50
+ for (const match of masked.matchAll(TASK)) {
51
+ const at = match.index ?? 0;
52
+ found.push(at + match[0].length - 3);
53
+ }
54
+ return found;
55
+ }
56
+ /** How many task items start before this offset: the ordinal of that item. */
57
+ export function ordinalAt(text, offset) {
58
+ return taskBoxes(text).filter((box) => box < offset).length;
59
+ }
60
+ /**
61
+ * Flips the n-th task box. Returns null when there is no such item, which is
62
+ * what a stale render looks like, and null is the honest answer: writing the
63
+ * wrong box would be worse than not writing.
64
+ */
65
+ export function toggleTaskAt(raw, ordinal) {
66
+ const boxes = taskBoxes(raw);
67
+ const at = boxes[ordinal];
68
+ if (at === undefined)
69
+ return null;
70
+ // `[x]` lowercase on the way in. GFM reads both cases, and picking one keeps
71
+ // the diff of a revision about what changed, not about how it was typed.
72
+ const current = raw[at + 1];
73
+ const next = current === ' ' ? 'x' : ' ';
74
+ return `${raw.slice(0, at + 1)}${next}${raw.slice(at + 2)}`;
75
+ }
76
+ /** Whether the n-th box is checked, for the optimistic state on the screen. */
77
+ export function taskCheckedAt(raw, ordinal) {
78
+ const at = taskBoxes(raw)[ordinal];
79
+ return at !== undefined && raw[at + 1] !== ' ';
80
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Transclusion: `![[note]]` and `![[note#section]]`, expanded for reading.
3
+ *
4
+ * The expansion belongs to the reading surface. It is the same class of work
5
+ * the wikilink resolution already does here: interpreting the notation to
6
+ * display it. Nothing changes in what the host stores: the body of the
7
+ * embedded note comes from the host, and the package never fetches.
8
+ *
9
+ * ONE LEVEL, AND ONLY ONE. An embed found inside transcluded content is drawn
10
+ * as a link to its target, which is what keeps a pair of notes that embed each
11
+ * other from hanging the page.
12
+ *
13
+ * **Three places an embed cannot expand, and one answer for all of them.**
14
+ * Inside embedded content, past the ceiling this file sets, and — since
15
+ * specification v0.4.0 named it — inside a table cell, because a cell holds inlines
16
+ * and never blocks (§4.1). Wherever expansion cannot happen the embed becomes
17
+ * a link and is never dropped, which is what `demoteEmbeds` does.
18
+ */
19
+ export type BodySegment = {
20
+ kind: 'text';
21
+ text: string;
22
+ } | {
23
+ kind: 'embed';
24
+ target: string;
25
+ anchor: string | null;
26
+ };
27
+ /**
28
+ * How many embeds one page expands. Past it the rest stay as links: the cost
29
+ * of a page has to stay predictable, and a note with forty embeds is a note
30
+ * that wanted a different structure.
31
+ */
32
+ export declare const EMBED_LIMIT = 10;
33
+ /**
34
+ * Splits a body into what to render inline and what to transclude. Embeds past
35
+ * the ceiling are demoted to plain wikilinks instead of being dropped, because
36
+ * losing the reference would be worse than not expanding it.
37
+ */
38
+ export declare function splitEmbeds(body: string,
39
+ /**
40
+ * Whether the notebook keeps a FILE under that name. An embed of one
41
+ * expands nothing here: it stays in the text run, where the wikilink pass
42
+ * turns it into the attachment the page draws by its type.
43
+ */
44
+ keeps?: (name: string) => boolean, limit?: number): BodySegment[];
45
+ /**
46
+ * `![[x]]` becomes `[[x]]`: a reference instead of an expansion.
47
+ *
48
+ * **An embed of a file keeps its `!`**. Demoting exists for an embed
49
+ * that cannot be expanded here — one inside a table cell, one inside embedded
50
+ * content — and an attachment is not one of those: the page draws it. Taking
51
+ * the bang off it was what made `[[picture]]` and `![[picture]]` arrive at the
52
+ * wikilink pass as the same three characters, so a **link** to a file drew the
53
+ * file. A link addresses a note (§5.2), and only the embed reaches a file.
54
+ */
55
+ export declare function demoteEmbeds(body: string, keeps?: (name: string) => boolean): string;
56
+ /**
57
+ * The block a `^identifier` names, or null when nothing names it (§7.7).
58
+ *
59
+ * A block is what the parser would call one: a run of lines up to a blank
60
+ * line. The identifier sits at the END of it, and the marker is left in the
61
+ * text on purpose — `remarkBlockIds` takes it off the page, and cutting it
62
+ * here would mean this function decides what the reader shows.
63
+ *
64
+ * An identifier that names nothing answers null, and the caller renders it the
65
+ * way it renders a pending link. It is never an error: a notebook is read most
66
+ * while it is being written.
67
+ */
68
+ export declare function blockOf(markdown: string, identifier: string): string | null;
69
+ /** An anchor written `^id` addresses a block; anything else, a section. */
70
+ export declare function isBlockAnchor(anchor: string): boolean;
71
+ /**
72
+ * The section of a document, cut syntactically: from the heading whose slug
73
+ * matches the anchor to the next heading of equal or higher level. No notebook
74
+ * convention takes part in this: the cut is syntactic.
75
+ */
76
+ export declare function sectionOf(markdown: string, anchor: string): string | null;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Transclusion: `![[note]]` and `![[note#section]]`, expanded for reading.
3
+ *
4
+ * The expansion belongs to the reading surface. It is the same class of work
5
+ * the wikilink resolution already does here: interpreting the notation to
6
+ * display it. Nothing changes in what the host stores: the body of the
7
+ * embedded note comes from the host, and the package never fetches.
8
+ *
9
+ * ONE LEVEL, AND ONLY ONE. An embed found inside transcluded content is drawn
10
+ * as a link to its target, which is what keeps a pair of notes that embed each
11
+ * other from hanging the page.
12
+ *
13
+ * **Three places an embed cannot expand, and one answer for all of them.**
14
+ * Inside embedded content, past the ceiling this file sets, and — since
15
+ * specification v0.4.0 named it — inside a table cell, because a cell holds inlines
16
+ * and never blocks (§4.1). Wherever expansion cannot happen the embed becomes
17
+ * a link and is never dropped, which is what `demoteEmbeds` does.
18
+ */
19
+ import { codeRegions, headingKey, insideCode, insideTable, outsideCode, tableRegions } from './markdown.js';
20
+ /** Same shape the link extractor matches, plus the leading `!`. */
21
+ const EMBED = /!\[\[([^\]|#]+?)(?:#([^\]|]+?))?(?:\|[^\]]*?)?\]\]/g;
22
+ /**
23
+ * How many embeds one page expands. Past it the rest stay as links: the cost
24
+ * of a page has to stay predictable, and a note with forty embeds is a note
25
+ * that wanted a different structure.
26
+ */
27
+ export const EMBED_LIMIT = 10;
28
+ /**
29
+ * Splits a body into what to render inline and what to transclude. Embeds past
30
+ * the ceiling are demoted to plain wikilinks instead of being dropped, because
31
+ * losing the reference would be worse than not expanding it.
32
+ */
33
+ export function splitEmbeds(body,
34
+ /**
35
+ * Whether the notebook keeps a FILE under that name. An embed of one
36
+ * expands nothing here: it stays in the text run, where the wikilink pass
37
+ * turns it into the attachment the page draws by its type.
38
+ */
39
+ keeps = () => false, limit = EMBED_LIMIT) {
40
+ const segments = [];
41
+ const code = codeRegions(body);
42
+ const tables = tableRegions(body);
43
+ let cursor = 0;
44
+ let expanded = 0;
45
+ for (const match of body.matchAll(EMBED)) {
46
+ const at = match.index ?? 0;
47
+ const target = (match[1] ?? '').trim();
48
+ if (!target)
49
+ continue;
50
+ // A file is not a note, so there is nothing to transclude: it is left in
51
+ // the text run, where the wikilink pass turns it into the attachment the
52
+ // page draws by its type.
53
+ if (keeps(target))
54
+ continue;
55
+ // An embed written inside code is an example of the notation, not a use of
56
+ // it: expanding it would replace the very text somebody was showing.
57
+ if (insideCode(code, at))
58
+ continue;
59
+ // An embed inside a table cell is drawn as a link (§7.3): no block
60
+ // fits in a cell. Skipping it here leaves it in the surrounding text run,
61
+ // where `demoteEmbeds` turns it into the wikilink the specification asks for —
62
+ // and, just as importantly, keeps the run from being cut mid-row, which
63
+ // used to empty the cell and leave the closing pipe as a paragraph.
64
+ if (insideTable(tables, at))
65
+ continue;
66
+ if (expanded >= limit)
67
+ break;
68
+ // Demoted like the tail is, and for the embeds this loop SKIPPED: one in
69
+ // a table cell lands in a run before an expanded one, and without this it
70
+ // would reach the page as the literal `![[…]]` — dropped notation, which
71
+ // is the one thing §7.3 forbids in every place expansion cannot happen.
72
+ // Code is untouched, because `demoteEmbeds` rewrites outside it.
73
+ const before = demoteEmbeds(body.slice(cursor, at), keeps);
74
+ if (before.length > 0)
75
+ segments.push({ kind: 'text', text: before });
76
+ segments.push({ kind: 'embed', target, anchor: match[2]?.trim() ?? null });
77
+ cursor = at + match[0].length;
78
+ expanded += 1;
79
+ }
80
+ const rest = body.slice(cursor);
81
+ if (rest.length > 0)
82
+ segments.push({ kind: 'text', text: demoteEmbeds(rest, keeps) });
83
+ return segments;
84
+ }
85
+ /**
86
+ * `![[x]]` becomes `[[x]]`: a reference instead of an expansion.
87
+ *
88
+ * **An embed of a file keeps its `!`**. Demoting exists for an embed
89
+ * that cannot be expanded here — one inside a table cell, one inside embedded
90
+ * content — and an attachment is not one of those: the page draws it. Taking
91
+ * the bang off it was what made `[[picture]]` and `![[picture]]` arrive at the
92
+ * wikilink pass as the same three characters, so a **link** to a file drew the
93
+ * file. A link addresses a note (§5.2), and only the embed reaches a file.
94
+ */
95
+ export function demoteEmbeds(body, keeps = () => false) {
96
+ return outsideCode(body, (text) => text.replace(EMBED, (all, target) => (keeps(target.trim()) ? all : all.slice(1))));
97
+ }
98
+ const HEADING = /^(#{1,6})\s+(.+?)\s*$/;
99
+ /**
100
+ * The block a `^identifier` names, or null when nothing names it (§7.7).
101
+ *
102
+ * A block is what the parser would call one: a run of lines up to a blank
103
+ * line. The identifier sits at the END of it, and the marker is left in the
104
+ * text on purpose — `remarkBlockIds` takes it off the page, and cutting it
105
+ * here would mean this function decides what the reader shows.
106
+ *
107
+ * An identifier that names nothing answers null, and the caller renders it the
108
+ * way it renders a pending link. It is never an error: a notebook is read most
109
+ * while it is being written.
110
+ */
111
+ export function blockOf(markdown, identifier) {
112
+ const marker = new RegExp(`[ \\t]\\^${escapeRegExp(identifier)}[ \\t]*$`);
113
+ const lines = markdown.split('\n');
114
+ for (let index = 0; index < lines.length; index++) {
115
+ if (!marker.test(lines[index] ?? ''))
116
+ continue;
117
+ // Walk back to the top of the block: the first line after a blank one.
118
+ let start = index;
119
+ while (start > 0 && (lines[start - 1] ?? '').trim().length > 0)
120
+ start--;
121
+ return lines
122
+ .slice(start, index + 1)
123
+ .join('\n')
124
+ .trimEnd();
125
+ }
126
+ return null;
127
+ }
128
+ /** An anchor written `^id` addresses a block; anything else, a section. */
129
+ export function isBlockAnchor(anchor) {
130
+ return anchor.startsWith('^');
131
+ }
132
+ function escapeRegExp(value) {
133
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
134
+ }
135
+ /**
136
+ * The section of a document, cut syntactically: from the heading whose slug
137
+ * matches the anchor to the next heading of equal or higher level. No notebook
138
+ * convention takes part in this: the cut is syntactic.
139
+ */
140
+ export function sectionOf(markdown, anchor) {
141
+ const wanted = headingKey(anchor);
142
+ const lines = markdown.split('\n');
143
+ let start = -1;
144
+ let level = 0;
145
+ for (let index = 0; index < lines.length; index++) {
146
+ const heading = HEADING.exec(lines[index] ?? '');
147
+ if (!heading)
148
+ continue;
149
+ if (start === -1) {
150
+ if (headingKey(heading[2] ?? '') === wanted) {
151
+ start = index;
152
+ level = (heading[1] ?? '').length;
153
+ }
154
+ continue;
155
+ }
156
+ if ((heading[1] ?? '').length <= level) {
157
+ return lines.slice(start, index).join('\n').trimEnd();
158
+ }
159
+ }
160
+ return start === -1 ? null : lines.slice(start).join('\n').trimEnd();
161
+ }
@@ -0,0 +1,8 @@
1
+ import type { ResolvedHost } from './host.js';
2
+ /**
3
+ * Where a wikilink goes: the note when exactly one answers it, and the choice
4
+ * otherwise — several notes, or none, which is pending and still asks — when
5
+ * the host can say which notes a target may lead to. With no such host, a
6
+ * target no single note answers is pending.
7
+ */
8
+ export declare function wikilinkAddress(host: ResolvedHost, name: string): string | null;
@@ -0,0 +1,13 @@
1
+ import { CHOICE_SCHEME } from './LinkChoice.js';
2
+ /**
3
+ * Where a wikilink goes: the note when exactly one answers it, and the choice
4
+ * otherwise — several notes, or none, which is pending and still asks — when
5
+ * the host can say which notes a target may lead to. With no such host, a
6
+ * target no single note answers is pending.
7
+ */
8
+ export function wikilinkAddress(host, name) {
9
+ const address = host.resolve(name);
10
+ if (address !== null)
11
+ return address;
12
+ return host.choose ? `${CHOICE_SCHEME}${encodeURIComponent(name)}` : null;
13
+ }