pantsdown 1.0.1 → 1.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.
@@ -1,5 +1,6 @@
1
1
  name: Release
2
2
  on:
3
+ pull_request:
3
4
  push:
4
5
  branches:
5
6
  - main
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "pantsdown",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
+ "author": "wallpants",
4
5
  "description": "The markdown parser",
6
+ "license": "MIT",
7
+ "type": "module",
5
8
  "repository": {
6
9
  "type": "git",
7
10
  "url": "git://github.com/wallpants/pantsdown.git"
8
11
  },
9
- "license": "MIT",
10
- "type": "module",
11
12
  "exports": {
12
13
  "types": "./src/index.ts",
13
14
  "bun": "./src/index.ts",
@@ -28,6 +29,9 @@
28
29
  "main"
29
30
  ]
30
31
  },
32
+ "dependencies": {
33
+ "highlight.js": "^11.9.0"
34
+ },
31
35
  "devDependencies": {
32
36
  "@typescript-eslint/eslint-plugin": "^6.7.3",
33
37
  "@typescript-eslint/parser": "^6.7.3",
@@ -39,8 +43,5 @@
39
43
  "prettier-plugin-organize-imports": "^3.2.3",
40
44
  "semantic-release": "^22.0.5",
41
45
  "typescript": "5.2.2"
42
- },
43
- "dependencies": {
44
- "highlight.js": "^11.9.0"
45
46
  }
46
47
  }
package/src/lexer.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { inline } from "./rules/inline.ts";
2
2
  import { Tokenizer } from "./tokenizer.ts";
3
- import { type Links, type Token } from "./types.ts";
3
+ import { type Links, type SourceMap, type Token } from "./types.ts";
4
4
 
5
5
  export class Lexer {
6
6
  private tokenizer: Tokenizer;
7
7
  private inlineQueue: { src: string; tokens: Token[] }[];
8
8
  private tokens: Token[] = [];
9
9
  private links: Links = {};
10
+ private line = 1;
10
11
 
11
12
  state = {
12
13
  inLink: false,
@@ -26,6 +27,7 @@ export class Lexer {
26
27
  // reset values from previous parse
27
28
  this.tokens = [];
28
29
  this.links = {};
30
+ this.line = 1;
29
31
 
30
32
  src = src.replace(/\r\n|\r/g, "\n");
31
33
 
@@ -39,6 +41,21 @@ export class Lexer {
39
41
  return this.tokens;
40
42
  }
41
43
 
44
+ getSourceMap(rawToken: string): SourceMap | undefined {
45
+ if (!this.state.top) return;
46
+ const tokenLines = rawToken.split("\n");
47
+ const sourceMap: SourceMap = [this.line, (this.line += tokenLines.length - 1)];
48
+
49
+ while (tokenLines.slice(-1)[0] === "") {
50
+ // token.raw sometimes includes newline chars at the end of the string
51
+ // which we don't want to include in the sourceMap
52
+ tokenLines.pop();
53
+ sourceMap[1]--;
54
+ }
55
+
56
+ return sourceMap;
57
+ }
58
+
42
59
  /**
43
60
  * Lexing
44
61
  */
package/src/parser.ts CHANGED
@@ -25,15 +25,19 @@ export class Parser {
25
25
  continue;
26
26
  }
27
27
  case "hr": {
28
- out += this.renderer.hr();
28
+ out += this.renderer.hr(token.sourceMap);
29
29
  continue;
30
30
  }
31
31
  case "heading": {
32
- out += this.renderer.heading(this.parseInline(token.tokens), token.depth);
32
+ out += this.renderer.heading(
33
+ this.parseInline(token.tokens),
34
+ token.depth,
35
+ token.sourceMap,
36
+ );
33
37
  continue;
34
38
  }
35
39
  case "code": {
36
- out += this.renderer.code(token.text, token.lang);
40
+ out += this.renderer.code(token.text, token.lang, token.sourceMap);
37
41
  continue;
38
42
  }
39
43
  case "table": {
@@ -47,7 +51,8 @@ export class Parser {
47
51
  align: token.align[j]!,
48
52
  });
49
53
  }
50
- header += this.renderer.tablerow(cell);
54
+ const sourceMapLineStart = token.sourceMap?.[0];
55
+ header += this.renderer.tablerow(cell, sourceMapLineStart);
51
56
 
52
57
  let body = "";
53
58
  for (let j = 0, rowsLen = token.rows.length; j < rowsLen; j++) {
@@ -61,14 +66,17 @@ export class Parser {
61
66
  });
62
67
  }
63
68
 
64
- body += this.renderer.tablerow(cell);
69
+ body += this.renderer.tablerow(
70
+ cell,
71
+ sourceMapLineStart ? sourceMapLineStart + 2 + j : undefined,
72
+ );
65
73
  }
