pantsdown 2.1.7 → 2.2.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/CLAUDE.md ADDED
@@ -0,0 +1,72 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ Pantsdown is a Markdown to HTML converter that renders markdown similar to GitHub's styling. It was built specifically for [github-preview.nvim](https://github.com/wallpants/github-preview.nvim). Based on [Marked](https://github.com/markedjs/marked).
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ # Type checking
13
+ bun run typecheck
14
+
15
+ # Linting
16
+ bun run lint
17
+
18
+ # Both typecheck and lint
19
+ bun run check
20
+
21
+ # Run tests (uses bun:test with happy-dom)
22
+ bun test
23
+
24
+ # Run a single test file
25
+ bun test tests/parse.test.ts
26
+
27
+ # Update test snapshots
28
+ bun test --update-snapshots
29
+
30
+ # Format code
31
+ bun run format
32
+
33
+ # Build docs
34
+ bun run docs:build
35
+ ```
36
+
37
+ ## Architecture
38
+
39
+ The parsing pipeline follows a classic compiler pattern:
40
+
41
+ 1. **Lexer** (`src/lexer.ts`) - Tokenizes markdown source into an array of tokens
42
+ - Uses `Tokenizer` for the actual token creation
43
+ - Processes block-level tokens first, then inline tokens
44
+ - Tracks source maps for line number references
45
+ - Collects footnotes separately
46
+
47
+ 2. **Tokenizer** (`src/tokenizer.ts`) - Creates tokens from markdown patterns
48
+ - Uses regex rules from `src/rules/block.ts` and `src/rules/inline.ts`
49
+
50
+ 3. **Parser** (`src/parser.ts`) - Converts tokens to HTML by dispatching to the Renderer
51
+ - Recursively processes nested tokens
52
+
53
+ 4. **Renderer** (`src/renderer.ts`) - Produces HTML output for each token type
54
+ - Uses highlight.js for syntax highlighting
55
+ - Uses github-slugger for heading anchors
56
+ - Handles special cases like mermaid diagrams and alerts
57
+
58
+ Entry point: `Pantsdown` class in `src/pantsdown.ts` coordinates the pipeline.
59
+
60
+ ## Key Types
61
+
62
+ All token types are defined in `src/types.ts`. The `Token` union type covers all possible markdown elements (headings, code blocks, lists, tables, footnotes, alerts, etc.).
63
+
64
+ ## Output
65
+
66
+ `pantsdown.parse(markdown)` returns `{ html, javascript }`:
67
+ - `html`: The rendered HTML string
68
+ - `javascript`: A script for interactive features (task list checkboxes, copy buttons)
69
+
70
+ ## Styling
71
+
72
+ CSS is in `src/css/styles.css`. Requires a parent element with classes `pantsdown light` or `pantsdown dark` (optionally with `high-contrast`).
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "pantsdown",
3
3
  "author": "wallpants",
4
4
  "type": "module",
5
- "version": "2.1.7",
5
+ "version": "2.2.0",
6
6
  "license": "MIT",
7
7
  "description": "Markdown to 'GitHub HTML' parser",
8
8
  "repository": {
@@ -51,21 +51,23 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "github-slugger": "^2.0.0",
54
- "highlight.js": "^11.11.1"
54
+ "highlight.js": "^11.11.1",
55
+ "katex": "^0.16.28"
55
56
  },
56
57
  "devDependencies": {
57
- "@commitlint/config-conventional": "^19.6.0",
58
- "@commitlint/cz-commitlint": "^19.6.1",
59
- "@happy-dom/global-registrator": "^16.3.0",
60
- "@types/bun": "^1.1.14",
61
- "@typescript-eslint/eslint-plugin": "^8.19.0",
62
- "@typescript-eslint/parser": "^8.19.0",
58
+ "@commitlint/config-conventional": "^19.8.1",
59
+ "@commitlint/cz-commitlint": "^19.8.1",
60
+ "@happy-dom/global-registrator": "^16.8.1",
61
+ "@types/bun": "^1.3.7",
62
+ "@types/katex": "^0.16.8",
63
+ "@typescript-eslint/eslint-plugin": "^8.54.0",
64
+ "@typescript-eslint/parser": "^8.54.0",
63
65
  "commitizen": "^4.3.1",
64
- "commitlint": "^19.6.1",
66
+ "commitlint": "^19.8.1",
65
67
  "eslint": "^8.57.1",
66
- "prettier": "^3.4.2",
67
- "prettier-plugin-organize-imports": "^4.1.0",
68
- "semantic-release": "^24.2.1",
69
- "typescript": "5.6.3"
68
+ "prettier": "^3.8.1",
69
+ "prettier-plugin-organize-imports": "^4.3.0",
70
+ "semantic-release": "^24.2.9",
71
+ "typescript": "5.7.2"
70
72
  }
71
73
  }
@@ -113,8 +113,9 @@
113
113
  margin: 0;
114
114
  color: var(--color-fg-default);
115
115
  background-color: var(--color-canvas-default);
116
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial,
117
- sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
116
+ font-family:
117
+ -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif,
118
+ "Apple Color Emoji", "Segoe UI Emoji";
118
119
  font-size: 16px;
119
120
  line-height: 1.5;
120
121
  word-wrap: break-word;
@@ -1323,3 +1324,36 @@
1323
1324
  /* .pantsdown.dark .hljs-tag { */
1324
1325
  /* /1* purposely ignored *1/ */
1325
1326
  /* } */
1327
+
1328
+ /*
1329
+ * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
1330
+ * *
1331
+ * KaTeX / LaTeX *
1332
+ * *
1333
+ * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
1334
+ */
1335
+
1336
+ .pantsdown .katex-block {
1337
+ display: block;
1338
+ margin: 16px 0;
1339
+ text-align: center;
1340
+ overflow-x: auto;
1341
+ overflow-y: hidden;
1342
+ }
1343
+
1344
+ .pantsdown .katex-inline {
1345
+ display: inline;
1346
+ }
1347
+
1348
+ .pantsdown .katex-error {
1349
+ color: var(--color-danger-fg);
1350
+ background-color: var(--color-canvas-subtle);
1351
+ padding: 0.2em 0.4em;
1352
+ border-radius: 6px;
1353
+ }
1354
+
1355
+ .pantsdown .katex-error code {
1356
+ color: inherit;
1357
+ background-color: transparent;
1358
+ padding: 0;
1359
+ }
package/src/lexer.ts CHANGED
@@ -111,6 +111,13 @@ export class Lexer {
111
111
  continue;
112
112
  }
113
113
 
114
+ // latexBlock
115
+ if ((token = this.tokenizer.latexBlock(src))) {
116
+ src = src.substring(token.raw.length);
117
+ tokens.push(token);
118
+ continue;
119
+ }
120
+
114
121
  // fences
115
122
  if ((token = this.tokenizer.fences(src))) {
116
123
  src = src.substring(token.raw.length);
@@ -169,8 +176,8 @@ export class Lexer {
169
176
  lastToken.text += "\n" + token.raw;
170
177
  const lastInline = this.inlineQueue[this.inlineQueue.length - 1];
171
178
  if (lastInline) lastInline.src = lastToken.text;
172
- } else if (!this.links[token.tag]) {
173
- this.links[token.tag] = {
179
+ } else {
180
+ this.links[token.tag] ??= {
174
181
  href: token.href,
175
182
  title: token.title,
176
183
  };
@@ -215,7 +222,7 @@ export class Lexer {
215
222
  if ((token = this.tokenizer.text(src))) {
216
223
  src = src.substring(token.raw.length);
217
224
  lastToken = tokens[tokens.length - 1];
218
- if (lastToken && lastToken.type === "text") {
225
+ if (lastToken?.type === "text") {
219
226
  lastToken.raw += "\n" + token.raw;
220
227
  lastToken.text += "\n" + token.text;
221
228
  this.inlineQueue.pop();
@@ -291,6 +298,13 @@ export class Lexer {
291
298
  }
292
299
  keepPrevChar = false;
293
300
 
301
+ // latexInline (before escape to handle \(...\) syntax)
302
+ if ((token = this.tokenizer.latexInline(src))) {
303
+ src = src.substring(token.raw.length);
304
+ tokens.push(token);
305
+ continue;
306
+ }
307
+
294
308
  // escape
295
309
  if ((token = this.tokenizer.escape(src))) {
296
310
  src = src.substring(token.raw.length);
@@ -391,7 +405,7 @@ export class Lexer {
391
405
  }
392
406
  keepPrevChar = true;
393
407
  lastToken = tokens[tokens.length - 1];
394
- if (lastToken && lastToken.type === "text") {
408
+ if (lastToken?.type === "text") {
395
409
  lastToken.raw += token.raw;
396
410
  lastToken.text += token.text;
397
411
  } else {
package/src/parser.ts CHANGED
@@ -184,6 +184,10 @@ export class Parser {
184
184
  out += top ? this.renderer.paragraph(body, textToken.sourceMap) : body;
185
185
  continue;
186
186
  }
187
+ case "latexBlock": {
188
+ out += this.renderer.latexBlock(token.text, token.sourceMap);
189
+ continue;
190
+ }
187
191
 
188
192
  default: {
189
193
  const errMsg = 'Token with "' + token.type + '" type was not found.';
@@ -253,6 +257,10 @@ export class Parser {
253
257
  out += this.renderer.text(token.text);
254
258
  break;
255
259
  }
260
+ case "latexInline": {
261
+ out += this.renderer.latexInline(token.text);
262
+ break;
263
+ }
256
264
  default: {
257
265
  const errMsg = 'Token with "' + token.type + '" type was not found.';
258
266
  throw new Error(errMsg);
package/src/renderer.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import GithubSlugger from "github-slugger";
2
2
  import hljs from "highlight.js";
3
+ import katex from "katex";
3
4
  import { type Pantsdown } from "./pantsdown.ts";
4
5
  import { inline } from "./rules/inline.ts";
5
6
  import { type HTMLAttrs, type SourceMap, type Tokens } from "./types.ts";
@@ -212,4 +213,40 @@ export class Renderer {
212
213
  text(text: string): string {
213
214
  return text;
214
215
  }
216
+
217
+ latexBlock(latex: string, sourceMap: SourceMap): string {
218
+ try {
219
+ const rendered = katex.renderToString(latex, {
220
+ displayMode: true,
221
+ throwOnError: false,
222
+ output: "html",
223
+ trust: false,
224
+ });
225
+ return injectHtmlAttributes(
226
+ `<div class="katex-block">${rendered}</div>\n`,
227
+ [],
228
+ sourceMap,
229
+ );
230
+ } catch {
231
+ return injectHtmlAttributes(
232
+ `<div class="katex-block katex-error"><code>${escape(latex)}</code></div>\n`,
233
+ [],
234
+ sourceMap,
235
+ );
236
+ }
237
+ }
238
+
239
+ latexInline(latex: string): string {
240
+ try {
241
+ const rendered = katex.renderToString(latex, {
242
+ displayMode: false,
243
+ throwOnError: false,
244
+ output: "html",
245
+ trust: false,
246
+ });
247
+ return `<span class="katex-inline">${rendered}</span>`;
248
+ } catch {
249
+ return `<span class="katex-inline katex-error"><code>${escape(latex)}</code></span>`;
250
+ }
251
+ }
215
252
  }
@@ -16,7 +16,8 @@ type BlockRuleNames =
16
16
  | "bullet"
17
17
  | "listItemStart"
18
18
  | "footnote"
19
- | "paragraph";
19
+ | "paragraph"
20
+ | "latexBlock";
20
21
 
21
22
  export const label = /(?!\s*\])(?:\\.|[^\[\]\\])+/;
22
23
 
@@ -136,4 +137,5 @@ export const block: Record<BlockRuleNames, RegExp> = {
136
137
  paragraph: block_paragraph,
137
138
  footnote: /^\[\^([^\]\n]+)\]:(?:[ \t]+|[\n]*?|$)([^\n]*?(?:\n|$)(?:[\n]*?[ ]{4,}[^\n]*)*)/,
138
139
  text: /^[^\n]+/,
140
+ latexBlock: /^(?:\$\$([^$]*(?:\$(?!\$)[^$]*)*)\$\$|\\\[([\s\S]*?)\\\])/,
139
141
  };
@@ -19,7 +19,8 @@ type InlineRuleNames =
19
19
  | "punctuation"
20
20
  | "blockSkip"
21
21
  | "footnoteRef"
22
- | "backpedal";
22
+ | "backpedal"
23
+ | "latexInline";
23
24
 
24
25
  // list of unicode punctuation marks, plus any missing characters from CommonMark spec
25
26
  const punctuation = "\\p{P}\\p{S}";
@@ -112,7 +113,7 @@ const inline_backpedal =
112
113
  const inline_del = /^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/;
113
114
 
114
115
  const inline_text =
115
- /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/;
116
+ /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_$]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/;
116
117
 
117
118
  const inline_url = edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, "i")
118
119
  .replace("email", extended_email)
@@ -139,4 +140,5 @@ export const inline: Omit<Record<InlineRuleNames, RegExp>, "emStrong"> & {
139
140
  blockSkip: inline_blockSkip,
140
141
  backpedal: inline_backpedal,
141
142
  footnoteRef: /^\[\^([^\]\n]+)\]/,
143
+ latexInline: /^(?:\$(?!\$)([^\s$](?:[^$\n]*[^\s$])?)\$(?!\$)|\\\((.+?)\\\))/,
142
144
  };
package/src/tokenizer.ts CHANGED
@@ -716,6 +716,7 @@ export class Tokenizer {
716
716
 
717
717
  if (!nextChar || !prevChar || inline.punctuation.exec(prevChar)) {
718
718
  // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below)
719
+ // eslint-disable-next-line @typescript-eslint/no-misused-spread
719
720
  const lLength = [...match[0]].length - 1;
720
721
  let rDelim,
721
722
  rLength,
@@ -736,6 +737,7 @@ export class Tokenizer {
736
737
 
737
738
  if (!rDelim) continue; // skip single * in __abc*abc__
738
739
 
740
+ // eslint-disable-next-line @typescript-eslint/no-misused-spread
739
741
  rLength = [...rDelim].length;
740
742
 
741
743
  if (match[3] || match[4]) {
@@ -757,6 +759,7 @@ export class Tokenizer {
757
759
  // Remove extra characters. *a*** -> *a*
758
760
  rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal);
759
761
  // char length can be >1 for unicode characters;
762
+ // eslint-disable-next-line @typescript-eslint/no-misused-spread
760
763
  const lastCharLength = [...match[0]][0]!.length;
761
764
  const raw = src.slice(0, lLength + match.index + lastCharLength + rLength);
762
765
 
@@ -920,4 +923,33 @@ export class Tokenizer {
920
923
  text,
921
924
  };
922
925
  }
926
+
927
+ latexBlock(src: string): Tokens["LatexBlock"] | undefined {
928
+ const cap = block.latexBlock.exec(src);
929
+ if (!cap) return undefined;
930
+
931
+ // cap[1] is from $$...$$ syntax, cap[2] is from \[...\] syntax
932
+ const text = cap[1] ?? cap[2] ?? "";
933
+
934
+ return {
935
+ type: "latexBlock",
936
+ raw: cap[0],
937
+ text: text.trim(),
938
+ sourceMap: this.lexer.getSourceMap(cap[0]),
939
+ };
940
+ }
941
+
942
+ latexInline(src: string): Tokens["LatexInline"] | undefined {
943
+ const cap = inline.latexInline.exec(src);
944
+ if (!cap) return undefined;
945
+
946
+ // cap[1] is from $...$ syntax, cap[2] is from \(...\) syntax
947
+ const text = cap[1] ?? cap[2] ?? "";
948
+
949
+ return {
950
+ type: "latexInline",
951
+ raw: cap[0],
952
+ text,
953
+ };
954
+ }
923
955
  }
package/src/types.ts CHANGED
@@ -186,6 +186,17 @@ export interface Tokens extends Record<string, BaseToken> {
186
186
  raw: string;
187
187
  items: Tokens["Footnote"][];
188
188
  };
189
+ LatexBlock: {
190
+ type: "latexBlock";
191
+ raw: string;
192
+ text: string;
193
+ sourceMap: SourceMap;
194
+ };
195
+ LatexInline: {
196
+ type: "latexInline";
197
+ raw: string;
198
+ text: string;
199
+ };
189
200
  }
190
201
 
191
202
  export type Token =
@@ -214,7 +225,9 @@ export type Token =
214
225
  | Tokens["Alert"]
215
226
  | Tokens["Footnote"]
216
227
  | Tokens["FootnoteRef"]
217
- | Tokens["Footnotes"];
228
+ | Tokens["Footnotes"]
229
+ | Tokens["LatexBlock"]
230
+ | Tokens["LatexInline"];
218
231
 
219
232
  export type Links = Record<string, { href: string; title: string }>;
220
233