@memorysmithapp/markdown-renderer 0.3.2 → 0.5.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.
- package/README.md +50 -4
- package/dist/Markdown.d.ts +6 -2
- package/dist/Markdown.js +126 -17
- package/dist/NoteActions.d.ts +9 -0
- package/dist/NoteActions.js +68 -0
- package/dist/NoteBody.d.ts +32 -2
- package/dist/NoteBody.js +74 -7
- package/dist/edit/AttachDialog.d.ts +23 -0
- package/dist/edit/AttachDialog.js +126 -0
- package/dist/edit/ContextMenu.d.ts +29 -0
- package/dist/edit/ContextMenu.js +95 -0
- package/dist/edit/LanguageControl.d.ts +19 -0
- package/dist/edit/LanguageControl.js +124 -0
- package/dist/edit/NoteEditor.d.ts +44 -0
- package/dist/edit/NoteEditor.js +520 -0
- package/dist/edit/SaveNoticeDialog.d.ts +15 -0
- package/dist/edit/SaveNoticeDialog.js +41 -0
- package/dist/edit/analyze.d.ts +150 -0
- package/dist/edit/analyze.js +268 -0
- package/dist/edit/blocks.d.ts +97 -0
- package/dist/edit/blocks.js +481 -0
- package/dist/edit/commands.d.ts +78 -0
- package/dist/edit/commands.js +257 -0
- package/dist/edit/frontmatter.d.ts +45 -0
- package/dist/edit/frontmatter.js +142 -0
- package/dist/edit/index.d.ts +5 -0
- package/dist/edit/index.js +7 -0
- package/dist/edit/live.d.ts +28 -0
- package/dist/edit/live.js +675 -0
- package/dist/edit/saves.d.ts +48 -0
- package/dist/edit/saves.js +73 -0
- package/dist/edit/table.d.ts +58 -0
- package/dist/edit/table.js +185 -0
- package/dist/file-types.d.ts +30 -0
- package/dist/file-types.js +70 -0
- package/dist/highlight.d.ts +21 -7
- package/dist/highlight.js +56 -7
- package/dist/host.d.ts +82 -3
- package/dist/host.js +16 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +1 -0
- package/dist/mode-icons.d.ts +6 -0
- package/dist/mode-icons.js +11 -0
- package/dist/prism-grammars.d.ts +18 -0
- package/dist/prism-grammars.js +305 -0
- package/dist/remark-callouts.d.ts +6 -0
- package/dist/remark-callouts.js +2 -2
- package/dist/remark-ring.d.ts +5 -0
- package/dist/remark-ring.js +25 -15
- package/dist/slots.js +17 -1
- package/dist/strings.d.ts +129 -1
- package/dist/strings.js +127 -1
- package/dist/task-saves.d.ts +35 -0
- package/dist/task-saves.js +86 -0
- package/dist/theme.d.ts +47 -0
- package/dist/theme.js +2 -2
- package/package.json +14 -2
- package/structure.css +355 -0
- package/theme-default.css +1099 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the save of the author's edits stands (#64, #86).
|
|
3
|
+
*
|
|
4
|
+
* The host saves each change it is handed. When `onChange` answers with a promise, the editor
|
|
5
|
+
* follows it: saving while it is pending, saved once it resolves, not saved when it rejects. Only
|
|
6
|
+
* the latest change counts: a save that settles after a later change was handed says nothing, so
|
|
7
|
+
* "Saved" always means the last text the author wrote. A host that answers with nothing has no
|
|
8
|
+
* status, and the editor shows none.
|
|
9
|
+
*
|
|
10
|
+
* A host may also say why (#86): a save that resolves with a `SaveOutcome` succeeded with
|
|
11
|
+
* something the author must decide, the alert; one rejected with a `SaveRefusal` was refused for
|
|
12
|
+
* a reason the host explains, the error. Either carries the actions the author chooses from, and
|
|
13
|
+
* an action the author runs is followed like a save.
|
|
14
|
+
*/
|
|
15
|
+
/** One choice the host offers the author: its word, and what choosing it does. */
|
|
16
|
+
export interface SaveAction {
|
|
17
|
+
/** The button's word, in the reader's language: the host words it. */
|
|
18
|
+
readonly label: string;
|
|
19
|
+
/** Runs the choice; a promise answered is followed like a save, and may end in another notice. */
|
|
20
|
+
readonly run: () => Promise<void | SaveOutcome> | void;
|
|
21
|
+
}
|
|
22
|
+
/** What the host has to say about a save, and the choices it gives the author. */
|
|
23
|
+
export interface SaveNotice {
|
|
24
|
+
/** The dialog's title, and the status button's tooltip. */
|
|
25
|
+
readonly title: string;
|
|
26
|
+
/** The explanation in the dialog. */
|
|
27
|
+
readonly message: string;
|
|
28
|
+
readonly actions: readonly SaveAction[];
|
|
29
|
+
}
|
|
30
|
+
/** A save that succeeded with something the author must decide: what `onChange`'s promise resolves with. */
|
|
31
|
+
export type SaveOutcome = SaveNotice;
|
|
32
|
+
/** A save the host refused, with its reason and its choices: what `onChange`'s promise rejects with. */
|
|
33
|
+
export type SaveRefusal = SaveNotice;
|
|
34
|
+
export type EditSave = 'idle' | 'saving' | 'saved' | 'alert' | 'failed';
|
|
35
|
+
export interface EditSaves {
|
|
36
|
+
readonly state: EditSave;
|
|
37
|
+
/** What the host said of the save: present in `alert`, and in `failed` when it gave a reason. */
|
|
38
|
+
readonly notice: SaveNotice | null;
|
|
39
|
+
/** Hands a change to the host and follows its save. */
|
|
40
|
+
readonly hand: (markdown: string) => void;
|
|
41
|
+
/** Hands the latest change to the host again, after a save failed. */
|
|
42
|
+
readonly retry: () => void;
|
|
43
|
+
/** Runs one of the notice's actions and follows it like a save. */
|
|
44
|
+
readonly run: (action: SaveAction) => void;
|
|
45
|
+
}
|
|
46
|
+
/** Whether a value is a notice a host gave: a title, a message and its actions. */
|
|
47
|
+
export declare function isSaveNotice(value: unknown): value is SaveNotice;
|
|
48
|
+
export declare function useEditSaves(onChange: ((markdown: string) => unknown) | undefined): EditSaves;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the save of the author's edits stands (#64, #86).
|
|
3
|
+
*
|
|
4
|
+
* The host saves each change it is handed. When `onChange` answers with a promise, the editor
|
|
5
|
+
* follows it: saving while it is pending, saved once it resolves, not saved when it rejects. Only
|
|
6
|
+
* the latest change counts: a save that settles after a later change was handed says nothing, so
|
|
7
|
+
* "Saved" always means the last text the author wrote. A host that answers with nothing has no
|
|
8
|
+
* status, and the editor shows none.
|
|
9
|
+
*
|
|
10
|
+
* A host may also say why (#86): a save that resolves with a `SaveOutcome` succeeded with
|
|
11
|
+
* something the author must decide, the alert; one rejected with a `SaveRefusal` was refused for
|
|
12
|
+
* a reason the host explains, the error. Either carries the actions the author chooses from, and
|
|
13
|
+
* an action the author runs is followed like a save.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
16
|
+
import { isThenable } from '../task-saves.js';
|
|
17
|
+
/** Whether a value is a notice a host gave: a title, a message and its actions. */
|
|
18
|
+
export function isSaveNotice(value) {
|
|
19
|
+
if (typeof value !== 'object' || value === null)
|
|
20
|
+
return false;
|
|
21
|
+
const notice = value;
|
|
22
|
+
return (typeof notice.title === 'string' &&
|
|
23
|
+
typeof notice.message === 'string' &&
|
|
24
|
+
Array.isArray(notice.actions) &&
|
|
25
|
+
notice.actions.every((action) => typeof action === 'object' && action !== null && typeof action.label === 'string' && typeof action.run === 'function'));
|
|
26
|
+
}
|
|
27
|
+
export function useEditSaves(onChange) {
|
|
28
|
+
const [status, setStatus] = useState({ state: 'idle', notice: null });
|
|
29
|
+
const latest = useRef({ onChange, count: 0, text: null, alive: true });
|
|
30
|
+
latest.current.onChange = onChange;
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const self = latest.current;
|
|
33
|
+
self.alive = true;
|
|
34
|
+
return () => {
|
|
35
|
+
self.alive = false;
|
|
36
|
+
};
|
|
37
|
+
}, []);
|
|
38
|
+
/** Follows what the host answered, a change's save or an action; `done` is the state of an answer that is not a promise. */
|
|
39
|
+
const follow = useCallback((answer, done) => {
|
|
40
|
+
const self = latest.current;
|
|
41
|
+
const count = ++self.count;
|
|
42
|
+
const settle = (state, notice = null) => {
|
|
43
|
+
if (self.alive && count === self.count)
|
|
44
|
+
setStatus({ state, notice });
|
|
45
|
+
};
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
result = answer();
|
|
49
|
+
}
|
|
50
|
+
catch (reason) {
|
|
51
|
+
settle('failed', isSaveNotice(reason) ? reason : null);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!isThenable(result)) {
|
|
55
|
+
settle(done);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
settle('saving');
|
|
59
|
+
result.then((value) => (isSaveNotice(value) ? settle('alert', value) : settle('saved')), (reason) => settle('failed', isSaveNotice(reason) ? reason : null));
|
|
60
|
+
}, []);
|
|
61
|
+
const hand = useCallback((markdown) => {
|
|
62
|
+
latest.current.text = markdown;
|
|
63
|
+
follow(() => latest.current.onChange?.(markdown), 'idle');
|
|
64
|
+
}, [follow]);
|
|
65
|
+
const retry = useCallback(() => {
|
|
66
|
+
const text = latest.current.text;
|
|
67
|
+
if (text !== null)
|
|
68
|
+
hand(text);
|
|
69
|
+
}, [hand]);
|
|
70
|
+
// An action that answers nothing has done what it does: the author decided, and nothing is left to say.
|
|
71
|
+
const run = useCallback((action) => follow(() => action.run(), 'saved'), [follow]);
|
|
72
|
+
return { state: status.state, notice: status.notice, hand, retry, run };
|
|
73
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A table of a note, read as ranges of its bytes, and the edits the editor makes to it (#56).
|
|
3
|
+
*
|
|
4
|
+
* Every edit is a list of changes to the note: a cell's content replaced, a row inserted or
|
|
5
|
+
* removed, a column inserted or removed in every row, the delimiter cell of one column rewritten.
|
|
6
|
+
* A row an edit does not reach keeps its spacing and its pipes as written.
|
|
7
|
+
*/
|
|
8
|
+
import type { Span } from './analyze.js';
|
|
9
|
+
export type Alignment = 'left' | 'center' | 'right' | null;
|
|
10
|
+
export interface TableCell extends Span {
|
|
11
|
+
/** The content as written, trimmed: the range is the content's, without the padding. */
|
|
12
|
+
readonly text: string;
|
|
13
|
+
}
|
|
14
|
+
export interface TableRow extends Span {
|
|
15
|
+
readonly cells: readonly TableCell[];
|
|
16
|
+
}
|
|
17
|
+
export interface Table extends Span {
|
|
18
|
+
/** The note's text the table was read from. */
|
|
19
|
+
readonly source: string;
|
|
20
|
+
/** The header row, the delimiter row and the body rows, in order. */
|
|
21
|
+
readonly rows: readonly TableRow[];
|
|
22
|
+
readonly align: readonly Alignment[];
|
|
23
|
+
/** Whether the header was written with a leading and a trailing pipe, which a new row copies. */
|
|
24
|
+
readonly leadingPipe: boolean;
|
|
25
|
+
readonly trailingPipe: boolean;
|
|
26
|
+
/** What precedes the first cell of a row: a quotation's markers, or the indent of a list. */
|
|
27
|
+
readonly prefix: string;
|
|
28
|
+
}
|
|
29
|
+
export interface TextChange {
|
|
30
|
+
readonly from: number;
|
|
31
|
+
readonly to: number;
|
|
32
|
+
readonly insert: string;
|
|
33
|
+
}
|
|
34
|
+
/** The table whose rows run from the line holding `from` to the line holding `to`. */
|
|
35
|
+
export declare function readTable(source: string, from: number, to: number): Table;
|
|
36
|
+
/** A cell's content as written in a row: a pipe escaped, a line break a space. */
|
|
37
|
+
export declare function cellText(value: string): string;
|
|
38
|
+
/** How many columns the table has: the header's. */
|
|
39
|
+
export declare function columnCount(table: Table): number;
|
|
40
|
+
/** The delimiter cell of an alignment. */
|
|
41
|
+
export declare function delimiterOf(alignment: Alignment): string;
|
|
42
|
+
/**
|
|
43
|
+
* Replaces the content of one cell of the header (row 0) or of the body (row 2 on). A row with
|
|
44
|
+
* fewer cells gains the ones it lacks at its end, and nothing else of the table moves.
|
|
45
|
+
*/
|
|
46
|
+
export declare function setCell(table: Table, row: number, column: number, value: string): TextChange[];
|
|
47
|
+
/** Inserts an empty row after `row`; after the header, it goes after the delimiter row. */
|
|
48
|
+
export declare function addRow(table: Table, row: number): TextChange[];
|
|
49
|
+
/** Removes a body row, with the line break before it; the header and the delimiter stay. */
|
|
50
|
+
export declare function removeRow(table: Table, row: number): TextChange[];
|
|
51
|
+
/** Inserts an empty column after `column`, in every row that reaches it. */
|
|
52
|
+
export declare function addColumn(table: Table, column: number): TextChange[];
|
|
53
|
+
/** Removes a column from every row, with the pipe that opens it. */
|
|
54
|
+
export declare function removeColumn(table: Table, column: number): TextChange[];
|
|
55
|
+
/** Rewrites the delimiter cell of one column; every other byte of the table stays. */
|
|
56
|
+
export declare function alignColumn(table: Table, column: number, alignment: Alignment): TextChange[];
|
|
57
|
+
/** Applies changes that do not overlap to a text, for the tests and for a host without the editor. */
|
|
58
|
+
export declare function applyChanges(text: string, changes: readonly TextChange[]): string;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A table of a note, read as ranges of its bytes, and the edits the editor makes to it (#56).
|
|
3
|
+
*
|
|
4
|
+
* Every edit is a list of changes to the note: a cell's content replaced, a row inserted or
|
|
5
|
+
* removed, a column inserted or removed in every row, the delimiter cell of one column rewritten.
|
|
6
|
+
* A row an edit does not reach keeps its spacing and its pipes as written.
|
|
7
|
+
*/
|
|
8
|
+
const PREFIX = /^(?:[ \t]*>[ \t]?)*[ \t]*/;
|
|
9
|
+
/** The offsets of the pipes of a line that separate cells: not escaped, not inside a code span. */
|
|
10
|
+
function pipesOf(source, from, to) {
|
|
11
|
+
const pipes = [];
|
|
12
|
+
let code = 0;
|
|
13
|
+
for (let i = from; i < to; i++) {
|
|
14
|
+
const character = source[i];
|
|
15
|
+
if (character === '\\') {
|
|
16
|
+
i += 1;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (character === '`') {
|
|
20
|
+
let run = 1;
|
|
21
|
+
while (source[i + run] === '`')
|
|
22
|
+
run += 1;
|
|
23
|
+
code = code === run ? 0 : code === 0 ? run : code;
|
|
24
|
+
i += run - 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (character === '|' && code === 0)
|
|
28
|
+
pipes.push(i);
|
|
29
|
+
}
|
|
30
|
+
return pipes;
|
|
31
|
+
}
|
|
32
|
+
function rowOf(source, from, to) {
|
|
33
|
+
const start = from + (PREFIX.exec(source.slice(from, to))?.[0].length ?? 0);
|
|
34
|
+
const pipes = pipesOf(source, start, to);
|
|
35
|
+
const leading = pipes[0] === start;
|
|
36
|
+
const trailing = pipes.length > 0 && /^[ \t]*$/.test(source.slice(pipes[pipes.length - 1] + 1, to));
|
|
37
|
+
const bounds = [leading ? pipes[0] : start - 1, ...pipes.slice(leading ? 1 : 0)];
|
|
38
|
+
if (!trailing)
|
|
39
|
+
bounds.push(to);
|
|
40
|
+
const cells = [];
|
|
41
|
+
for (let i = 0; i + 1 < bounds.length; i++) {
|
|
42
|
+
const open = bounds[i] + 1;
|
|
43
|
+
const close = bounds[i + 1];
|
|
44
|
+
const raw = source.slice(open, close);
|
|
45
|
+
const text = raw.trim();
|
|
46
|
+
// An empty cell's range is one character in, where typing lands between its padding.
|
|
47
|
+
const at = text.length === 0 ? open + Math.min(1, raw.length) : open + (raw.length - raw.trimStart().length);
|
|
48
|
+
cells.push({ from: at, to: at + text.length, text });
|
|
49
|
+
}
|
|
50
|
+
return { from, to, cells, leading, trailing };
|
|
51
|
+
}
|
|
52
|
+
/** The table whose rows run from the line holding `from` to the line holding `to`. */
|
|
53
|
+
export function readTable(source, from, to) {
|
|
54
|
+
const rows = [];
|
|
55
|
+
let leadingPipe = true;
|
|
56
|
+
let trailingPipe = true;
|
|
57
|
+
let at = source.lastIndexOf('\n', from - 1) + 1;
|
|
58
|
+
const prefix = PREFIX.exec(source.slice(at, to))?.[0] ?? '';
|
|
59
|
+
while (at <= to) {
|
|
60
|
+
const newline = source.indexOf('\n', at);
|
|
61
|
+
const end = newline === -1 ? source.length : newline;
|
|
62
|
+
const row = rowOf(source, at, end);
|
|
63
|
+
if (rows.length === 0) {
|
|
64
|
+
leadingPipe = row.leading;
|
|
65
|
+
trailingPipe = row.trailing;
|
|
66
|
+
}
|
|
67
|
+
rows.push({ from: row.from, to: row.to, cells: row.cells });
|
|
68
|
+
if (newline === -1 || end >= to)
|
|
69
|
+
break;
|
|
70
|
+
at = end + 1;
|
|
71
|
+
}
|
|
72
|
+
const align = (rows[1]?.cells ?? []).map((cell) => {
|
|
73
|
+
const left = cell.text.startsWith(':');
|
|
74
|
+
const right = cell.text.endsWith(':');
|
|
75
|
+
return left && right ? 'center' : right ? 'right' : left ? 'left' : null;
|
|
76
|
+
});
|
|
77
|
+
return { source, from: rows[0].from, to: rows[rows.length - 1].to, rows, align, leadingPipe, trailingPipe, prefix };
|
|
78
|
+
}
|
|
79
|
+
/** A cell's content as written in a row: a pipe escaped, a line break a space. */
|
|
80
|
+
export function cellText(value) {
|
|
81
|
+
return value
|
|
82
|
+
.replace(/\r?\n/g, ' ')
|
|
83
|
+
.replace(/(?<!\\)\|/g, '\\|')
|
|
84
|
+
.trim();
|
|
85
|
+
}
|
|
86
|
+
/** How many columns the table has: the header's. */
|
|
87
|
+
export function columnCount(table) {
|
|
88
|
+
return Math.max(table.rows[0]?.cells.length ?? 1, 1);
|
|
89
|
+
}
|
|
90
|
+
function rowText(table, cells) {
|
|
91
|
+
const inner = cells.map((cell) => ` ${cell} `).join('|');
|
|
92
|
+
return `${table.prefix}${table.leadingPipe ? '|' : ''}${inner}${table.trailingPipe ? '|' : ''}`;
|
|
93
|
+
}
|
|
94
|
+
/** The delimiter cell of an alignment. */
|
|
95
|
+
export function delimiterOf(alignment) {
|
|
96
|
+
return alignment === 'center' ? ':---:' : alignment === 'right' ? '---:' : alignment === 'left' ? ':---' : '---';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Replaces the content of one cell of the header (row 0) or of the body (row 2 on). A row with
|
|
100
|
+
* fewer cells gains the ones it lacks at its end, and nothing else of the table moves.
|
|
101
|
+
*/
|
|
102
|
+
export function setCell(table, row, column, value) {
|
|
103
|
+
const target = table.rows[row];
|
|
104
|
+
if (!target || row === 1)
|
|
105
|
+
return [];
|
|
106
|
+
const insert = cellText(value);
|
|
107
|
+
const cell = target.cells[column];
|
|
108
|
+
if (cell)
|
|
109
|
+
return cell.text === insert ? [] : [{ from: cell.from, to: cell.to, insert }];
|
|
110
|
+
const missing = Array.from({ length: column - target.cells.length + 1 }, (_, index) => index === column - target.cells.length ? insert : '');
|
|
111
|
+
const written = table.source.slice(target.from, target.to);
|
|
112
|
+
const closed = /\|[ \t]*$/.test(written);
|
|
113
|
+
const text = missing.map((each) => ` ${each} |`).join('');
|
|
114
|
+
return [{ from: target.to, to: target.to, insert: closed ? text : `|${text.slice(0, -1)}` }];
|
|
115
|
+
}
|
|
116
|
+
/** Inserts an empty row after `row`; after the header, it goes after the delimiter row. */
|
|
117
|
+
export function addRow(table, row) {
|
|
118
|
+
const after = table.rows[Math.max(row, 1)];
|
|
119
|
+
if (!after)
|
|
120
|
+
return [];
|
|
121
|
+
return [{ from: after.to, to: after.to, insert: `\n${rowText(table, Array.from({ length: columnCount(table) }, () => ''))}` }];
|
|
122
|
+
}
|
|
123
|
+
/** Removes a body row, with the line break before it; the header and the delimiter stay. */
|
|
124
|
+
export function removeRow(table, row) {
|
|
125
|
+
const target = table.rows[row];
|
|
126
|
+
if (!target || row < 2)
|
|
127
|
+
return [];
|
|
128
|
+
return [{ from: table.rows[row - 1].to, to: target.to, insert: '' }];
|
|
129
|
+
}
|
|
130
|
+
function pipeAfter(table, row, at) {
|
|
131
|
+
return pipesOf(table.source, at, row.to)[0] ?? null;
|
|
132
|
+
}
|
|
133
|
+
function pipeBefore(table, row, at) {
|
|
134
|
+
const pipes = pipesOf(table.source, row.from, at);
|
|
135
|
+
return pipes.length > 0 ? pipes[pipes.length - 1] : null;
|
|
136
|
+
}
|
|
137
|
+
/** Inserts an empty column after `column`, in every row that reaches it. */
|
|
138
|
+
export function addColumn(table, column) {
|
|
139
|
+
return table.rows.flatMap((row, index) => {
|
|
140
|
+
const cell = row.cells[Math.min(column, row.cells.length - 1)];
|
|
141
|
+
if (!cell)
|
|
142
|
+
return [];
|
|
143
|
+
const content = index === 1 ? ' --- ' : ' ';
|
|
144
|
+
const pipe = pipeAfter(table, row, cell.to);
|
|
145
|
+
if (pipe !== null)
|
|
146
|
+
return [{ from: pipe + 1, to: pipe + 1, insert: `${content}|` }];
|
|
147
|
+
// A row written with no closing pipe: a body row is short and draws the cell empty, but the
|
|
148
|
+
// header and the delimiter have to name it, so they gain one.
|
|
149
|
+
if (index > 1)
|
|
150
|
+
return [];
|
|
151
|
+
return [{ from: row.to, to: row.to, insert: ` |${content}|` }];
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** Removes a column from every row, with the pipe that opens it. */
|
|
155
|
+
export function removeColumn(table, column) {
|
|
156
|
+
if (columnCount(table) < 2)
|
|
157
|
+
return [];
|
|
158
|
+
return table.rows.flatMap((row) => {
|
|
159
|
+
const cell = row.cells[column];
|
|
160
|
+
if (!cell)
|
|
161
|
+
return [];
|
|
162
|
+
const open = pipeBefore(table, row, cell.from);
|
|
163
|
+
const close = pipeAfter(table, row, cell.to);
|
|
164
|
+
const start = row.from + (PREFIX.exec(table.source.slice(row.from, row.to))?.[0].length ?? 0);
|
|
165
|
+
if (open === null || open < start) {
|
|
166
|
+
// The first cell of a row with no leading pipe: it goes with the pipe that closes it.
|
|
167
|
+
return close === null ? [] : [{ from: start, to: close + 1, insert: '' }];
|
|
168
|
+
}
|
|
169
|
+
return [{ from: open, to: close ?? row.to, insert: '' }];
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** Rewrites the delimiter cell of one column; every other byte of the table stays. */
|
|
173
|
+
export function alignColumn(table, column, alignment) {
|
|
174
|
+
const cell = table.rows[1]?.cells[column];
|
|
175
|
+
if (!cell)
|
|
176
|
+
return [];
|
|
177
|
+
const insert = delimiterOf(alignment);
|
|
178
|
+
return cell.text === insert ? [] : [{ from: cell.from, to: cell.to, insert }];
|
|
179
|
+
}
|
|
180
|
+
/** Applies changes that do not overlap to a text, for the tests and for a host without the editor. */
|
|
181
|
+
export function applyChanges(text, changes) {
|
|
182
|
+
return [...changes]
|
|
183
|
+
.sort((a, b) => b.from - a.from)
|
|
184
|
+
.reduce((out, change) => out.slice(0, change.from) + change.insert + out.slice(change.to), text);
|
|
185
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a file the notebook keeps is drawn, by its media type and never by its name (SPEC.md §5.8,
|
|
3
|
+
* #77). The list is MemorySmith's closed one — *observed* at `4b67c7e`, `FILE_TYPES` in its
|
|
4
|
+
* contracts — sixteen types, each with the shape a reading surface draws it in: what a browser
|
|
5
|
+
* draws without running anything is drawn, an image, an audio, a video, and everything else is a
|
|
6
|
+
* card, the PDF included. A type outside the list is a card too, named by its media type.
|
|
7
|
+
*/
|
|
8
|
+
export type FileShape = 'image' | 'audio' | 'video' | 'card';
|
|
9
|
+
interface FileType {
|
|
10
|
+
readonly mimeType: string;
|
|
11
|
+
readonly renders: FileShape;
|
|
12
|
+
/** Whether a browser shows a card's file on its own, so the card may offer to open it: the PDF. */
|
|
13
|
+
readonly opens?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare const FILE_TYPES: readonly FileType[];
|
|
16
|
+
/** The shape a type is drawn in: `card` for anything this surface does not draw. */
|
|
17
|
+
export declare function rendersAs(mimeType: string): FileShape;
|
|
18
|
+
/** Whether a browser shows the file on its own: everything drawn, and a card's only when its type says so. */
|
|
19
|
+
export declare function opensInBrowser(mimeType: string): boolean;
|
|
20
|
+
/** What a type is called in the reader's words, or its media type when it is not on the list or has none. */
|
|
21
|
+
export declare function fileKind(mimeType: string, words: Readonly<Record<string, string>>): string;
|
|
22
|
+
/** A size as MemorySmith writes it, in the reader's locale: bytes, then kB, MB and GB in steps of 1024. */
|
|
23
|
+
export declare function formatBytes(bytes: number, locale: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* The address of a file's bytes as it will be written, or the empty string when this surface will
|
|
26
|
+
* not follow it: a path, the web, a `blob:` the host made, or the bytes themselves as `data:` —
|
|
27
|
+
* never a scheme that runs something, nor `data:` of a type a browser runs as a page or a script.
|
|
28
|
+
*/
|
|
29
|
+
export declare function fileAddress(url: string): string;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a file the notebook keeps is drawn, by its media type and never by its name (SPEC.md §5.8,
|
|
3
|
+
* #77). The list is MemorySmith's closed one — *observed* at `4b67c7e`, `FILE_TYPES` in its
|
|
4
|
+
* contracts — sixteen types, each with the shape a reading surface draws it in: what a browser
|
|
5
|
+
* draws without running anything is drawn, an image, an audio, a video, and everything else is a
|
|
6
|
+
* card, the PDF included. A type outside the list is a card too, named by its media type.
|
|
7
|
+
*/
|
|
8
|
+
export const FILE_TYPES = [
|
|
9
|
+
{ mimeType: 'image/png', renders: 'image' },
|
|
10
|
+
{ mimeType: 'image/jpeg', renders: 'image' },
|
|
11
|
+
{ mimeType: 'image/webp', renders: 'image' },
|
|
12
|
+
{ mimeType: 'image/gif', renders: 'image' },
|
|
13
|
+
{ mimeType: 'image/svg+xml', renders: 'image' },
|
|
14
|
+
{ mimeType: 'application/pdf', renders: 'card', opens: true },
|
|
15
|
+
{ mimeType: 'audio/mpeg', renders: 'audio' },
|
|
16
|
+
{ mimeType: 'audio/wav', renders: 'audio' },
|
|
17
|
+
{ mimeType: 'audio/ogg', renders: 'audio' },
|
|
18
|
+
{ mimeType: 'audio/webm', renders: 'audio' },
|
|
19
|
+
{ mimeType: 'video/mp4', renders: 'video' },
|
|
20
|
+
{ mimeType: 'video/webm', renders: 'video' },
|
|
21
|
+
{ mimeType: 'video/quicktime', renders: 'video' },
|
|
22
|
+
{ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', renders: 'card' },
|
|
23
|
+
{ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', renders: 'card' },
|
|
24
|
+
{ mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', renders: 'card' },
|
|
25
|
+
];
|
|
26
|
+
function typeOf(mimeType) {
|
|
27
|
+
return FILE_TYPES.find((each) => each.mimeType === mimeType.trim().toLowerCase()) ?? null;
|
|
28
|
+
}
|
|
29
|
+
/** The shape a type is drawn in: `card` for anything this surface does not draw. */
|
|
30
|
+
export function rendersAs(mimeType) {
|
|
31
|
+
return typeOf(mimeType)?.renders ?? 'card';
|
|
32
|
+
}
|
|
33
|
+
/** Whether a browser shows the file on its own: everything drawn, and a card's only when its type says so. */
|
|
34
|
+
export function opensInBrowser(mimeType) {
|
|
35
|
+
const type = typeOf(mimeType);
|
|
36
|
+
if (!type)
|
|
37
|
+
return false;
|
|
38
|
+
return type.renders === 'card' ? type.opens === true : true;
|
|
39
|
+
}
|
|
40
|
+
/** What a type is called in the reader's words, or its media type when it is not on the list or has none. */
|
|
41
|
+
export function fileKind(mimeType, words) {
|
|
42
|
+
const type = typeOf(mimeType);
|
|
43
|
+
return type ? (words[type.mimeType] ?? mimeType) : mimeType;
|
|
44
|
+
}
|
|
45
|
+
/** A size as MemorySmith writes it, in the reader's locale: bytes, then kB, MB and GB in steps of 1024. */
|
|
46
|
+
export function formatBytes(bytes, locale) {
|
|
47
|
+
const units = [
|
|
48
|
+
{ limit: 1024, divisor: 1, unit: 'byte', digits: 0 },
|
|
49
|
+
{ limit: 1024 ** 2, divisor: 1024, unit: 'kilobyte', digits: 0 },
|
|
50
|
+
{ limit: 1024 ** 3, divisor: 1024 ** 2, unit: 'megabyte', digits: bytes >= 1024 ** 2 * 10 ? 0 : 1 },
|
|
51
|
+
{ limit: Infinity, divisor: 1024 ** 3, unit: 'gigabyte', digits: 1 },
|
|
52
|
+
];
|
|
53
|
+
const scale = units.find((each) => bytes < each.limit) ?? units[units.length - 1];
|
|
54
|
+
return new Intl.NumberFormat(locale, { style: 'unit', unit: scale.unit, unitDisplay: 'short', maximumFractionDigits: scale.digits }).format(bytes / scale.divisor);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The address of a file's bytes as it will be written, or the empty string when this surface will
|
|
58
|
+
* not follow it: a path, the web, a `blob:` the host made, or the bytes themselves as `data:` —
|
|
59
|
+
* never a scheme that runs something, nor `data:` of a type a browser runs as a page or a script.
|
|
60
|
+
*/
|
|
61
|
+
export function fileAddress(url) {
|
|
62
|
+
const trimmed = url.trim();
|
|
63
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed))
|
|
64
|
+
return url;
|
|
65
|
+
if (/^(https?|blob):/i.test(trimmed))
|
|
66
|
+
return url;
|
|
67
|
+
if (/^data:/i.test(trimmed) && !/^data:\s*(text\/html|application\/xhtml\+xml|text\/xml|application\/xml|text\/javascript|application\/javascript)/i.test(trimmed))
|
|
68
|
+
return url;
|
|
69
|
+
return '';
|
|
70
|
+
}
|
package/dist/highlight.d.ts
CHANGED
|
@@ -33,15 +33,29 @@
|
|
|
33
33
|
* input. `refractor` produces a hast tree, which is converted to React
|
|
34
34
|
* elements, so nothing is ever parsed as markup.
|
|
35
35
|
*
|
|
36
|
-
* **
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
36
|
+
* **Fifteen languages are there at once, and every other one when it is used.**
|
|
37
|
+
* The fifteen are what a notebook of MemorySmith plausibly holds — the languages
|
|
38
|
+
* of its own repository, the two query languages, the three configuration
|
|
39
|
+
* formats and the two shells — MemorySmith's own list, drawn highlighted on the
|
|
40
|
+
* first paint, a server's included. Every other grammar Prism carries is listed
|
|
41
|
+
* in `prism-grammars.ts` behind a dynamic import (#84): a fence that names one
|
|
42
|
+
* is plain code until its grammar arrives, then highlighted, and a page never
|
|
43
|
+
* downloads a grammar no note of it uses.
|
|
43
44
|
*/
|
|
44
45
|
import type { Root } from 'hast';
|
|
46
|
+
import { type Grammar } from './prism-grammars.js';
|
|
47
|
+
/** The grammar a fence's info string names, by identifier or alias, or `null`: never `mermaid`'s. */
|
|
48
|
+
export declare function grammarOf(name: string | null | undefined): Grammar | null;
|
|
49
|
+
/**
|
|
50
|
+
* Registers the grammar a name points at, fetching it the first time; answers whether it is
|
|
51
|
+
* registered. The fifteen above are already, and answer at once.
|
|
52
|
+
*/
|
|
53
|
+
export declare function loadGrammar(name: string): Promise<boolean>;
|
|
54
|
+
/**
|
|
55
|
+
* The language of a fence that a grammar answers but that is not registered yet, which
|
|
56
|
+
* `loadGrammar` fetches; `null` when there is none to fetch.
|
|
57
|
+
*/
|
|
58
|
+
export declare function languageToLoad(className: string | undefined): string | null;
|
|
45
59
|
/**
|
|
46
60
|
* The language of a fenced block, from the class react-markdown puts on it.
|
|
47
61
|
*
|
package/dist/highlight.js
CHANGED
|
@@ -33,13 +33,14 @@
|
|
|
33
33
|
* input. `refractor` produces a hast tree, which is converted to React
|
|
34
34
|
* elements, so nothing is ever parsed as markup.
|
|
35
35
|
*
|
|
36
|
-
* **
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
36
|
+
* **Fifteen languages are there at once, and every other one when it is used.**
|
|
37
|
+
* The fifteen are what a notebook of MemorySmith plausibly holds — the languages
|
|
38
|
+
* of its own repository, the two query languages, the three configuration
|
|
39
|
+
* formats and the two shells — MemorySmith's own list, drawn highlighted on the
|
|
40
|
+
* first paint, a server's included. Every other grammar Prism carries is listed
|
|
41
|
+
* in `prism-grammars.ts` behind a dynamic import (#84): a fence that names one
|
|
42
|
+
* is plain code until its grammar arrives, then highlighted, and a page never
|
|
43
|
+
* downloads a grammar no note of it uses.
|
|
43
44
|
*/
|
|
44
45
|
import { refractor } from 'refractor/core';
|
|
45
46
|
import bash from 'refractor/bash';
|
|
@@ -57,6 +58,7 @@ import toml from 'refractor/toml';
|
|
|
57
58
|
import tsx from 'refractor/tsx';
|
|
58
59
|
import typescript from 'refractor/typescript';
|
|
59
60
|
import yaml from 'refractor/yaml';
|
|
61
|
+
import { GRAMMARS } from './prism-grammars.js';
|
|
60
62
|
for (const language of [
|
|
61
63
|
bash,
|
|
62
64
|
css,
|
|
@@ -76,6 +78,53 @@ for (const language of [
|
|
|
76
78
|
]) {
|
|
77
79
|
refractor.register(language);
|
|
78
80
|
}
|
|
81
|
+
// ── Every other grammar, loaded when a note names it (#84) ─────────────────────
|
|
82
|
+
/** Each grammar by its identifier and by each alias, lower case. */
|
|
83
|
+
const BY_NAME = new Map();
|
|
84
|
+
for (const grammar of GRAMMARS) {
|
|
85
|
+
for (const name of [grammar.id, ...grammar.aliases])
|
|
86
|
+
BY_NAME.set(name.toLowerCase(), grammar);
|
|
87
|
+
}
|
|
88
|
+
/** The grammar a fence's info string names, by identifier or alias, or `null`: never `mermaid`'s. */
|
|
89
|
+
export function grammarOf(name) {
|
|
90
|
+
const named = name?.trim().toLowerCase();
|
|
91
|
+
if (!named || named === 'mermaid')
|
|
92
|
+
return null;
|
|
93
|
+
return BY_NAME.get(named) ?? null;
|
|
94
|
+
}
|
|
95
|
+
const loading = new Map();
|
|
96
|
+
/**
|
|
97
|
+
* Registers the grammar a name points at, fetching it the first time; answers whether it is
|
|
98
|
+
* registered. The fifteen above are already, and answer at once.
|
|
99
|
+
*/
|
|
100
|
+
export function loadGrammar(name) {
|
|
101
|
+
const grammar = grammarOf(name);
|
|
102
|
+
if (!grammar)
|
|
103
|
+
return Promise.resolve(false);
|
|
104
|
+
if (refractor.registered(grammar.id))
|
|
105
|
+
return Promise.resolve(true);
|
|
106
|
+
let pending = loading.get(grammar.id);
|
|
107
|
+
if (!pending) {
|
|
108
|
+
pending = grammar.load().then((module) => {
|
|
109
|
+
refractor.register(module.default);
|
|
110
|
+
return true;
|
|
111
|
+
}, () => {
|
|
112
|
+
loading.delete(grammar.id);
|
|
113
|
+
return false;
|
|
114
|
+
});
|
|
115
|
+
loading.set(grammar.id, pending);
|
|
116
|
+
}
|
|
117
|
+
return pending;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The language of a fence that a grammar answers but that is not registered yet, which
|
|
121
|
+
* `loadGrammar` fetches; `null` when there is none to fetch.
|
|
122
|
+
*/
|
|
123
|
+
export function languageToLoad(className) {
|
|
124
|
+
const match = /(?:^|\s)language-([A-Za-z0-9#+._-]+)/.exec(className ?? '');
|
|
125
|
+
const grammar = grammarOf(match?.[1]);
|
|
126
|
+
return grammar && !refractor.registered(grammar.id) ? grammar.id : null;
|
|
127
|
+
}
|
|
79
128
|
/**
|
|
80
129
|
* The language of a fenced block, from the class react-markdown puts on it.
|
|
81
130
|
*
|