66
- out += this.renderer.table(header, body);
74
+ out += this.renderer.table(header, body, token.sourceMap);
67
75
  continue;
68
76
  }
69
77
  case "blockquote": {
70
78
  const body = this.parse(token.tokens);
71
- out += this.renderer.blockquote(body);
79
+ out += this.renderer.blockquote(body, token.sourceMap);
72
80
  continue;
73
81
  }
74
82
  case "list": {
@@ -84,7 +92,7 @@ export class Parser {
84
92
 
85
93
  let itemBody = "";
86
94
  if (item.task) {
87
- const checkbox = this.renderer.checkbox(!!checked);
95
+ const checkbox = this.renderer.checkbox(Boolean(checked));
88
96
  if (loose) {
89
97
  if (
90
98
  item.tokens.length > 0 &&
@@ -110,18 +118,22 @@ export class Parser {
110
118
  }
111
119
 
112
120
  itemBody += this.parse(item.tokens, loose);
113
- body += this.renderer.listitem(itemBody, task, !!checked);
121
+ body += this.renderer.listitem(itemBody, task, Boolean(checked));
114
122
  }
115
123
 
116
- out += this.renderer.list(body, ordered, start);
124
+ out += this.renderer.list(body, ordered, start, token.sourceMap);
117
125
  continue;
118
126
  }
119
127
  case "html": {
120
- out += this.renderer.html(token.text, token.block);
128
+ out += this.renderer.html(
129
+ token.text,
130
+ token.block,
131
+ "sourceMap" in token ? token.sourceMap : undefined,
132
+ );
121
133
  continue;
122
134
  }
123
135
  case "paragraph": {
124
- out += this.renderer.paragraph(this.parseInline(token.tokens));
136
+ out += this.renderer.paragraph(this.parseInline(token.tokens), token.sourceMap);
125
137
  continue;
126
138
  }
127
139
  case "text": {
@@ -137,7 +149,7 @@ export class Parser {
137
149
  ? this.parseInline(textToken.tokens)
138
150
  : textToken.text);
139
151
  }
140
- out += top ? this.renderer.paragraph(body) : body;
152
+ out += top ? this.renderer.paragraph(body, textToken.sourceMap) : body;
141
153
  continue;
142
154
  }
143
155
 
@@ -166,7 +178,7 @@ export class Parser {
166
178
  break;
167
179
  }
168
180
  case "html": {
169
- out += this.renderer.html(token.text);
181
+ out += this.renderer.html(token.text, false);
170
182
  break;
171
183
  }
172
184
  case "link": {
package/src/renderer.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import hljs from "highlight.js";
2
- import { type RendererProps } from "./types.ts";
3
- import { cleanUrl, escape, fixHtmlLocalImageHref, fixLocalImageHref } from "./utils.ts";
2
+ import { type RendererProps, type SourceMap } from "./types.ts";
3
+ import {
4
+ cleanUrl,
5
+ escape,
6
+ fixHtmlLocalImageHref,
7
+ fixLocalImageHref,
8
+ injectHtmlAttributes,
9
+ renderSourceMap,
10
+ } from "./utils.ts";
4
11
 
5
12
  /**
6
13
  * An object containing functions to render tokens to HTML.
@@ -12,7 +19,7 @@ export class Renderer {
12
19
  this.props = props;
13
20
  }
14
21
 
15
- code(code: string, infostring: string | undefined): string {
22
+ code(code: string, infostring: string | undefined, sourceMap: SourceMap): string {
16
23
  const lang = (infostring ?? "").match(/^\S*/)?.[0];
17
24
 
18
25
  code = code.replace(/\n$/, "") + "\n";
@@ -21,7 +28,7 @@ export class Renderer {
21
28
  const highlightedCode = hljs.highlight(code, { language }).value;
22
29
 
23
30
  return (
24
- '<pre><code class="hljs language-' +
31
+ `<pre${renderSourceMap(sourceMap)}><code class="hljs language-` +
25
32
  escape(language) +
26
33
  '">' +
27
34
  highlightedCode +
@@ -29,27 +36,36 @@ export class Renderer {
29
36
  );
30
37
  }
31
38
 
32
- blockquote(quote: string): string {
33
- return `<blockquote>\n${quote}</blockquote>\n`;
39
+ blockquote(quote: string, sourceMap: SourceMap): string {
40
+ return `<blockquote${renderSourceMap(sourceMap)}>\n${quote}</blockquote>\n`;
34
41
  }
35
42
 
36
- html(html: string, _block?: boolean): string {
37
- return fixHtmlLocalImageHref(html, this.props.localImageUrlPrefix);
43
+ html(html: string, _block: boolean, sourceMap?: SourceMap | undefined): string {
44
+ let result = fixHtmlLocalImageHref(html, this.props.localImageUrlPrefix);
45
+ if (sourceMap) {
46
+ result = injectHtmlAttributes(result, [
47
+ ["line-start", sourceMap[0]],
48
+ ["line-end", sourceMap[1]],
49
+ ]);
50
+ }
51
+ return result;
38
52
  }
39
53
 
40
- heading(text: string, level: number): string {
54
+ heading(text: string, level: number, sourceMap: SourceMap): string {
41
55
  // ignore IDs
42
- return `<h${level}>${text}</h${level}>\n`;
56
+ return `<h${level}${renderSourceMap(sourceMap)}>${text}</h${level}>\n`;
43
57
  }
44
58
 
45
- hr(): string {
46
- return "<hr>\n";
59
+ hr(sourceMap: SourceMap): string {
60
+ return `<hr${renderSourceMap(sourceMap)}>\n`;
47
61
  }
48
62
 
49
- list(body: string, ordered: boolean, start: number | ""): string {
63
+ list(body: string, ordered: boolean, start: number | "", sourceMap: SourceMap): string {
50
64
  const type = ordered ? "ol" : "ul";
51
65
  const startatt = ordered && start !== 1 ? ' start="' + start + '"' : "";
52
- return "<" + type + startatt + ">\n" + body + "</" + type + ">\n";
66
+ return (
67
+ "<" + type + startatt + `${renderSourceMap(sourceMap)}>\n` + body + "</" + type + ">\n"
68
+ );
53
69
  }
54
70
 
55
71
  listitem(text: string, _task: boolean, _checked: boolean): string {
@@ -60,18 +76,27 @@ export class Renderer {
60
76
  return "<input " + (checked ? 'checked="" ' : "") + 'disabled="" type="checkbox">';
61
77
  }
62
78
 
63
- paragraph(text: string): string {
64
- return `<p>${text}</p>\n`;
79
+ paragraph(text: string, sourceMap: SourceMap): string {
80
+ return `<p${renderSourceMap(sourceMap)}>${text}</p>\n`;
65
81
  }
66
82
 
67
- table(header: string, body: string): string {
83
+ table(header: string, body: string, sourceMap: SourceMap): string {
68
84
  if (body) body = `<tbody>${body}</tbody>`;
69
85
 
70
- return "<table>\n" + "<thead>\n" + header + "</thead>\n" + body + "</table>\n";
86
+ return (
87
+ `<table${renderSourceMap(sourceMap)}>\n` +
88
+ "<thead>\n" +
89
+ header +
90
+ "</thead>\n" +
91
+ body +
92
+ "</table>\n"
93
+ );
71
94
  }
72
95
 
73
- tablerow(content: string): string {
74
- return `<tr>\n${content}</tr>\n`;
96
+ tablerow(content: string, sourceMapStart: number | undefined): string {
97
+ return `<tr${renderSourceMap(
98
+ sourceMapStart ? [sourceMapStart, sourceMapStart] : undefined,
99
+ )}>\n${content}</tr>\n`;
75
100
  }
76
101
 
77
102
  tablecell(
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "bun:test";
2
2
 
3
- test("2 + 2", () => {
3
+ test("1 + 3", () => {
4
4
  expect(1 + 3).toBe(4);
5
5
  });
package/src/tokenizer.ts CHANGED
@@ -24,6 +24,8 @@ export class Tokenizer {
24
24
  space(src: string): Tokens["Space"] | undefined {
25
25
  const cap = block.newline.exec(src);
26
26
  if (cap && cap[0].length > 0) {
27
+ // call getSourceMap to increment "this.lexer.line"
28
+ this.lexer.getSourceMap(cap[0]);
27
29
  return {
28
30
  type: "space",
29
31
  raw: cap[0],
@@ -42,6 +44,7 @@ export class Tokenizer {
42
44
  raw: cap[0],
43
45
  codeBlockStyle: "indented",
44
46
  text: rtrim(text, "\n"),
47
+ sourceMap: this.lexer.getSourceMap(cap[0]),
45
48
  };
46
49
  }
47
50
 
@@ -57,6 +60,7 @@ export class Tokenizer {
57
60
  raw,
58
61
  lang: cap[2] ? cap[2].trim().replace(inline.escapes, "$1") : cap[2],
59
62
  text,
63
+ sourceMap: this.lexer.getSourceMap(raw),
60
64
  };
61
65
  }
62
66
 
@@ -80,6 +84,7 @@ export class Tokenizer {
80
84
  depth: cap[1]!.length,
81
85
  text,
82
86
  tokens: this.lexer.inline(text),
87
+ sourceMap: this.lexer.getSourceMap(cap[0]),
83
88
  };
84
89
  }
85
90
 
@@ -90,6 +95,7 @@ export class Tokenizer {
90
95
  return {
91
96
  type: "hr",
92
97
  raw: cap[0],
98
+ sourceMap: this.lexer.getSourceMap(cap[0]),
93
99
  };
94
100
  }
95
101
 
@@ -107,6 +113,7 @@ export class Tokenizer {
107
113
  raw: cap[0],
108
114
  tokens,
109
115
  text,
116
+ sourceMap: this.lexer.getSourceMap(cap[0]),
110
117
  };
111
118
  }
112
119
 
@@ -124,6 +131,7 @@ export class Tokenizer {
124
131
  start: isordered ? +bull.slice(0, -1) : "",
125
132
  loose: false,
126
133
  items: [] as Tokens["ListItem"][],
134
+ sourceMap: [0, 0], // updated below once token.raw is built
127
135
  };
128
136
 
129
137
  bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`;
@@ -285,6 +293,8 @@ export class Tokenizer {
285
293
  list.items[list.items.length - 1]!.text = itemContents.trimEnd();
286
294
  list.raw = list.raw.trimEnd();
287
295
 
296
+ list.sourceMap = this.lexer.getSourceMap(list.raw);
297
+
288
298
  // Item child tokens handled here at end because we needed to have the final item to trim it first
289
299
  for (let i = 0, listItemsLen = list.items.length; i < listItemsLen; i++) {
290
300
  this.lexer.state.top = false;
@@ -321,6 +331,7 @@ export class Tokenizer {
321
331
  raw: cap[0],
322
332
  pre: cap[1] === "pre" || cap[1] === "script" || cap[1] === "style",
323
333
  text: cap[0],
334
+ sourceMap: this.lexer.getSourceMap(cap[0]),
324
335
  };
325
336
  return token;
326
337
  }
@@ -340,6 +351,7 @@ export class Tokenizer {
340
351
  raw: cap[0],
341
352
  href,
342
353
  title,
354
+ sourceMap: this.lexer.getSourceMap(cap[0]),
343
355
  };
344
356
  }
345
357
 
@@ -363,6 +375,7 @@ export class Tokenizer {
363
375
  })),
364
376
  align: [],
365
377
  rows: [],
378
+ sourceMap: this.lexer.getSourceMap(cap[0]),
366
379
  };
367
380
 
368
381
  const align = cap[2].replace(/^\||\| *$/g, "").split("|") as (string | null)[];
@@ -429,6 +442,7 @@ export class Tokenizer {
429
442
  depth: cap[2]!.startsWith("=") ? 1 : 2,
430
443
  text: cap[1]!,
431
444
  tokens: this.lexer.inline(cap[1]!),
445
+ sourceMap: this.lexer.getSourceMap(cap[0]),
432
446
  };
433
447
  }
434
448
 
@@ -442,6 +456,7 @@ export class Tokenizer {
442
456
  raw: cap[0],
443
457
  text,
444
458
  tokens: this.lexer.inline(text),
459
+ sourceMap: this.lexer.getSourceMap(cap[0]),
445
460
  };
446
461
  }
447
462
 
@@ -454,6 +469,7 @@ export class Tokenizer {
454
469
  raw: cap[0],
455
470
  text: cap[0],
456
471
  tokens: this.lexer.inline(cap[0]),
472
+ sourceMap: this.lexer.getSourceMap(cap[0]),
457
473
  };
458
474
  }
459
475
 
package/src/types.ts CHANGED
@@ -2,8 +2,11 @@ type BaseToken = {
2
2
  type: string;
3
3
  raw: string;
4
4
  text?: string;
5
+ sourceMap?: SourceMap;
5
6
  };
6
7
 
8
+ export type SourceMap = [start: number, end: number] | undefined;
9
+
7
10
  // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
8
11
  export interface Tokens extends Record<string, BaseToken> {
9
12
  Space: {
@@ -16,6 +19,7 @@ export interface Tokens extends Record<string, BaseToken> {
16
19
  text: string;
17
20
  codeBlockStyle?: "indented";
18
21
  lang?: string | undefined;
22
+ sourceMap: SourceMap;
19
23
  };
20
24
  Heading: {
21
25
  type: "heading";
@@ -23,6 +27,7 @@ export interface Tokens extends Record<string, BaseToken> {
23
27
  text: string;
24
28
  depth: number;
25
29
  tokens: Token[];
30
+ sourceMap: SourceMap;
26
31
  };
27
32
  Table: {
28
33
  type: "table";
@@ -30,6 +35,7 @@ export interface Tokens extends Record<string, BaseToken> {
30
35
  align: ("center" | "left" | "right" | null)[];
31
36
  header: Tokens["TableCell"][];
32
37
  rows: Tokens["TableCell"][][];
38
+ sourceMap: SourceMap;
33
39
  };
34
40
  TableCell: {
35
41
  type: "tablecell";
@@ -40,12 +46,14 @@ export interface Tokens extends Record<string, BaseToken> {
40
46
  Hr: {
41
47
  type: "hr";
42
48
  raw: string;
49
+ sourceMap: SourceMap;
43
50
  };
44
51
  Blockquote: {
45
52
  type: "blockquote";
46
53
  raw: string;
47
54
  text: string;
48
55
  tokens: Token[];
56
+ sourceMap: SourceMap;
49
57
  };
50
58
  List: {
51
59
  type: "list";
@@ -54,6 +62,7 @@ export interface Tokens extends Record<string, BaseToken> {
54
62
  start: number | "";
55
63
  loose: boolean;
56
64
  items: Tokens["ListItem"][];
65
+ sourceMap: SourceMap;
57
66
  };
58
67
  ListItem: {
59
68
  type: "list_item";
@@ -70,6 +79,7 @@ export interface Tokens extends Record<string, BaseToken> {
70
79
  text: string;
71
80
  pre?: boolean | undefined;
72
81
  tokens: Token[];
82
+ sourceMap: SourceMap;
73
83
  };
74
84
  HTML: {
75
85
  type: "html";
@@ -77,12 +87,14 @@ export interface Tokens extends Record<string, BaseToken> {
77
87
  text: string;
78
88
  pre: boolean;
79
89
  block: boolean;
90
+ sourceMap: SourceMap;
80
91
  };
81
92
  Text: {
82
93
  type: "text";
83
94
  raw: string;
84
95
  text: string;
85
96
  tokens?: Token[];
97
+ sourceMap?: SourceMap;
86
98
  };
87
99
  Def: {
88
100
  type: "def";
@@ -90,6 +102,7 @@ export interface Tokens extends Record<string, BaseToken> {
90
102
  tag: string;
91
103
  href: string;
92
104
  title: string;
105
+ sourceMap: SourceMap;
93
106
  };
94
107
  Escape: {
95
108
  type: "escape";
package/src/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Lexer } from "./lexer.ts";
2
- import { type Tokens } from "./types.ts";
2
+ import { type SourceMap, type Tokens } from "./types.ts";
3
3
 
4
4
  /**
5
5
  * Helpers
@@ -31,6 +31,27 @@ export function escape(html: string, encode?: boolean) {
31
31
  return html;
32
32
  }
33
33
 
34
+ export function injectHtmlAttributes(
35
+ html: string,
36
+ attrs: [name: string, value: string | number][],
37
+ ) {
38
+ const parser = new DOMParser();
39
+ try {
40
+ const doc = parser.parseFromString(html, "text/html");
41
+ // eslint-disable-next-line
42
+ if (!doc.body) throw Error("Invalid HTML");
43
+ const element = doc.body.firstChild as HTMLElement;
44
+ // eslint-disable-next-line
45
+ if (!element) throw Error("No valid element found");
46
+ attrs.forEach(([name, value]) => {
47
+ element.setAttribute(name, JSON.stringify(value));
48
+ });
49
+ return element.outerHTML;
50
+ } catch (_) {
51
+ return html;
52
+ }
53
+ }
54
+
34
55
  export function fixHtmlLocalImageHref(html: string, localImageUrlPrefix: string): string {
35
56
  return html.replace(
36
57
  /<img\s+([^>]*?)src\s*=\s*(["'])([^\2>]+?)\2([^>]*)>/gm,
@@ -65,6 +86,11 @@ export function fixLocalImageHref(href: string, localImageUrlPrefix: string): st
65
86
  }
66
87
  }
67
88
 
89
+ export function renderSourceMap(sourceMap: SourceMap) {
90
+ if (!sourceMap) return "";
91
+ return ` line-start=${sourceMap[0]} line-end=${sourceMap[1]}`;
92
+ }
93
+
68
94
  export function cleanUrl(href: string) {
69
95
  try {
70
96
  href = encodeURI(href).replace(/%25/g, "%");