@simple-mark/model 0.1.0 → 0.3.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.
Files changed (3) hide show
  1. package/index.d.ts +71 -2
  2. package/index.js +210 -1
  3. package/package.json +1 -1
package/index.d.ts CHANGED
@@ -1,11 +1,80 @@
1
1
  import { MarkdownExtension } from "@lezer/markdown";
2
- import { Tree } from "@lezer/common";
2
+ import { SyntaxNode, Tree } from "@lezer/common";
3
3
  //#region packages/model/src/parser.d.ts
4
4
  /** The Markdown flavor every SimpleMark renderer parses: CommonMark + GFM + $math$. */
5
5
  export declare const markdownExtensions: MarkdownExtension[];
6
6
  /** A Lezer parser for that flavor, for hosts without CodeMirror (e.g. a canvas renderer). */
7
7
  export declare const markdownParser: import("@lezer/markdown").MarkdownParser;
8
8
  //#endregion
9
+ //#region packages/model/src/links.d.ts
10
+ interface LinkAt {
11
+ /** An image (`![alt](src)`) or anything else that links (links, autolinks, bare URLs). */
12
+ kind: 'link' | 'image';
13
+ /** The target as written; null when a reference (`[text][label]`) has no definition. */
14
+ target: string | null;
15
+ /** Where the link is in the text. */
16
+ from: number;
17
+ to: number;
18
+ }
19
+ /**
20
+ * The link at `pos`: a link, image, autolink, bare URL, or the URL of a definition
21
+ * (`[label]: url`). `text` must use one character per line break, so offsets match the tree.
22
+ */
23
+ export declare function linkAt(tree: Tree, text: string, pos: number): LinkAt | null;
24
+ /**
25
+ * What a link target is:
26
+ * - `web`: http(s), mailto, or a bare `www.` address (given back as a full URL)
27
+ * - `anchor`: `#heading` in the same document
28
+ * - `file`: a relative or absolute path (an image, another Markdown file, …)
29
+ * - `unsafe`: any other scheme (javascript:, data:, file:, …), which should not be opened
30
+ */
31
+ export declare function linkKind(target: string): {
32
+ kind: 'web' | 'anchor' | 'file' | 'unsafe';
33
+ url: string;
34
+ };
35
+ /**
36
+ * The anchor of a heading, as GitHub makes it: lower case, punctuation removed, spaces as
37
+ * hyphens ("Next Steps!" → "next-steps", "다음 단계" → "다음-단계").
38
+ */
39
+ export declare function headingSlug(heading: string): string;
40
+ /**
41
+ * The headings of a document with their anchors; repeated anchors get -1, -2, … like GitHub.
42
+ * `from` is where each heading starts.
43
+ */
44
+ export declare function headingAnchors(tree: Tree, text: string): {
45
+ anchor: string;
46
+ from: number;
47
+ }[];
48
+ //#endregion
49
+ //#region packages/model/src/previews.d.ts
50
+ /** An image as written in the document. */
51
+ interface ImageSource {
52
+ alt: string;
53
+ /** The path as written, or null when a reference has no definition. */
54
+ src: string | null;
55
+ /** For messages: the path, or the reference label (`[logo]`) that has no definition. */
56
+ raw: string;
57
+ }
58
+ /** A preview drawn below the line that ends at `at` (an offset in the text). */
59
+ type PreviewBlock = {
60
+ at: number;
61
+ images: ImageSource[];
62
+ } | {
63
+ at: number;
64
+ tex: string;
65
+ };
66
+ /**
67
+ * The TeX of a math node (`InlineMath` or `BlockMath`), without delimiters or quote markers;
68
+ * null while it is not closed. `slice` reads the document.
69
+ */
70
+ export declare function mathSource(node: SyntaxNode, slice: (from: number, to: number) => string): string | null;
71
+ /**
72
+ * The previews of a document: the images of each line (one entry per line, in order) and
73
+ * closed, non-empty `$$` blocks. `text` must use one character per line break ("\n"), so
74
+ * offsets match the tree.
75
+ */
76
+ export declare function previewBlocks(tree: Tree, text: string): PreviewBlock[];
77
+ //#endregion
9
78
  //#region packages/model/src/styles.d.ts
