@simple-mark/model 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.
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/index.d.ts +116 -0
- package/index.js +508 -0
- package/package.json +26 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 one-prime
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @simple-mark/model
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Markdown model behind [SimpleMark](https://github.com/one-prime/simple-mark): Markdown
|
|
4
|
+
that never hides its syntax. Pure TypeScript, no DOM — usable in a browser, a worker, Node
|
|
5
|
+
or a canvas renderer.
|
|
6
|
+
|
|
7
|
+
- Parses CommonMark + GFM + `$math$` (Lezer) into **styled ranges**: line styles (headings,
|
|
8
|
+
quotes, code blocks, tables, …) and non-overlapping inline spans (strong, em, code, link,
|
|
9
|
+
and `mark` for the syntax characters, which stay visible and dimmed).
|
|
10
|
+
- A **theme** as plain data, and `resolveLook` to turn styles into font/color properties.
|
|
11
|
+
- **Tables**: display width (Korean, Chinese, Japanese count as two columns), row splitting,
|
|
12
|
+
and re-aligning a table by padding its cells.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { markdownParser, markdownStyles, resolveLook, defaultTheme, parseTable, renderTable } from '@simple-mark/model';
|
|
16
|
+
|
|
17
|
+
const { blocks, spans } = markdownStyles(markdownParser.parse('# Hi **there**'));
|
|
18
|
+
const look = resolveLook(['h1'], ['strong'], defaultTheme);
|
|
19
|
+
const aligned = renderTable(parseTable(['|a|b|', '|-|-:|', '|한글|2|'])).lines;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { MarkdownExtension } from "@lezer/markdown";
|
|
2
|
+
import { Tree } from "@lezer/common";
|
|
3
|
+
//#region packages/model/src/parser.d.ts
|
|
4
|
+
/** The Markdown flavor every SimpleMark renderer parses: CommonMark + GFM + $math$. */
|
|
5
|
+
export declare const markdownExtensions: MarkdownExtension[];
|
|
6
|
+
/** A Lezer parser for that flavor, for hosts without CodeMirror (e.g. a canvas renderer). */
|
|
7
|
+
export declare const markdownParser: import("@lezer/markdown").MarkdownParser;
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region packages/model/src/styles.d.ts
|
|
10
|
+
/** Styles of whole lines, in precedence order (later wins when two set the same property). */
|
|
11
|
+
export declare const BLOCK_STYLES: readonly ['quote', 'codeblock', 'mathblock', 'table', 'tableHeader', 'tableRowAlt', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
|
12
|
+
type BlockStyle = (typeof BLOCK_STYLES)[number];
|
|
13
|
+
/** Styles of text ranges, in precedence order ("mark" last, so markers stay dimmed everywhere). */
|
|
14
|
+
export declare const INLINE_STYLES: readonly ['link', 'strong', 'em', 'strike', 'code', 'math', 'url', 'mark'];
|
|
15
|
+
type InlineStyle = (typeof INLINE_STYLES)[number];
|
|
16
|
+
/** A block construct; every line it touches gets `style`. Blocks may nest (a heading in a quote). */
|
|
17
|
+
interface BlockRange {
|
|
18
|
+
from: number;
|
|
19
|
+
to: number;
|
|
20
|
+
style: BlockStyle;
|
|
21
|
+
}
|
|
22
|
+
/** A run of text with the same inline styles. Spans never overlap and are sorted by position. */
|
|
23
|
+
interface Span {
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
/** In `INLINE_STYLES` order. */
|
|
27
|
+
styles: InlineStyle[];
|
|
28
|
+
}
|
|
29
|
+
interface MarkdownStyles {
|
|
30
|
+
blocks: BlockRange[];
|
|
31
|
+
spans: Span[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Styles for the part of `tree` between `from` and `to` (a viewport). The range grows to whole
|
|
35
|
+
* top-level blocks, so constructs that cross its edges keep both their markers.
|
|
36
|
+
*/
|
|
37
|
+
export declare function markdownStyles(tree: Tree, from?: number, to?: number): MarkdownStyles;
|
|
38
|
+
/** Overlapping styled ranges as sorted, non-overlapping spans; equal neighbours are merged. */
|
|
39
|
+
export declare function flattenSpans(ranges: readonly {
|
|
40
|
+
from: number;
|
|
41
|
+
to: number;
|
|
42
|
+
style: InlineStyle;
|
|
43
|
+
}[]): Span[];
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region packages/model/src/table.d.ts
|
|
46
|
+
/** Columns a character takes in a monospace font: 0, 1 or 2. */
|
|
47
|
+
export declare function charWidth(codePoint: number): 0 | 1 | 2;
|
|
48
|
+
/** Columns `text` takes in a monospace font ("지민" → 4). */
|
|
49
|
+
export declare function displayWidth(text: string): number;
|
|
50
|
+
type ColumnAlign = 'none' | 'left' | 'center' | 'right';
|
|
51
|
+
/** A cell of a row: the raw text between two pipes (spaces included). */
|
|
52
|
+
interface RowCell {
|
|
53
|
+
from: number;
|
|
54
|
+
to: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The cells of a table row. Pipes split cells unless escaped (`\|`); outer pipes are
|
|
58
|
+
* optional, as in GFM.
|
|
59
|
+
*/
|
|
60
|
+
export declare function splitRow(row: string): RowCell[];
|
|
61
|
+
/** The index of the cell containing `offset` in `row` (outer pipes count toward their cell). */
|
|
62
|
+
export declare function cellAt(row: string, offset: number): number;
|
|
63
|
+
/** A table's content: the delimiter row becomes `aligns`; `rows[0]` is the header. */
|
|
64
|
+
interface Table {
|
|
65
|
+
aligns: ColumnAlign[];
|
|
66
|
+
/** Trimmed cell texts. Rows may hold more cells than columns; the extra text is kept. */
|
|
67
|
+
rows: string[][];
|
|
68
|
+
}
|
|
69
|
+
/** Reads table lines (header, delimiter, body rows) without any line prefix such as "> ". */
|
|
70
|
+
export declare function parseTable(lines: readonly string[]): Table;
|
|
71
|
+
/** The line of `lines` that holds table row `row` (the delimiter line sits between rows 0 and 1). */
|
|
72
|
+
export declare function rowLine(row: number): number;
|
|
73
|
+
interface RenderedTable {
|
|
74
|
+
lines: string[];
|
|
75
|
+
/** Where each cell's text lies in its line: `cells[row][col]`, row as in `Table.rows`. */
|
|
76
|
+
cells: RowCell[][];
|
|
77
|
+
}
|
|
78
|
+
/** The table as aligned lines: `| cell | cell |`, columns padded to their widest cell. */
|
|
79
|
+
export declare function renderTable(table: Table): RenderedTable;
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region packages/model/src/theme.d.ts
|
|
82
|
+
/** Text properties. Unset properties are inherited (from the line, then the base text). */
|
|
83
|
+
interface Look {
|
|
84
|
+
/** Font size relative to the base size (1 = unchanged). Meant for block styles. */
|
|
85
|
+
fontScale?: number;
|
|
86
|
+
/** 100–900, as in CSS. */
|
|
87
|
+
fontWeight?: number;
|
|
88
|
+
italic?: boolean;
|
|
89
|
+
monospace?: boolean;
|
|
90
|
+
color?: string;
|
|
91
|
+
/** Behind the styled text only (e.g. inline code). */
|
|
92
|
+
background?: string;
|
|
93
|
+
strike?: boolean;
|
|
94
|
+
underline?: boolean;
|
|
95
|
+
}
|
|
96
|
+
/** Properties of whole lines. */
|
|
97
|
+
interface LineLook extends Look {
|
|
98
|
+
/** Behind the full width of the line (e.g. code blocks). */
|
|
99
|
+
lineBackground?: string;
|
|
100
|
+
/** A bar along the left edge of the line (e.g. quotes). */
|
|
101
|
+
borderLeft?: string;
|
|
102
|
+
}
|
|
103
|
+
interface Theme {
|
|
104
|
+
fontFamily: string;
|
|
105
|
+
monospaceFamily: string;
|
|
106
|
+
color: string;
|
|
107
|
+
block: Record<BlockStyle, LineLook>;
|
|
108
|
+
inline: Record<InlineStyle, Look>;
|
|
109
|
+
}
|
|
110
|
+
export declare const defaultTheme: Theme;
|
|
111
|
+
/** The look of a line with these block styles (any order). */
|
|
112
|
+
export declare function resolveLineLook(blocks: Iterable<BlockStyle>, theme?: Theme): LineLook;
|
|
113
|
+
/** The look of text with these block and inline styles (any order). */
|
|
114
|
+
export declare function resolveLook(blocks: Iterable<BlockStyle>, inline: Iterable<InlineStyle>, theme?: Theme): Look;
|
|
115
|
+
//#endregion
|
|
116
|
+
export type { BlockRange, BlockStyle, ColumnAlign, InlineStyle, LineLook, Look, MarkdownStyles, RenderedTable, RowCell, Span, Table, Theme };
|
package/index.js
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { GFM, parser } from "@lezer/markdown";
|
|
2
|
+
//#region packages/model/src/math-syntax.ts
|
|
3
|
+
const DOLLAR = 36;
|
|
4
|
+
const BACKSLASH = 92;
|
|
5
|
+
const isSpace = (ch) => ch === 32 || ch === 9 || ch === 10 || ch === 13;
|
|
6
|
+
const isDigit = (ch) => ch >= 48 && ch <= 57;
|
|
7
|
+
/**
|
|
8
|
+
* Inline math: an opening `$` not followed by a space, a closing `$` not preceded by a space
|
|
9
|
+
* nor followed by a digit ("$5 and $10" stays text). `$$...$$` works the same way.
|
|
10
|
+
*/
|
|
11
|
+
const inlineMath = {
|
|
12
|
+
name: "InlineMath",
|
|
13
|
+
before: "InlineCode",
|
|
14
|
+
parse(cx, next, start) {
|
|
15
|
+
if (next !== DOLLAR) return -1;
|
|
16
|
+
const size = cx.char(start + 1) === DOLLAR ? 2 : 1;
|
|
17
|
+
const contentStart = start + size;
|
|
18
|
+
if (contentStart >= cx.end || isSpace(cx.char(contentStart)) || cx.char(contentStart) === DOLLAR) return -1;
|
|
19
|
+
for (let pos = contentStart; pos < cx.end; pos++) {
|
|
20
|
+
const ch = cx.char(pos);
|
|
21
|
+
if (ch === BACKSLASH) {
|
|
22
|
+
pos++;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (ch !== DOLLAR) continue;
|
|
26
|
+
if (!(size === 1 || cx.char(pos + 1) === DOLLAR) || isSpace(cx.char(pos - 1))) {
|
|
27
|
+
if (size === 2) pos++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const end = pos + size;
|
|
31
|
+
if (size === 1 && isDigit(cx.char(end))) continue;
|
|
32
|
+
return cx.addElement(cx.elt("InlineMath", start, end, [cx.elt("MathMark", start, contentStart), cx.elt("MathMark", pos, end)]));
|
|
33
|
+
}
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** Where `$$` opens a block: at the line's content start, not indented as code. */
|
|
38
|
+
function opensBlock(line) {
|
|
39
|
+
return line.next === DOLLAR && line.text.charCodeAt(line.pos + 1) === DOLLAR && line.indent - line.baseIndent < 4;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether `line` still belongs to every container (quote, list item) around the block. Lezer
|
|
43
|
+
* sets `depth` on each line but leaves it out of its types; its own fenced code checks it the
|
|
44
|
+
* same way. A quote ending ends the math block, as it ends a code block.
|
|
45
|
+
*/
|
|
46
|
+
function inContainers(cx, line) {
|
|
47
|
+
const depth = line.depth;
|
|
48
|
+
return depth === void 0 || depth >= cx.depth;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region packages/model/src/parser.ts
|
|
52
|
+
/** The Markdown flavor every SimpleMark renderer parses: CommonMark + GFM + $math$. */
|
|
53
|
+
const markdownExtensions = [GFM, {
|
|
54
|
+
defineNodes: [
|
|
55
|
+
{
|
|
56
|
+
name: "BlockMath",
|
|
57
|
+
block: true
|
|
58
|
+
},
|
|
59
|
+
{ name: "InlineMath" },
|
|
60
|
+
{ name: "MathMark" },
|
|
61
|
+
{ name: "MathText" }
|
|
62
|
+
],
|
|
63
|
+
parseBlock: [{
|
|
64
|
+
name: "BlockMath",
|
|
65
|
+
before: "FencedCode",
|
|
66
|
+
parse(cx, line) {
|
|
67
|
+
if (!opensBlock(line)) return false;
|
|
68
|
+
const start = cx.lineStart + line.pos;
|
|
69
|
+
const children = [cx.elt("MathMark", start, start + 2)];
|
|
70
|
+
const first = line.text.slice(line.pos + 2);
|
|
71
|
+
const sameLine = /^(.*\S.*?)\$\$\s*$/.exec(first);
|
|
72
|
+
if (sameLine) {
|
|
73
|
+
const close = start + 2 + sameLine[1].length;
|
|
74
|
+
children.push(cx.elt("MathText", start + 2, close), cx.elt("MathMark", close, close + 2));
|
|
75
|
+
cx.nextLine();
|
|
76
|
+
cx.addElement(cx.elt("BlockMath", start, close + 2, children));
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (first.trim()) children.push(cx.elt("MathText", start + 2, cx.lineStart + line.text.length));
|
|
80
|
+
let end = cx.lineStart + line.text.length;
|
|
81
|
+
while (cx.nextLine() && inContainers(cx, line)) {
|
|
82
|
+
children.push(...line.markers);
|
|
83
|
+
const text = line.text.slice(line.pos);
|
|
84
|
+
const textStart = cx.lineStart + line.pos;
|
|
85
|
+
const close = text.indexOf("$$");
|
|
86
|
+
if (close >= 0 && !text.slice(close + 2).trim()) {
|
|
87
|
+
if (close > 0) children.push(cx.elt("MathText", textStart, textStart + close));
|
|
88
|
+
children.push(cx.elt("MathMark", textStart + close, textStart + close + 2));
|
|
89
|
+
end = textStart + close + 2;
|
|
90
|
+
cx.nextLine();
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (text) children.push(cx.elt("MathText", textStart, cx.lineStart + line.text.length));
|
|
94
|
+
end = cx.lineStart + line.text.length;
|
|
95
|
+
}
|
|
96
|
+
cx.addElement(cx.elt("BlockMath", start, end, children));
|
|
97
|
+
return true;
|
|
98
|
+
},
|
|
99
|
+
endLeaf: (_cx, line) => opensBlock(line)
|
|
100
|
+
}],
|
|
101
|
+
parseInline: [inlineMath]
|
|
102
|
+
}];
|
|
103
|
+
/** A Lezer parser for that flavor, for hosts without CodeMirror (e.g. a canvas renderer). */
|
|
104
|
+
const markdownParser = parser.configure(markdownExtensions);
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region packages/model/src/styles.ts
|
|
107
|
+
/** Styles of whole lines, in precedence order (later wins when two set the same property). */
|
|
108
|
+
const BLOCK_STYLES = [
|
|
109
|
+
"quote",
|
|
110
|
+
"codeblock",
|
|
111
|
+
"mathblock",
|
|
112
|
+
"table",
|
|
113
|
+
"tableHeader",
|
|
114
|
+
"tableRowAlt",
|
|
115
|
+
"hr",
|
|
116
|
+
"h1",
|
|
117
|
+
"h2",
|
|
118
|
+
"h3",
|
|
119
|
+
"h4",
|
|
120
|
+
"h5",
|
|
121
|
+
"h6"
|
|
122
|
+
];
|
|
123
|
+
/** Styles of text ranges, in precedence order ("mark" last, so markers stay dimmed everywhere). */
|
|
124
|
+
const INLINE_STYLES = [
|
|
125
|
+
"link",
|
|
126
|
+
"strong",
|
|
127
|
+
"em",
|
|
128
|
+
"strike",
|
|
129
|
+
"code",
|
|
130
|
+
"math",
|
|
131
|
+
"url",
|
|
132
|
+
"mark"
|
|
133
|
+
];
|
|
134
|
+
const BLOCK_NODES = {
|
|
135
|
+
ATXHeading1: "h1",
|
|
136
|
+
ATXHeading2: "h2",
|
|
137
|
+
ATXHeading3: "h3",
|
|
138
|
+
ATXHeading4: "h4",
|
|
139
|
+
ATXHeading5: "h5",
|
|
140
|
+
ATXHeading6: "h6",
|
|
141
|
+
SetextHeading1: "h1",
|
|
142
|
+
SetextHeading2: "h2",
|
|
143
|
+
FencedCode: "codeblock",
|
|
144
|
+
CodeBlock: "codeblock",
|
|
145
|
+
BlockMath: "mathblock",
|
|
146
|
+
Blockquote: "quote",
|
|
147
|
+
HorizontalRule: "hr",
|
|
148
|
+
Table: "table",
|
|
149
|
+
TableHeader: "tableHeader"
|
|
150
|
+
};
|
|
151
|
+
const INLINE_NODES = {
|
|
152
|
+
StrongEmphasis: "strong",
|
|
153
|
+
Emphasis: "em",
|
|
154
|
+
Strikethrough: "strike",
|
|
155
|
+
InlineCode: "code",
|
|
156
|
+
InlineMath: "math",
|
|
157
|
+
Link: "link",
|
|
158
|
+
Image: "link",
|
|
159
|
+
Autolink: "link",
|
|
160
|
+
HeaderMark: "mark",
|
|
161
|
+
EmphasisMark: "mark",
|
|
162
|
+
StrikethroughMark: "mark",
|
|
163
|
+
CodeMark: "mark",
|
|
164
|
+
CodeInfo: "mark",
|
|
165
|
+
LinkMark: "mark",
|
|
166
|
+
LinkLabel: "mark",
|
|
167
|
+
LinkTitle: "mark",
|
|
168
|
+
QuoteMark: "mark",
|
|
169
|
+
ListMark: "mark",
|
|
170
|
+
TaskMarker: "mark",
|
|
171
|
+
TableDelimiter: "mark",
|
|
172
|
+
HorizontalRule: "mark",
|
|
173
|
+
MathMark: "mark"
|
|
174
|
+
};
|
|
175
|
+
/** Where a URL is the target of a link (dimmed) rather than the visible text (a bare autolink). */
|
|
176
|
+
const LINK_TARGET_PARENTS = /* @__PURE__ */ new Set([
|
|
177
|
+
"Link",
|
|
178
|
+
"Image",
|
|
179
|
+
"LinkReference"
|
|
180
|
+
]);
|
|
181
|
+
function inlineStyle(node) {
|
|
182
|
+
if (node.name === "URL") return LINK_TARGET_PARENTS.has(node.node.parent?.name ?? "") ? "url" : "link";
|
|
183
|
+
return INLINE_NODES[node.name];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The top-level block (a child of the document) at `pos`, or null between blocks. `side`
|
|
187
|
+
* picks the block starting (1) or ending (-1) at `pos`.
|
|
188
|
+
*/
|
|
189
|
+
function topLevelBlock(tree, pos, side) {
|
|
190
|
+
let node = tree.resolve(pos, side);
|
|
191
|
+
while (node?.parent?.parent) node = node.parent;
|
|
192
|
+
return node?.parent ? node : null;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Styles for the part of `tree` between `from` and `to` (a viewport). The range grows to whole
|
|
196
|
+
* top-level blocks, so constructs that cross its edges keep both their markers.
|
|
197
|
+
*/
|
|
198
|
+
function markdownStyles(tree, from = 0, to = tree.length) {
|
|
199
|
+
from = Math.min(from, topLevelBlock(tree, from, 1)?.from ?? from);
|
|
200
|
+
to = Math.max(to, topLevelBlock(tree, to, -1)?.to ?? to);
|
|
201
|
+
const blocks = [];
|
|
202
|
+
const inline = [];
|
|
203
|
+
let tableRows = 0;
|
|
204
|
+
tree.iterate({
|
|
205
|
+
from,
|
|
206
|
+
to,
|
|
207
|
+
enter(node) {
|
|
208
|
+
const block = BLOCK_NODES[node.name];
|
|
209
|
+
if (block) blocks.push({
|
|
210
|
+
from: node.from,
|
|
211
|
+
to: node.to,
|
|
212
|
+
style: block
|
|
213
|
+
});
|
|
214
|
+
if (node.name === "Table") tableRows = 0;
|
|
215
|
+
if (node.name === "TableRow" && ++tableRows % 2 === 0) blocks.push({
|
|
216
|
+
from: node.from,
|
|
217
|
+
to: node.to,
|
|
218
|
+
style: "tableRowAlt"
|
|
219
|
+
});
|
|
220
|
+
if (node.name === "Escape") {
|
|
221
|
+
inline.push({
|
|
222
|
+
from: node.from,
|
|
223
|
+
to: node.from + 1,
|
|
224
|
+
style: "mark"
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const style = inlineStyle(node);
|
|
229
|
+
if (style && node.to > node.from) inline.push({
|
|
230
|
+
from: node.from,
|
|
231
|
+
to: node.to,
|
|
232
|
+
style
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return {
|
|
237
|
+
blocks,
|
|
238
|
+
spans: flattenSpans(inline)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Overlapping styled ranges as sorted, non-overlapping spans; equal neighbours are merged. */
|
|
242
|
+
function flattenSpans(ranges) {
|
|
243
|
+
const events = [];
|
|
244
|
+
for (const r of ranges) {
|
|
245
|
+
if (r.to <= r.from) continue;
|
|
246
|
+
const index = INLINE_STYLES.indexOf(r.style);
|
|
247
|
+
events.push({
|
|
248
|
+
pos: r.from,
|
|
249
|
+
index,
|
|
250
|
+
delta: 1
|
|
251
|
+
}, {
|
|
252
|
+
pos: r.to,
|
|
253
|
+
index,
|
|
254
|
+
delta: -1
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
events.sort((a, b) => a.pos - b.pos);
|
|
258
|
+
const counts = new Array(INLINE_STYLES.length).fill(0);
|
|
259
|
+
const spans = [];
|
|
260
|
+
let i = 0;
|
|
261
|
+
while (i < events.length) {
|
|
262
|
+
const pos = events[i].pos;
|
|
263
|
+
for (; i < events.length && events[i].pos === pos; i++) counts[events[i].index] += events[i].delta;
|
|
264
|
+
if (i === events.length) break;
|
|
265
|
+
const styles = INLINE_STYLES.filter((_, k) => counts[k] > 0);
|
|
266
|
+
if (!styles.length) continue;
|
|
267
|
+
const end = events[i].pos;
|
|
268
|
+
const last = spans.at(-1);
|
|
269
|
+
if (last && last.to === pos && sameStyles(last.styles, styles)) last.to = end;
|
|
270
|
+
else spans.push({
|
|
271
|
+
from: pos,
|
|
272
|
+
to: end,
|
|
273
|
+
styles
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return spans;
|
|
277
|
+
}
|
|
278
|
+
function sameStyles(a, b) {
|
|
279
|
+
return a.length === b.length && a.every((s, k) => s === b[k]);
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region packages/model/src/table.ts
|
|
283
|
+
/** Characters drawn two columns wide in a monospace font (East Asian Wide and Fullwidth). */
|
|
284
|
+
const WIDE = [
|
|
285
|
+
[4352, 4447],
|
|
286
|
+
[11904, 12350],
|
|
287
|
+
[12353, 13311],
|
|
288
|
+
[13312, 19903],
|
|
289
|
+
[19968, 40959],
|
|
290
|
+
[40960, 42191],
|
|
291
|
+
[43360, 43391],
|
|
292
|
+
[44032, 55203],
|
|
293
|
+
[63744, 64255],
|
|
294
|
+
[65040, 65049],
|
|
295
|
+
[65072, 65135],
|
|
296
|
+
[65280, 65376],
|
|
297
|
+
[65504, 65510],
|
|
298
|
+
[127744, 128591],
|
|
299
|
+
[129280, 129535],
|
|
300
|
+
[131072, 262141]
|
|
301
|
+
];
|
|
302
|
+
/** Characters that take no column of their own (combining marks, joiners, variation selectors). */
|
|
303
|
+
const ZERO = [
|
|
304
|
+
[768, 879],
|
|
305
|
+
[4448, 4607],
|
|
306
|
+
[6832, 6911],
|
|
307
|
+
[7616, 7679],
|
|
308
|
+
[8203, 8207],
|
|
309
|
+
[8400, 8447],
|
|
310
|
+
[55216, 55295],
|
|
311
|
+
[65024, 65039],
|
|
312
|
+
[65056, 65071]
|
|
313
|
+
];
|
|
314
|
+
const inRanges = (ranges, cp) => ranges.some(([lo, hi]) => cp >= lo && cp <= hi);
|
|
315
|
+
/** Columns a character takes in a monospace font: 0, 1 or 2. */
|
|
316
|
+
function charWidth(codePoint) {
|
|
317
|
+
if (codePoint < 768) return 1;
|
|
318
|
+
if (inRanges(ZERO, codePoint)) return 0;
|
|
319
|
+
return inRanges(WIDE, codePoint) ? 2 : 1;
|
|
320
|
+
}
|
|
321
|
+
/** Columns `text` takes in a monospace font ("지민" → 4). */
|
|
322
|
+
function displayWidth(text) {
|
|
323
|
+
let width = 0;
|
|
324
|
+
for (const ch of text) width += charWidth(ch.codePointAt(0));
|
|
325
|
+
return width;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The cells of a table row. Pipes split cells unless escaped (`\|`); outer pipes are
|
|
329
|
+
* optional, as in GFM.
|
|
330
|
+
*/
|
|
331
|
+
function splitRow(row) {
|
|
332
|
+
const pipes = [];
|
|
333
|
+
for (let i = 0; i < row.length; i++) if (row[i] === "\\") i++;
|
|
334
|
+
else if (row[i] === "|") pipes.push(i);
|
|
335
|
+
const cells = [];
|
|
336
|
+
let start = 0;
|
|
337
|
+
for (const pipe of pipes) {
|
|
338
|
+
cells.push({
|
|
339
|
+
from: start,
|
|
340
|
+
to: pipe
|
|
341
|
+
});
|
|
342
|
+
start = pipe + 1;
|
|
343
|
+
}
|
|
344
|
+
cells.push({
|
|
345
|
+
from: start,
|
|
346
|
+
to: row.length
|
|
347
|
+
});
|
|
348
|
+
const blank = (c) => !row.slice(c.from, c.to).trim();
|
|
349
|
+
if (pipes.length && blank(cells.at(-1))) cells.pop();
|
|
350
|
+
if (pipes.length && cells.length && blank(cells[0])) cells.shift();
|
|
351
|
+
return cells;
|
|
352
|
+
}
|
|
353
|
+
/** The index of the cell containing `offset` in `row` (outer pipes count toward their cell). */
|
|
354
|
+
function cellAt(row, offset) {
|
|
355
|
+
const cells = splitRow(row);
|
|
356
|
+
const index = cells.findIndex((c) => offset <= c.to);
|
|
357
|
+
return index === -1 ? Math.max(0, cells.length - 1) : index;
|
|
358
|
+
}
|
|
359
|
+
function parseAlign(cell) {
|
|
360
|
+
const left = cell.startsWith(":");
|
|
361
|
+
const right = cell.endsWith(":");
|
|
362
|
+
return left && right ? "center" : right ? "right" : left ? "left" : "none";
|
|
363
|
+
}
|
|
364
|
+
/** Reads table lines (header, delimiter, body rows) without any line prefix such as "> ". */
|
|
365
|
+
function parseTable(lines) {
|
|
366
|
+
const cellTexts = (line) => splitRow(line).map((c) => line.slice(c.from, c.to).trim());
|
|
367
|
+
const aligns = cellTexts(lines[1] ?? "").map(parseAlign);
|
|
368
|
+
const rows = [lines[0] ?? "", ...lines.slice(2)].map(cellTexts);
|
|
369
|
+
for (const row of rows) while (row.length < aligns.length) row.push("");
|
|
370
|
+
return {
|
|
371
|
+
aligns,
|
|
372
|
+
rows
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/** The line of `lines` that holds table row `row` (the delimiter line sits between rows 0 and 1). */
|
|
376
|
+
function rowLine(row) {
|
|
377
|
+
return row === 0 ? 0 : row + 1;
|
|
378
|
+
}
|
|
379
|
+
function pad(text, width, align) {
|
|
380
|
+
const extra = Math.max(0, width - displayWidth(text));
|
|
381
|
+
const left = align === "right" ? extra : align === "center" ? Math.floor(extra / 2) : 0;
|
|
382
|
+
return {
|
|
383
|
+
text: " ".repeat(left) + text + " ".repeat(extra - left),
|
|
384
|
+
left
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/** The table as aligned lines: `| cell | cell |`, columns padded to their widest cell. */
|
|
388
|
+
function renderTable(table) {
|
|
389
|
+
const columns = Math.max(table.aligns.length, ...table.rows.map((r) => r.length));
|
|
390
|
+
const widths = Array.from({ length: columns }, (_, c) => Math.max(3, ...table.rows.map((r) => displayWidth(r[c] ?? ""))));
|
|
391
|
+
const cells = [];
|
|
392
|
+
const rowText = (row) => {
|
|
393
|
+
let line = "|";
|
|
394
|
+
const positions = [];
|
|
395
|
+
row.forEach((text, c) => {
|
|
396
|
+
const padded = pad(text, widths[c], table.aligns[c] ?? "none");
|
|
397
|
+
const from = line.length + 1 + (text ? padded.left : 0);
|
|
398
|
+
positions.push({
|
|
399
|
+
from,
|
|
400
|
+
to: from + text.length
|
|
401
|
+
});
|
|
402
|
+
line += ` ${padded.text} |`;
|
|
403
|
+
});
|
|
404
|
+
cells.push(positions);
|
|
405
|
+
return line;
|
|
406
|
+
};
|
|
407
|
+
const delimiter = "|" + table.aligns.map((align, c) => {
|
|
408
|
+
const left = align === "left" || align === "center" ? ":" : "-";
|
|
409
|
+
const right = align === "right" || align === "center" ? ":" : "-";
|
|
410
|
+
return `${left}${"-".repeat(widths[c])}${right}`;
|
|
411
|
+
}).join("|") + "|";
|
|
412
|
+
const [header = [], ...body] = table.rows;
|
|
413
|
+
return {
|
|
414
|
+
lines: [
|
|
415
|
+
rowText(header),
|
|
416
|
+
delimiter,
|
|
417
|
+
...body.map(rowText)
|
|
418
|
+
],
|
|
419
|
+
cells
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
//#endregion
|
|
423
|
+
//#region packages/model/src/theme.ts
|
|
424
|
+
const defaultTheme = {
|
|
425
|
+
fontFamily: "system-ui, -apple-system, \"Segoe UI\", \"Noto Sans KR\", sans-serif",
|
|
426
|
+
monospaceFamily: "\"D2Coding\", \"Noto Sans Mono CJK KR\", ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace",
|
|
427
|
+
color: "#1f2328",
|
|
428
|
+
block: {
|
|
429
|
+
quote: {
|
|
430
|
+
color: "#59636e",
|
|
431
|
+
borderLeft: "#d1d9e0"
|
|
432
|
+
},
|
|
433
|
+
codeblock: {
|
|
434
|
+
monospace: true,
|
|
435
|
+
lineBackground: "#f6f8fa"
|
|
436
|
+
},
|
|
437
|
+
mathblock: {
|
|
438
|
+
monospace: true,
|
|
439
|
+
color: "#6639ba"
|
|
440
|
+
},
|
|
441
|
+
table: { monospace: true },
|
|
442
|
+
tableHeader: { fontWeight: 700 },
|
|
443
|
+
tableRowAlt: { lineBackground: "#f6f8fa" },
|
|
444
|
+
hr: {},
|
|
445
|
+
h1: {
|
|
446
|
+
fontScale: 1.6,
|
|
447
|
+
fontWeight: 700
|
|
448
|
+
},
|
|
449
|
+
h2: {
|
|
450
|
+
fontScale: 1.4,
|
|
451
|
+
fontWeight: 700
|
|
452
|
+
},
|
|
453
|
+
h3: {
|
|
454
|
+
fontScale: 1.2,
|
|
455
|
+
fontWeight: 700
|
|
456
|
+
},
|
|
457
|
+
h4: {
|
|
458
|
+
fontScale: 1.1,
|
|
459
|
+
fontWeight: 700
|
|
460
|
+
},
|
|
461
|
+
h5: { fontWeight: 700 },
|
|
462
|
+
h6: {
|
|
463
|
+
fontWeight: 700,
|
|
464
|
+
color: "#59636e"
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
inline: {
|
|
468
|
+
link: { color: "#0969da" },
|
|
469
|
+
strong: { fontWeight: 700 },
|
|
470
|
+
em: { italic: true },
|
|
471
|
+
strike: { strike: true },
|
|
472
|
+
code: {
|
|
473
|
+
monospace: true,
|
|
474
|
+
background: "rgba(129, 139, 152, 0.15)"
|
|
475
|
+
},
|
|
476
|
+
math: {
|
|
477
|
+
monospace: true,
|
|
478
|
+
color: "#6639ba"
|
|
479
|
+
},
|
|
480
|
+
url: {
|
|
481
|
+
color: "#8c959f",
|
|
482
|
+
underline: true
|
|
483
|
+
},
|
|
484
|
+
mark: {
|
|
485
|
+
color: "#8c959f",
|
|
486
|
+
fontWeight: 400,
|
|
487
|
+
italic: false,
|
|
488
|
+
strike: false,
|
|
489
|
+
underline: false
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
/** The look of a line with these block styles (any order). */
|
|
494
|
+
function resolveLineLook(blocks, theme = defaultTheme) {
|
|
495
|
+
const present = new Set(blocks);
|
|
496
|
+
const look = {};
|
|
497
|
+
for (const style of BLOCK_STYLES) if (present.has(style)) Object.assign(look, theme.block[style]);
|
|
498
|
+
return look;
|
|
499
|
+
}
|
|
500
|
+
/** The look of text with these block and inline styles (any order). */
|
|
501
|
+
function resolveLook(blocks, inline, theme = defaultTheme) {
|
|
502
|
+
const { lineBackground: _bg, borderLeft: _border, ...look } = resolveLineLook(blocks, theme);
|
|
503
|
+
const present = new Set(inline);
|
|
504
|
+
for (const style of INLINE_STYLES) if (present.has(style)) Object.assign(look, theme.inline[style]);
|
|
505
|
+
return look;
|
|
506
|
+
}
|
|
507
|
+
//#endregion
|
|
508
|
+
export { BLOCK_STYLES, INLINE_STYLES, cellAt, charWidth, defaultTheme, displayWidth, flattenSpans, markdownExtensions, markdownParser, markdownStyles, parseTable, renderTable, resolveLineLook, resolveLook, rowLine, splitRow };
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simple-mark/model",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Markdown parsing and styling model shared by every renderer. Pure TypeScript, no DOM.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/one-prime/simple-mark.git",
|
|
10
|
+
"directory": "packages/model"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"markdown",
|
|
14
|
+
"wysiwyg",
|
|
15
|
+
"editor",
|
|
16
|
+
"gfm"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./index.d.ts",
|
|
21
|
+
"import": "./index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@lezer/common": "^1.5.3",
|
|
26
|
+
"@lezer/markdown": "^1.8.1"
|
|
27
|
+
}
|
|
28
|
+
}
|