@memorysmithapp/markdown-renderer 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +37 -2
- package/dist/CopyButton.d.ts +27 -0
- package/dist/CopyButton.js +69 -0
- package/dist/LinkChoice.d.ts +28 -0
- package/dist/LinkChoice.js +181 -0
- package/dist/Markdown.d.ts +35 -0
- package/dist/Markdown.js +283 -0
- package/dist/MermaidDiagram.d.ts +16 -0
- package/dist/MermaidDiagram.js +70 -0
- package/dist/NoteBody.d.ts +15 -0
- package/dist/NoteBody.js +92 -0
- package/dist/Transclusion.d.ts +11 -0
- package/dist/Transclusion.js +66 -0
- package/dist/address.d.ts +53 -0
- package/dist/address.js +69 -0
- package/dist/delegated.d.ts +39 -0
- package/dist/delegated.js +63 -0
- package/dist/footnotes.d.ts +72 -0
- package/dist/footnotes.js +137 -0
- package/dist/highlight.d.ts +54 -0
- package/dist/highlight.js +96 -0
- package/dist/host.d.ts +135 -0
- package/dist/host.js +49 -0
- package/dist/image-dimensions.d.ts +33 -0
- package/dist/image-dimensions.js +42 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/markdown.d.ts +68 -0
- package/dist/markdown.js +395 -0
- package/dist/remark-callouts.d.ts +40 -0
- package/dist/remark-callouts.js +108 -0
- package/dist/remark-ring.d.ts +68 -0
- package/dist/remark-ring.js +178 -0
- package/dist/strings.d.ts +55 -0
- package/dist/strings.js +64 -0
- package/dist/tasklist.d.ts +25 -0
- package/dist/tasklist.js +80 -0
- package/dist/transclusion.d.ts +76 -0
- package/dist/transclusion.js +161 -0
- package/dist/wikilink-address.d.ts +8 -0
- package/dist/wikilink-address.js +13 -0
- package/package.json +75 -3
- package/styles.css +865 -0
package/dist/host.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
3
|
+
import { withDefaults } from './strings.js';
|
|
4
|
+
/**
|
|
5
|
+
* Every member of the contract, by name. The `satisfies` is what makes it complete: a member added
|
|
6
|
+
* to `NoteHost` and not listed here does not compile.
|
|
7
|
+
*/
|
|
8
|
+
export const HOST_KEYS = Object.keys({
|
|
9
|
+
resolve: 1,
|
|
10
|
+
reaches: 1,
|
|
11
|
+
choose: 1,
|
|
12
|
+
targetAddress: 1,
|
|
13
|
+
navigate: 1,
|
|
14
|
+
loadNote: 1,
|
|
15
|
+
writable: 1,
|
|
16
|
+
onToggleTask: 1,
|
|
17
|
+
keeps: 1,
|
|
18
|
+
renderAttachment: 1,
|
|
19
|
+
renderLink: 1,
|
|
20
|
+
strings: 1,
|
|
21
|
+
theme: 1,
|
|
22
|
+
remoteNotice: 1,
|
|
23
|
+
});
|
|
24
|
+
const nothing = () => null;
|
|
25
|
+
const never = () => false;
|
|
26
|
+
const plainLink = ({ href, className, children }) => (_jsx("a", { className: className, href: href, children: children }));
|
|
27
|
+
export function resolveHost(host) {
|
|
28
|
+
const resolve = host.resolve ?? nothing;
|
|
29
|
+
return {
|
|
30
|
+
resolve,
|
|
31
|
+
reaches: host.reaches ?? ((name) => (resolve(name) === null ? 0 : 1)),
|
|
32
|
+
choose: host.choose ?? null,
|
|
33
|
+
targetAddress: host.targetAddress ?? nothing,
|
|
34
|
+
navigate: host.navigate ?? ((address) => window.location.assign(address)),
|
|
35
|
+
remoteNotice: host.remoteNotice ?? true,
|
|
36
|
+
loadNote: host.loadNote ?? null,
|
|
37
|
+
keeps: host.keeps ?? never,
|
|
38
|
+
renderAttachment: host.renderAttachment ?? null,
|
|
39
|
+
renderLink: host.renderLink ?? plainLink,
|
|
40
|
+
strings: withDefaults(host.strings),
|
|
41
|
+
theme: host.theme ?? 'light',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const HostContext = createContext(resolveHost({}));
|
|
45
|
+
export const HostProvider = HostContext.Provider;
|
|
46
|
+
/** The host the note is drawn under, read by every part of the surface. */
|
|
47
|
+
export function useHost() {
|
|
48
|
+
return useContext(HostContext);
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pipe inside the alt text of an image, which separates the description
|
|
3
|
+
* from the dimensions (`SPEC.md` §3.14).
|
|
4
|
+
*
|
|
5
|
+
* `` is 100 by 145 CSS pixels and
|
|
6
|
+
* `` is 100 wide with the aspect ratio kept. What precedes
|
|
7
|
+
* the pipe is the **description** and is never dropped — it is what a person
|
|
8
|
+
* gets when the image does not load, and it is what a screen reader says.
|
|
9
|
+
*
|
|
10
|
+
* **A value that is neither form is not a dimension.** ``
|
|
11
|
+
* keeps `Engelbart|large` as the whole description, because deleting what an
|
|
12
|
+
* author wrote into an accessibility label is the worse of the two failures.
|
|
13
|
+
* Until the specification declared this form, an imported note displayed the
|
|
14
|
+
* pipe and the digits on screen and mandated the whole string as the label.
|
|
15
|
+
*/
|
|
16
|
+
export interface ImageAlt {
|
|
17
|
+
/** What a reader hears, and sees when the image does not load. */
|
|
18
|
+
readonly description: string;
|
|
19
|
+
readonly width: number | null;
|
|
20
|
+
readonly height: number | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What follows a pipe, when it is a dimension and not text.
|
|
24
|
+
*
|
|
25
|
+
* The wikilink embed of an attachment carries the same thing the alt text of a
|
|
26
|
+
* Markdown image does — `![[engelbart.jpg|100x145]]` and
|
|
27
|
+
* `` mean one thing — so both read it here.
|
|
28
|
+
*/
|
|
29
|
+
export declare function readDimensions(value: string): {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number | null;
|
|
32
|
+
} | null;
|
|
33
|
+
export declare function readImageAlt(alt: string): ImageAlt;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pipe inside the alt text of an image, which separates the description
|
|
3
|
+
* from the dimensions (`SPEC.md` §3.14).
|
|
4
|
+
*
|
|
5
|
+
* `` is 100 by 145 CSS pixels and
|
|
6
|
+
* `` is 100 wide with the aspect ratio kept. What precedes
|
|
7
|
+
* the pipe is the **description** and is never dropped — it is what a person
|
|
8
|
+
* gets when the image does not load, and it is what a screen reader says.
|
|
9
|
+
*
|
|
10
|
+
* **A value that is neither form is not a dimension.** ``
|
|
11
|
+
* keeps `Engelbart|large` as the whole description, because deleting what an
|
|
12
|
+
* author wrote into an accessibility label is the worse of the two failures.
|
|
13
|
+
* Until the specification declared this form, an imported note displayed the
|
|
14
|
+
* pipe and the digits on screen and mandated the whole string as the label.
|
|
15
|
+
*/
|
|
16
|
+
/** `100` or `100x145`, and nothing else. */
|
|
17
|
+
const DIMENSIONS = /^(\d{1,5})(?:x(\d{1,5}))?$/;
|
|
18
|
+
/**
|
|
19
|
+
* What follows a pipe, when it is a dimension and not text.
|
|
20
|
+
*
|
|
21
|
+
* The wikilink embed of an attachment carries the same thing the alt text of a
|
|
22
|
+
* Markdown image does — `![[engelbart.jpg|100x145]]` and
|
|
23
|
+
* `` mean one thing — so both read it here.
|
|
24
|
+
*/
|
|
25
|
+
export function readDimensions(value) {
|
|
26
|
+
const measured = DIMENSIONS.exec(value.trim());
|
|
27
|
+
if (!measured)
|
|
28
|
+
return null;
|
|
29
|
+
return {
|
|
30
|
+
width: Number(measured[1]),
|
|
31
|
+
height: measured[2] === undefined ? null : Number(measured[2]),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function readImageAlt(alt) {
|
|
35
|
+
const at = alt.lastIndexOf('|');
|
|
36
|
+
if (at === -1)
|
|
37
|
+
return { description: alt, width: null, height: null };
|
|
38
|
+
const measured = readDimensions(alt.slice(at + 1));
|
|
39
|
+
if (!measured)
|
|
40
|
+
return { description: alt, width: null, height: null };
|
|
41
|
+
return { description: alt.slice(0, at).trim(), width: measured.width, height: measured.height };
|
|
42
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { NoteBody, type NoteBodyProps } from './NoteBody.js';
|
|
2
|
+
export type { AttachmentRequest, EmbeddedNote, LinkChoiceNote, LinkChoices, LinkRequest, NoteHost } from './host.js';
|
|
3
|
+
export { ENGLISH, type NoteStrings } from './strings.js';
|
|
4
|
+
export { followable } from './address.js';
|
|
5
|
+
export { readDimensions, readImageAlt, type ImageAlt } from './image-dimensions.js';
|
|
6
|
+
export { codeRegions, fenceRegions, headingKey, remoteImageHosts, resolveWikilinks, splitFrontmatter, tableRegions, toUnixNewlines, type SplitDocument, } from './markdown.js';
|
|
7
|
+
export { ordinalAt, taskBoxes, taskCheckedAt, toggleTaskAt } from './tasklist.js';
|
|
8
|
+
export { DELEGATED_TO_THE_BASE_PARSER, unclassified } from './delegated.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The entry point of @memorysmithapp/markdown-renderer.
|
|
2
|
+
export { NoteBody } from './NoteBody.js';
|
|
3
|
+
export { ENGLISH } from './strings.js';
|
|
4
|
+
export { followable } from './address.js';
|
|
5
|
+
export { readDimensions, readImageAlt } from './image-dimensions.js';
|
|
6
|
+
export { codeRegions, fenceRegions, headingKey, remoteImageHosts, resolveWikilinks, splitFrontmatter, tableRegions, toUnixNewlines, } from './markdown.js';
|
|
7
|
+
export { ordinalAt, taskBoxes, taskCheckedAt, toggleTaskAt } from './tasklist.js';
|
|
8
|
+
export { DELEGATED_TO_THE_BASE_PARSER, unclassified } from './delegated.js';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface SplitDocument {
|
|
2
|
+
frontmatter: Record<string, string>;
|
|
3
|
+
/**
|
|
4
|
+
* Which of those keys the notebook wrote as a list, whether in a dash block or
|
|
5
|
+
* inline. The values are flattened to one comma-separated string so the
|
|
6
|
+
* table can print them, and that flattening is lossy: `tags: a, b` and a
|
|
7
|
+
* two-item list read the same afterwards. The set keeps the difference,
|
|
8
|
+
* because a list is drawn as chips and a sentence is not, and guessing from
|
|
9
|
+
* the commas would turn a prose value that happens to have one into chips.
|
|
10
|
+
*/
|
|
11
|
+
lists: Set<string>;
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A note is bytes the notebook wrote, and a notebook written on Windows, or in a
|
|
16
|
+
* desktop editor, or pasted from anywhere, carries CRLF. Every reader in this
|
|
17
|
+
* module starts here, because a carriage return left at the end of a line is
|
|
18
|
+
* invisible in the source and fatal to a regex: `.` does not match one, so
|
|
19
|
+
* `(.*)$` fails on exactly the lines that have a value.
|
|
20
|
+
*/
|
|
21
|
+
export declare function toUnixNewlines(raw: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Where the body of a note starts: after its frontmatter, which a host shows as properties and
|
|
24
|
+
* not as prose. The cut is the one MemorySmith's reading page makes, so the ordinals of the task
|
|
25
|
+
* boxes below it are counted over the whole note, as the host stores it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function bodyStart(text: string): number;
|
|
28
|
+
/**
|
|
29
|
+
* The frontmatter block, read the way MemorySmith's reading page reads it to show a note's
|
|
30
|
+
* properties. The package does not draw it — `NoteBody` starts after it — and exports this so a
|
|
31
|
+
* host that draws the properties reads the same values MemorySmith does.
|
|
32
|
+
*/
|
|
33
|
+
export declare function splitFrontmatter(input: string): SplitDocument;
|
|
34
|
+
/** The half-open ranges of `body` that are fenced blocks. */
|
|
35
|
+
export declare function fenceRegions(body: string): Array<[number, number]>;
|
|
36
|
+
/** The half-open ranges of `body` that are code, fenced or inline, in order. */
|
|
37
|
+
export declare function codeRegions(body: string): Array<[number, number]>;
|
|
38
|
+
/** Whether an offset falls inside one of them. */
|
|
39
|
+
export declare function insideCode(regions: Array<[number, number]>, at: number): boolean;
|
|
40
|
+
export declare function tableRegions(body: string): Array<[number, number]>;
|
|
41
|
+
/** Whether an offset falls inside one of them. */
|
|
42
|
+
export declare function insideTable(regions: Array<[number, number]>, at: number): boolean;
|
|
43
|
+
/** Runs a rewrite over everything except code, which is copied through. */
|
|
44
|
+
export declare function outsideCode(body: string, rewrite: (text: string) => string): string;
|
|
45
|
+
export declare function resolveWikilinks(body: string, resolve: (name: string) => string | null,
|
|
46
|
+
/**
|
|
47
|
+
* Whether the notebook keeps a FILE under that name. The extension of a
|
|
48
|
+
* name decides nothing, so what a target is cannot be read off it: it is
|
|
49
|
+
* read off what the notebook holds, which is the host's to answer.
|
|
50
|
+
*/
|
|
51
|
+
keeps?: (name: string) => boolean): string;
|
|
52
|
+
/**
|
|
53
|
+
* The key a **heading** is matched by, which is how an anchor finds the
|
|
54
|
+
* section it names inside a note.
|
|
55
|
+
*
|
|
56
|
+
* **It is not the key of a note, and there is no longer one of those.** A link
|
|
57
|
+
* resolves against the name, literally and case-exact (§5.2), so the
|
|
58
|
+
* slug this file used to compute — the second implementation of a rule the
|
|
59
|
+
* kernel also implemented, and the one that drifted — is gone. What is
|
|
60
|
+
* left is this: an anchor and a heading are two spellings of the same phrase,
|
|
61
|
+
* written by the same person minutes apart, and folding case and punctuation
|
|
62
|
+
* between them is what makes `#Artigo 75` find `## Artigo 75`.
|
|
63
|
+
*
|
|
64
|
+
* Nothing outside this comparison reads it, and nothing else in the renderer
|
|
65
|
+
* computes a key from a name: the address of a note is the host's to give.
|
|
66
|
+
*/
|
|
67
|
+
export declare function headingKey(name: string): string;
|
|
68
|
+
export declare function remoteImageHosts(body: string): string[];
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// The text helpers of the reading surface: what a note becomes before the parser reads it.
|
|
2
|
+
// Nothing here rewrites the note a host keeps; each function returns a new string for display.
|
|
3
|
+
import { readDimensions } from './image-dimensions.js';
|
|
4
|
+
/**
|
|
5
|
+
* A note is bytes the notebook wrote, and a notebook written on Windows, or in a
|
|
6
|
+
* desktop editor, or pasted from anywhere, carries CRLF. Every reader in this
|
|
7
|
+
* module starts here, because a carriage return left at the end of a line is
|
|
8
|
+
* invisible in the source and fatal to a regex: `.` does not match one, so
|
|
9
|
+
* `(.*)$` fails on exactly the lines that have a value.
|
|
10
|
+
*/
|
|
11
|
+
export function toUnixNewlines(raw) {
|
|
12
|
+
return raw.replace(/\r\n?/g, '\n');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Where the body of a note starts: after its frontmatter, which a host shows as properties and
|
|
16
|
+
* not as prose. The cut is the one MemorySmith's reading page makes, so the ordinals of the task
|
|
17
|
+
* boxes below it are counted over the whole note, as the host stores it.
|
|
18
|
+
*/
|
|
19
|
+
export function bodyStart(text) {
|
|
20
|
+
return text.startsWith('---') ? text.indexOf('\n---', 3) + 4 : 0;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Strips one layer of matching quotes, as the facet projector does. The trim
|
|
24
|
+
* comes first: an item of an inline list arrives with the space that followed
|
|
25
|
+
* the comma, and a quote that is not at position zero is not stripped.
|
|
26
|
+
*/
|
|
27
|
+
function unquote(value) {
|
|
28
|
+
return value
|
|
29
|
+
.trim()
|
|
30
|
+
.replace(/^["']|["']$/g, '')
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The frontmatter block, read the way MemorySmith's reading page reads it to show a note's
|
|
35
|
+
* properties. The package does not draw it — `NoteBody` starts after it — and exports this so a
|
|
36
|
+
* host that draws the properties reads the same values MemorySmith does.
|
|
37
|
+
*/
|
|
38
|
+
export function splitFrontmatter(input) {
|
|
39
|
+
const raw = toUnixNewlines(input);
|
|
40
|
+
const bare = { frontmatter: {}, lists: new Set(), body: raw };
|
|
41
|
+
if (!raw.startsWith('---'))
|
|
42
|
+
return bare;
|
|
43
|
+
const end = raw.indexOf('\n---', 3);
|
|
44
|
+
if (end === -1)
|
|
45
|
+
return bare;
|
|
46
|
+
const head = raw.slice(raw.indexOf('\n') + 1, end);
|
|
47
|
+
const body = raw.slice(raw.indexOf('\n', end + 1) + 1);
|
|
48
|
+
const frontmatter = {};
|
|
49
|
+
const lists = new Set();
|
|
50
|
+
let lastKey = null;
|
|
51
|
+
for (const line of head.split('\n')) {
|
|
52
|
+
const kv = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
53
|
+
if (kv && kv[1]) {
|
|
54
|
+
lastKey = kv[1];
|
|
55
|
+
const value = (kv[2] ?? '').trim();
|
|
56
|
+
// An inline list becomes the same comma-separated string a dash list
|
|
57
|
+
// produces, so `tags: [a, b]` and a `tags:` block read alike from here.
|
|
58
|
+
const inlineList = value.startsWith('[') && value.endsWith(']');
|
|
59
|
+
if (inlineList)
|
|
60
|
+
lists.add(lastKey);
|
|
61
|
+
frontmatter[lastKey] = inlineList
|
|
62
|
+
? value
|
|
63
|
+
.slice(1, -1)
|
|
64
|
+
.split(',')
|
|
65
|
+
.map(unquote)
|
|
66
|
+
.filter((item) => item.length > 0)
|
|
67
|
+
.join(', ')
|
|
68
|
+
: unquote(value);
|
|
69
|
+
}
|
|
70
|
+
else if (lastKey && /^\s+-\s+/.test(line)) {
|
|
71
|
+
const item = unquote(line.replace(/^\s+-\s+/, ''));
|
|
72
|
+
lists.add(lastKey);
|
|
73
|
+
frontmatter[lastKey] = frontmatter[lastKey] ? `${frontmatter[lastKey]}, ${item}` : item;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { frontmatter, lists, body };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A wikilink, with the `!` in front of it when it is an embed.
|
|
80
|
+
*
|
|
81
|
+
* The bang is captured and not assumed away, because it is what tells a
|
|
82
|
+
* **link** from an **embed**, and the two reach different things: a link
|
|
83
|
+
* addresses a note and nothing else (§5.2), while an embed may also name a
|
|
84
|
+
* file the notebook keeps (§5.8). By the time this pass runs, the bang of a
|
|
85
|
+
* note embed has been taken off by `demoteEmbeds` — an embed that could not be
|
|
86
|
+
* expanded is a reference — so what still carries one is an embed of a file.
|
|
87
|
+
*/
|
|
88
|
+
const WIKILINK = /(!)?\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
|
|
89
|
+
/**
|
|
90
|
+
* Code, where notation is characters and nothing else.
|
|
91
|
+
*
|
|
92
|
+
* A fenced block parses nothing inside it and a code span is where an author
|
|
93
|
+
* writes notation without invoking it — both stated by the specification, in the
|
|
94
|
+
* `code-fenced` and `code-span` entries of `spec.json`. The rewrites below
|
|
95
|
+
* run over the raw text BEFORE the parser sees it, so they are the one place
|
|
96
|
+
* in the renderer that has to know that on its own: every other reading of
|
|
97
|
+
* the notation is a remark plugin, working on a tree where a code node is
|
|
98
|
+
* already a code node.
|
|
99
|
+
*
|
|
100
|
+
* Code is found by the rules the specification states and never by a pattern of
|
|
101
|
+
* backticks: a fence opens on a line whose first thing is a run of
|
|
102
|
+
* three or more backticks or tildes, closes on a line holding a run of the
|
|
103
|
+
* same character, at least as long, and nothing else, and otherwise runs to
|
|
104
|
+
* the end of the note; a code span closes on a run of exactly its own length.
|
|
105
|
+
* A pattern pairing any two runs of three backticks read a span SHOWING three
|
|
106
|
+
* backticks as an opening fence, and every block after it out of phase. The
|
|
107
|
+
* link extractor of MemorySmith reads code by the same rules, and both are held
|
|
108
|
+
* to the `link-in-code` cases of the conformance suite.
|
|
109
|
+
*
|
|
110
|
+
* The indented form is deliberately NOT here, and the reason is the cost of
|
|
111
|
+
* being wrong. Telling four spaces of code from four spaces of a nested list
|
|
112
|
+
* item needs the block context only a parser has; guessing it wrong turns a
|
|
113
|
+
* real link into text, and the same guess in the link extractor would drop a
|
|
114
|
+
* real edge. A wikilink written inside an indented code block is therefore
|
|
115
|
+
* still read, in both readers, and that is declared rather than discovered
|
|
116
|
+
* in MemorySmith's architecture. For the same reason a fence is accepted
|
|
117
|
+
* after whatever its list item or block quote puts in front of it, and a code
|
|
118
|
+
* span is not followed across a line break.
|
|
119
|
+
*/
|
|
120
|
+
const CONTAINER = String.raw `(?:[ \t]*(?:>[ \t]?|(?:[-*+]|\d{1,9}[.)])[ \t]+))*[ \t]*`;
|
|
121
|
+
const OPENING_FENCE = new RegExp(`^(${CONTAINER})(\`{3,}|~{3,})(.*?)\r?$`);
|
|
122
|
+
const CLOSING_FENCE = new RegExp(`^${CONTAINER}(\`{3,}|~{3,})[ \t]*\r?$`);
|
|
123
|
+
/**
|
|
124
|
+
* The fenced blocks of `body`, as half-open ranges from the opening run to the
|
|
125
|
+
* end of the closing line, and the lines no fence holds, which are where code
|
|
126
|
+
* spans may be.
|
|
127
|
+
*/
|
|
128
|
+
function scanFences(body) {
|
|
129
|
+
const fences = [];
|
|
130
|
+
const prose = [];
|
|
131
|
+
let open = null;
|
|
132
|
+
let at = 0;
|
|
133
|
+
for (const line of body.split('\n')) {
|
|
134
|
+
const end = at + line.length;
|
|
135
|
+
if (open) {
|
|
136
|
+
const run = CLOSING_FENCE.exec(line)?.[1];
|
|
137
|
+
if (run && run[0] === open.char && run.length >= open.length) {
|
|
138
|
+
fences.push([open.start, end]);
|
|
139
|
+
open = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const fence = OPENING_FENCE.exec(line);
|
|
144
|
+
const run = fence?.[2] ?? '';
|
|
145
|
+
// A backtick fence may not carry a backtick in its info string: such a
|
|
146
|
+
// line is a paragraph, and what looks like a fence in it is a span.
|
|
147
|
+
if (fence && !(run[0] === '`' && (fence[3] ?? '').includes('`'))) {
|
|
148
|
+
open = { char: run[0] ?? '`', length: run.length, start: at + (fence[1] ?? '').length };
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
prose.push({ line, at });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
at = end + 1;
|
|
155
|
+
}
|
|
156
|
+
if (open)
|
|
157
|
+
fences.push([open.start, body.length]);
|
|
158
|
+
return { fences, prose };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The code spans of one line: a run of backticks closes on the next run of
|
|
162
|
+
* exactly its length, a run with no partner is text, and a backtick escaped by
|
|
163
|
+
* a backslash opens nothing.
|
|
164
|
+
*/
|
|
165
|
+
function codeSpans(line, offset) {
|
|
166
|
+
const runs = [...line.matchAll(/`+/g)].map((match) => ({
|
|
167
|
+
index: match.index ?? 0,
|
|
168
|
+
length: match[0].length,
|
|
169
|
+
}));
|
|
170
|
+
const spans = [];
|
|
171
|
+
for (let i = 0; i < runs.length; i++) {
|
|
172
|
+
const run = runs[i];
|
|
173
|
+
if (!run)
|
|
174
|
+
continue;
|
|
175
|
+
const escaped = /(^|[^\\])(\\\\)*\\$/.test(line.slice(0, run.index));
|
|
176
|
+
const start = escaped ? run.index + 1 : run.index;
|
|
177
|
+
const length = escaped ? run.length - 1 : run.length;
|
|
178
|
+
if (length === 0)
|
|
179
|
+
continue;
|
|
180
|
+
const close = runs.findIndex((other, j) => j > i && other.length === length);
|
|
181
|
+
const closing = runs[close];
|
|
182
|
+
if (close === -1 || !closing)
|
|
183
|
+
continue;
|
|
184
|
+
spans.push([offset + start, offset + closing.index + closing.length]);
|
|
185
|
+
i = close;
|
|
186
|
+
}
|
|
187
|
+
return spans;
|
|
188
|
+
}
|
|
189
|
+
/** The half-open ranges of `body` that are fenced blocks. */
|
|
190
|
+
export function fenceRegions(body) {
|
|
191
|
+
return scanFences(body).fences;
|
|
192
|
+
}
|
|
193
|
+
/** The half-open ranges of `body` that are code, fenced or inline, in order. */
|
|
194
|
+
export function codeRegions(body) {
|
|
195
|
+
const { fences, prose } = scanFences(body);
|
|
196
|
+
const spans = prose.flatMap(({ line, at }) => codeSpans(line, at));
|
|
197
|
+
return [...fences, ...spans].sort((a, b) => a[0] - b[0]);
|
|
198
|
+
}
|
|
199
|
+
/** Whether an offset falls inside one of them. */
|
|
200
|
+
export function insideCode(regions, at) {
|
|
201
|
+
return regions.some(([start, end]) => at >= start && at < end);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A GFM table, which is the third place an embed cannot expand (§7.3).
|
|
205
|
+
*
|
|
206
|
+
* A cell holds inlines and never blocks (§4.1), so an embed of a note in a
|
|
207
|
+
* cell is drawn as a link, the same answer the one-level rule and the embed
|
|
208
|
+
* ceiling already give. What made this its own case is that the expansion is
|
|
209
|
+
* decided on the raw string, by cutting the body into runs around each
|
|
210
|
+
* `![[…]]`, and a cut inside a table row does not merely fail to expand: it
|
|
211
|
+
* ends the run mid-row, so the parser that reads it has an unterminated table
|
|
212
|
+
* and the closing pipe becomes a paragraph of its own.
|
|
213
|
+
*
|
|
214
|
+
* Detected here rather than in a plugin for the reason the code regions
|
|
215
|
+
* already are: the split happens before anything is parsed, so this is the one
|
|
216
|
+
* place in the renderer that has to recognise the block on its own.
|
|
217
|
+
*
|
|
218
|
+
* A table is a line containing a pipe, followed by a delimiter row of dashes,
|
|
219
|
+
* and it runs to the first blank line. The outer pipes are optional, which is
|
|
220
|
+
* why the delimiter row is what identifies the block. A shape this misses
|
|
221
|
+
* costs nothing new — it is the behaviour of before — so the pattern is
|
|
222
|
+
* deliberately the conservative one.
|
|
223
|
+
*/
|
|
224
|
+
const DELIMITER_ROW = /^ {0,3}\|?[ \t]*:?-{1,}:?[ \t]*(\|[ \t]*:?-{1,}:?[ \t]*)*\|?[ \t]*$/;
|
|
225
|
+
export function tableRegions(body) {
|
|
226
|
+
const regions = [];
|
|
227
|
+
const code = codeRegions(body);
|
|
228
|
+
const lines = body.split('\n');
|
|
229
|
+
const offsets = [];
|
|
230
|
+
let at = 0;
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
offsets.push(at);
|
|
233
|
+
at += line.length + 1;
|
|
234
|
+
}
|
|
235
|
+
for (let index = 0; index + 1 < lines.length; index++) {
|
|
236
|
+
const header = lines[index] ?? '';
|
|
237
|
+
const delimiter = lines[index + 1] ?? '';
|
|
238
|
+
if (!header.includes('|') || !delimiter.includes('|'))
|
|
239
|
+
continue;
|
|
240
|
+
if (!DELIMITER_ROW.test(delimiter))
|
|
241
|
+
continue;
|
|
242
|
+
if (insideCode(code, offsets[index] ?? 0))
|
|
243
|
+
continue;
|
|
244
|
+
let last = index + 1;
|
|
245
|
+
while (last + 1 < lines.length && (lines[last + 1] ?? '').trim().length > 0)
|
|
246
|
+
last++;
|
|
247
|
+
regions.push([offsets[index] ?? 0, (offsets[last] ?? 0) + (lines[last] ?? '').length]);
|
|
248
|
+
index = last;
|
|
249
|
+
}
|
|
250
|
+
return regions;
|
|
251
|
+
}
|
|
252
|
+
/** Whether an offset falls inside one of them. */
|
|
253
|
+
export function insideTable(regions, at) {
|
|
254
|
+
return regions.some(([start, end]) => at >= start && at < end);
|
|
255
|
+
}
|
|
256
|
+
/** Runs a rewrite over everything except code, which is copied through. */
|
|
257
|
+
export function outsideCode(body, rewrite) {
|
|
258
|
+
let out = '';
|
|
259
|
+
let cursor = 0;
|
|
260
|
+
for (const [start, end] of codeRegions(body)) {
|
|
261
|
+
out += rewrite(body.slice(cursor, start)) + body.slice(start, end);
|
|
262
|
+
cursor = end;
|
|
263
|
+
}
|
|
264
|
+
return out + rewrite(body.slice(cursor));
|
|
265
|
+
}
|
|
266
|
+
// Replaces [[wikilinks]] with markdown links. Resolved targets point at the
|
|
267
|
+
// note route; unresolved ones become pending: links styled by the renderer.
|
|
268
|
+
export function resolveWikilinks(body, resolve,
|
|
269
|
+
/**
|
|
270
|
+
* Whether the notebook keeps a FILE under that name. The extension of a
|
|
271
|
+
* name decides nothing, so what a target is cannot be read off it: it is
|
|
272
|
+
* read off what the notebook holds, which is the host's to answer.
|
|
273
|
+
*/
|
|
274
|
+
keeps = () => false) {
|
|
275
|
+
return outsideCode(body, (text) => resolveWikilinksIn(text, resolve, keeps));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The target of a wikilink is a NAME, and it is literal: nothing in it is
|
|
279
|
+
* decoded, no extension is removed and no path segment is discarded
|
|
280
|
+
* (§5.2). It used to be slugified here, which is what made a link differ
|
|
281
|
+
* from its note over an accent or a capital.
|
|
282
|
+
*/
|
|
283
|
+
function resolveWikilinksIn(body, resolve, keeps) {
|
|
284
|
+
return body.replace(WIKILINK, (_all, bang, target, label) => {
|
|
285
|
+
const clean = target.split('#')[0]?.trim().replace(/\\$/, '').trim() ?? '';
|
|
286
|
+
const embed = bang !== undefined;
|
|
287
|
+
const text = displayText(clean, label, target, (name) => embed && keeps(name));
|
|
288
|
+
if (!clean)
|
|
289
|
+
return `[${text}](pending:)`;
|
|
290
|
+
// The pipe is read by WHAT THE TARGET IS: a note takes the alias, an
|
|
291
|
+
// attachment takes the dimensions, and a target that resolves to neither
|
|
292
|
+
// takes the alias — because reading it as a dimension would discard text
|
|
293
|
+
// an author wrote.
|
|
294
|
+
//
|
|
295
|
+
// **Only an embed reaches a file**. A `[[name]]` is resolved against
|
|
296
|
+
// the notes of the notebook and their aliases, and against nothing else:
|
|
297
|
+
// §5.2 lists the whole of resolution and an attachment is in none of its
|
|
298
|
+
// steps, so a name only a file carries is a pending link like any other.
|
|
299
|
+
if (embed && keeps(clean)) {
|
|
300
|
+
// The dimension travels with the address, because the name is already
|
|
301
|
+
// percent-encoded and a bare `|` after it cannot be part of it.
|
|
302
|
+
// Dropping it here is what made `![[picture|120]]` render at full size:
|
|
303
|
+
// the notation was read and then thrown away one line before it was used.
|
|
304
|
+
const measured = label === undefined ? null : readDimensions(label);
|
|
305
|
+
const sized = measured
|
|
306
|
+
? `|${measured.width}${measured.height === null ? '' : `x${measured.height}`}`
|
|
307
|
+
: '';
|
|
308
|
+
return `[${text}](attachment:${encodeURIComponent(clean)}${sized})`;
|
|
309
|
+
}
|
|
310
|
+
const url = resolve(clean.normalize('NFC'));
|
|
311
|
+
return url ? `[${text}](${url})` : `[${text}](pending:${encodeURIComponent(clean)})`;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* What the link shows. The pipe of an attachment carries dimensions and is
|
|
316
|
+
* never rendered as text, so what is left to show is the name of the file.
|
|
317
|
+
*/
|
|
318
|
+
function displayText(clean, label, target, keeps) {
|
|
319
|
+
if (clean && keeps(clean))
|
|
320
|
+
return clean;
|
|
321
|
+
return (label ?? target).trim();
|
|
322
|
+
}
|
|
323
|
+
/** The longest a heading key may be, which is what an anchor is matched on. */
|
|
324
|
+
const MAX_KEY_LENGTH = 80;
|
|
325
|
+
/**
|
|
326
|
+
* The key a **heading** is matched by, which is how an anchor finds the
|
|
327
|
+
* section it names inside a note.
|
|
328
|
+
*
|
|
329
|
+
* **It is not the key of a note, and there is no longer one of those.** A link
|
|
330
|
+
* resolves against the name, literally and case-exact (§5.2), so the
|
|
331
|
+
* slug this file used to compute — the second implementation of a rule the
|
|
332
|
+
* kernel also implemented, and the one that drifted — is gone. What is
|
|
333
|
+
* left is this: an anchor and a heading are two spellings of the same phrase,
|
|
334
|
+
* written by the same person minutes apart, and folding case and punctuation
|
|
335
|
+
* between them is what makes `#Artigo 75` find `## Artigo 75`.
|
|
336
|
+
*
|
|
337
|
+
* Nothing outside this comparison reads it, and nothing else in the renderer
|
|
338
|
+
* computes a key from a name: the address of a note is the host's to give.
|
|
339
|
+
*/
|
|
340
|
+
export function headingKey(name) {
|
|
341
|
+
return name
|
|
342
|
+
.normalize('NFD')
|
|
343
|
+
.replace(/(\d)[.,](\d)/g, '$1$2')
|
|
344
|
+
.replace(/[̀-ͯ]/g, '')
|
|
345
|
+
.toLowerCase()
|
|
346
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
347
|
+
.replace(/^-+|-+$/g, '')
|
|
348
|
+
.slice(0, MAX_KEY_LENGTH)
|
|
349
|
+
.replace(/-+$/g, '');
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The hosts a note asks the reader's browser to talk to (§7.10).
|
|
353
|
+
*
|
|
354
|
+
* An image whose destination names a host is a **request to that host**, made
|
|
355
|
+
* when the note is opened, by whoever opens it — and the trigger was written
|
|
356
|
+
* by whoever wrote the note, which is the same sentence §7.9 uses about raw
|
|
357
|
+
* HTML. What travels with it is the reader's address, their user-agent and the
|
|
358
|
+
* moment they read it.
|
|
359
|
+
*
|
|
360
|
+
* The specification admits three answers and forbids only silence: never fetching,
|
|
361
|
+
* fetching on the reader's action, or fetching and saying so. MemorySmith,
|
|
362
|
+
* and this renderer with it, gives the third, so this is the half that says so. It is the image of §3.14
|
|
363
|
+
* and nothing else: a diagram and a formula render locally, and a link —
|
|
364
|
+
* bracketed, bare or autolinked — is a navigation the person initiates rather
|
|
365
|
+
* than a request the page makes.
|
|
366
|
+
*
|
|
367
|
+
* Code is excluded, because an image written inside a fence is an example of
|
|
368
|
+
* the notation and fetches nothing.
|
|
369
|
+
*/
|
|
370
|
+
const IMAGE_DESTINATION = /!\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g;
|
|
371
|
+
export function remoteImageHosts(body) {
|
|
372
|
+
const regions = codeRegions(body);
|
|
373
|
+
const hosts = new Set();
|
|
374
|
+
for (const match of body.matchAll(IMAGE_DESTINATION)) {
|
|
375
|
+
if (insideCode(regions, match.index ?? 0))
|
|
376
|
+
continue;
|
|
377
|
+
const destination = (match[1] ?? '').trim();
|
|
378
|
+
// A path, a relative target or an anchor names no host and asks nobody
|
|
379
|
+
// for anything. Only a scheme or a leading `//` reaches outside.
|
|
380
|
+
const authority = /^[a-z][a-z0-9+.-]*:\/\//i.test(destination)
|
|
381
|
+
? destination.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
|
|
382
|
+
: destination.startsWith('//')
|
|
383
|
+
? destination.slice(2)
|
|
384
|
+
: '';
|
|
385
|
+
if (authority === '')
|
|
386
|
+
continue;
|
|
387
|
+
// The authority, minus any credentials, port and path: what a person needs
|
|
388
|
+
// in order to recognise who they just talked to.
|
|
389
|
+
const host = (authority.split('/')[0] ?? '').split('@').pop() ?? '';
|
|
390
|
+
const named = host.split(':')[0] ?? '';
|
|
391
|
+
if (named !== '')
|
|
392
|
+
hosts.add(named.toLowerCase());
|
|
393
|
+
}
|
|
394
|
+
return [...hosts];
|
|
395
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notebook callouts, rendered as callouts.
|
|
3
|
+
*
|
|
4
|
+
* A callout is a blockquote whose first line is a type marker:
|
|
5
|
+
*
|
|
6
|
+
* > [!warning] O que pode dar errado
|
|
7
|
+
* > O corpo do aviso.
|
|
8
|
+
*
|
|
9
|
+
* It is a notebook convention and not universal Markdown (§7.1). What
|
|
10
|
+
* MemorySmith's reading page once did was a pass over the
|
|
11
|
+
* string that swapped the marker for an emoji and left the blockquote behind,
|
|
12
|
+
* which reads as a quotation with a picture in front of it. This turns it into
|
|
13
|
+
* the element the vault editors draw, and the type travels to the DOM as an
|
|
14
|
+
* attribute so the stylesheet can colour and ice it without a class per type.
|
|
15
|
+
*
|
|
16
|
+
* It is a remark plugin rather than another pass over the string because the
|
|
17
|
+
* marker has to leave the tree the renderer walks, not the text the parser
|
|
18
|
+
* reads: a `[!note]` inside a code fence is not a callout, and only the parser
|
|
19
|
+
* can tell the difference.
|
|
20
|
+
*/
|
|
21
|
+
interface MdastNode {
|
|
22
|
+
type: string;
|
|
23
|
+
value?: string;
|
|
24
|
+
children?: MdastNode[];
|
|
25
|
+
data?: {
|
|
26
|
+
hName?: string;
|
|
27
|
+
hProperties?: Record<string, unknown>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface CalloutOptions {
|
|
31
|
+
/**
|
|
32
|
+
* The title of a callout written without one, in the language of whoever
|
|
33
|
+
* reads it. It answers `undefined` for a type it has no word for,
|
|
34
|
+
* and that type titles itself, capitalised, as the editors do: the
|
|
35
|
+
* vocabulary is open (§7.1) and a word nobody translated is still a word.
|
|
36
|
+
*/
|
|
37
|
+
readonly titleOf?: (kind: string) => string | undefined;
|
|
38
|
+
}
|
|
39
|
+
export declare function remarkCallouts(options?: CalloutOptions): (tree: MdastNode) => void;
|
|
40
|
+
export {};
|