10
79
  /** Styles of whole lines, in precedence order (later wins when two set the same property). */
11
80
  export declare const BLOCK_STYLES: readonly ['quote', 'codeblock', 'mathblock', 'table', 'tableHeader', 'tableRowAlt', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
@@ -113,4 +182,4 @@ export declare function resolveLineLook(blocks: Iterable<BlockStyle>, theme?: Th
113
182
  /** The look of text with these block and inline styles (any order). */
114
183
  export declare function resolveLook(blocks: Iterable<BlockStyle>, inline: Iterable<InlineStyle>, theme?: Theme): Look;
115
184
  //#endregion
116
- export type { BlockRange, BlockStyle, ColumnAlign, InlineStyle, LineLook, Look, MarkdownStyles, RenderedTable, RowCell, Span, Table, Theme };
185
+ export type { BlockRange, BlockStyle, ColumnAlign, ImageSource, InlineStyle, LineLook, LinkAt, Look, MarkdownStyles, PreviewBlock, RenderedTable, RowCell, Span, Table, Theme };
package/index.js CHANGED
@@ -103,6 +103,215 @@ const markdownExtensions = [GFM, {
103
103
  /** A Lezer parser for that flavor, for hosts without CodeMirror (e.g. a canvas renderer). */
104
104
  const markdownParser = parser.configure(markdownExtensions);
105
105
  //#endregion
106
+ //#region packages/model/src/previews.ts
107
+ /** A reference label (`[Logo]`) as definitions are looked up: case and spacing ignored. */
108
+ const normalizeLabel = (label) => label.slice(1, -1).trim().replace(/\s+/g, " ").toLowerCase();
109
+ /** A link destination as written, without the optional angle brackets (`<a b.png>`). */
110
+ const destination = (text, url) => text.slice(url.from, url.to).replace(/^<(.*)>$/, "$1");
111
+ /** Link reference definitions (`[label]: url`) of the document, by normalized label. */
112
+ function linkDefinitions(tree, text) {
113
+ const definitions = /* @__PURE__ */ new Map();
114
+ for (let node = tree.topNode.firstChild; node; node = node.nextSibling) {
115
+ if (node.name !== "LinkReference") continue;
116
+ const label = node.getChild("LinkLabel");
117
+ const url = node.getChild("URL");
118
+ if (label && url) {
119
+ const key = normalizeLabel(text.slice(label.from, label.to));
120
+ if (!definitions.has(key)) definitions.set(key, destination(text, url));
121
+ }
122
+ }
123
+ return definitions;
124
+ }
125
+ function imageSource(node, text, definitions) {
126
+ const marks = node.getChildren("LinkMark");
127
+ const alt = marks.length >= 2 ? text.slice(marks[0].to, marks[1].from) : "";
128
+ const url = node.getChild("URL");
129
+ if (url) {
130
+ const src = destination(text, url);
131
+ return {
132
+ alt,
133
+ src,
134
+ raw: src
135
+ };
136
+ }
137
+ const label = node.getChild("LinkLabel");
138
+ const labelText = label && label.to - label.from > 2 ? text.slice(label.from, label.to) : `[${alt}]`;
139
+ const src = definitions.get(normalizeLabel(labelText));
140
+ return src === void 0 ? {
141
+ alt,
142
+ src: null,
143
+ raw: labelText
144
+ } : {
145
+ alt,
146
+ src,
147
+ raw: src
148
+ };
149
+ }
150
+ /**
151
+ * The TeX of a math node (`InlineMath` or `BlockMath`), without delimiters or quote markers;
152
+ * null while it is not closed. `slice` reads the document.
153
+ */
154
+ function mathSource(node, slice) {
155
+ const marks = node.getChildren("MathMark");
156
+ if (marks.length < 2) return null;
157
+ if (node.name === "InlineMath") return slice(marks[0].to, marks[1].from);
158
+ return node.getChildren("MathText").map((part) => slice(part.from, part.to)).join("\n");
159
+ }
160
+ /**
161
+ * The previews of a document: the images of each line (one entry per line, in order) and
162
+ * closed, non-empty `$$` blocks. `text` must use one character per line break ("\n"), so
163
+ * offsets match the tree.
164
+ */
165
+ function previewBlocks(tree, text) {
166
+ const definitions = linkDefinitions(tree, text);
167
+ const lineEnd = (pos) => {
168
+ const end = text.indexOf("\n", pos);
169
+ return end === -1 ? text.length : end;
170
+ };
171
+ const imagesByLine = /* @__PURE__ */ new Map();
172
+ const blocks = [];
173
+ tree.iterate({ enter(node) {
174
+ if (node.name === "Image") {
175
+ const at = lineEnd(node.to);
176
+ let images = imagesByLine.get(at);
177
+ if (!images) {
178
+ imagesByLine.set(at, images = []);
179
+ blocks.push({
180
+ at,
181
+ images
182
+ });
183
+ }
184
+ images.push(imageSource(node.node, text, definitions));
185
+ return false;
186
+ }
187
+ if (node.name === "BlockMath") {
188
+ const tex = mathSource(node.node, (from, to) => text.slice(from, to));
189
+ if (tex !== null && tex.trim()) blocks.push({
190
+ at: lineEnd(node.to),
191
+ tex
192
+ });
193
+ return false;
194
+ }
195
+ } });
196
+ return blocks;
197
+ }
198
+ //#endregion
199
+ //#region packages/model/src/links.ts
200
+ /** Where a URL node is a link's target rather than a bare URL of its own. */
201
+ const LINK_PARENTS = /* @__PURE__ */ new Set([
202
+ "Link",
203
+ "Image",
204
+ "Autolink",
205
+ "LinkReference"
206
+ ]);
207
+ function targetOf(node, text, definitions) {
208
+ const url = node.name === "URL" ? node : node.getChild("URL");
209
+ if (url) return destination(text, url);
210
+ const marks = node.getChildren("LinkMark");
211
+ const content = marks.length >= 2 ? text.slice(marks[0].to, marks[1].from) : "";
212
+ const label = node.getChild("LinkLabel");
213
+ const labelText = label && label.to - label.from > 2 ? text.slice(label.from, label.to) : `[${content}]`;
214
+ return definitions().get(normalizeLabel(labelText)) ?? null;
215
+ }
216
+ /**
217
+ * The link at `pos`: a link, image, autolink, bare URL, or the URL of a definition
218
+ * (`[label]: url`). `text` must use one character per line break, so offsets match the tree.
219
+ */
220
+ function linkAt(tree, text, pos) {
221
+ let definitions;
222
+ const lazyDefinitions = () => definitions ??= linkDefinitions(tree, text);
223
+ for (const side of [1, -1]) for (let node = tree.resolveInner(pos, side); node; node = node.parent) {
224
+ if (node.from > pos || node.to < pos) continue;
225
+ if (node.name === "Link" || node.name === "Image" || node.name === "Autolink") return {
226
+ kind: node.name === "Image" ? "image" : "link",
227
+ target: targetOf(node, text, lazyDefinitions),
228
+ from: node.from,
229
+ to: node.to
230
+ };
231
+ if (node.name === "URL" && !LINK_PARENTS.has(node.parent?.name ?? "")) return {
232
+ kind: "link",
233
+ target: destination(text, node),
234
+ from: node.from,
235
+ to: node.to
236
+ };
237
+ if (node.name === "URL" && node.parent?.name === "LinkReference") return {
238
+ kind: "link",
239
+ target: destination(text, node),
240
+ from: node.from,
241
+ to: node.to
242
+ };
243
+ }
244
+ return null;
245
+ }
246
+ /**
247
+ * What a link target is:
248
+ * - `web`: http(s), mailto, or a bare `www.` address (given back as a full URL)
249
+ * - `anchor`: `#heading` in the same document
250
+ * - `file`: a relative or absolute path (an image, another Markdown file, …)
251
+ * - `unsafe`: any other scheme (javascript:, data:, file:, …), which should not be opened
252
+ */
253
+ function linkKind(target) {
254
+ const url = target.trim();
255
+ if (/^(https?:\/\/|mailto:)/i.test(url)) return {
256
+ kind: "web",
257
+ url
258
+ };
259
+ if (/^www\./i.test(url)) return {
260
+ kind: "web",
261
+ url: `https://${url}`
262
+ };
263
+ if (/^[^\s/:@]+@[^\s/:@]+\.[^\s/:@]+$/.test(url)) return {
264
+ kind: "web",
265
+ url: `mailto:${url}`
266
+ };
267
+ if (url.startsWith("#")) return {
268
+ kind: "anchor",
269
+ url
270
+ };
271
+ if (/^[a-z][a-z0-9+.-]+:/i.test(url)) return {
272
+ kind: "unsafe",
273
+ url
274
+ };
275
+ return {
276
+ kind: "file",
277
+ url
278
+ };
279
+ }
280
+ /**
281
+ * The anchor of a heading, as GitHub makes it: lower case, punctuation removed, spaces as
282
+ * hyphens ("Next Steps!" → "next-steps", "다음 단계" → "다음-단계").
283
+ */
284
+ function headingSlug(heading) {
285
+ return heading.trim().toLowerCase().replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, "").replace(/\s/g, "-");
286
+ }
287
+ const HEADINGS = /^(ATXHeading[1-6]|SetextHeading[12])$/;
288
+ /**
289
+ * The headings of a document with their anchors; repeated anchors get -1, -2, … like GitHub.
290
+ * `from` is where each heading starts.
291
+ */
292
+ function headingAnchors(tree, text) {
293
+ const out = [];
294
+ const seen = /* @__PURE__ */ new Map();
295
+ for (let node = tree.topNode.firstChild; node; node = node.nextSibling) {
296
+ if (!HEADINGS.test(node.name)) continue;
297
+ let title = "";
298
+ let pos = node.from;
299
+ for (const mark of node.getChildren("HeaderMark")) {
300
+ title += text.slice(pos, mark.from);
301
+ pos = mark.to;
302
+ }
303
+ title += text.slice(pos, node.to);
304
+ const base = headingSlug(title.split("\n")[0]);
305
+ const count = seen.get(base) ?? 0;
306
+ seen.set(base, count + 1);
307
+ out.push({
308
+ anchor: count ? `${base}-${count}` : base,
309
+ from: node.from
310
+ });
311
+ }
312
+ return out;
313
+ }
314
+ //#endregion
106
315
  //#region packages/model/src/styles.ts
107
316
  /** Styles of whole lines, in precedence order (later wins when two set the same property). */
108
317
  const BLOCK_STYLES = [
@@ -505,4 +714,4 @@ function resolveLook(blocks, inline, theme = defaultTheme) {
505
714
  return look;
506
715
  }
507
716
  //#endregion
508
- export { BLOCK_STYLES, INLINE_STYLES, cellAt, charWidth, defaultTheme, displayWidth, flattenSpans, markdownExtensions, markdownParser, markdownStyles, parseTable, renderTable, resolveLineLook, resolveLook, rowLine, splitRow };
717
+ export { BLOCK_STYLES, INLINE_STYLES, cellAt, charWidth, defaultTheme, displayWidth, flattenSpans, headingAnchors, headingSlug, linkAt, linkKind, markdownExtensions, markdownParser, markdownStyles, mathSource, parseTable, previewBlocks, renderTable, resolveLineLook, resolveLook, rowLine, splitRow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-mark/model",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Markdown parsing and styling model shared by every renderer. Pure TypeScript, no DOM.",
5
5
  "license": "MIT",
6
6
  "type": "module",