pantsdown 2.2.6 → 2.2.8
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 +2 -0
- package/package.json +20 -18
- package/src/lexer.ts +47 -51
- package/src/parser.ts +51 -125
- package/src/renderer.ts +133 -63
- package/src/rules/block.ts +41 -34
- package/src/rules/inline.ts +93 -29
- package/src/rules/other.ts +79 -0
- package/src/rules/utils.ts +2 -2
- package/src/text-renderer.ts +57 -0
- package/src/tokenizer.ts +352 -175
- package/src/types.ts +12 -2
- package/src/utils.ts +62 -40
- package/.oxfmtrc.json +0 -17
- package/CLAUDE.md +0 -73
- package/bun.lock +0 -1504
- package/eslint.config.ts +0 -46
- package/tsconfig.json +0 -32
package/src/types.ts
CHANGED
|
@@ -9,7 +9,7 @@ type BaseToken = {
|
|
|
9
9
|
|
|
10
10
|
export type SourceMap = [start: number, end: number] | undefined;
|
|
11
11
|
|
|
12
|
-
// eslint-disable-next-line
|
|
12
|
+
// eslint-disable-next-line typescript/consistent-type-definitions
|
|
13
13
|
export interface Tokens extends Record<string, BaseToken> {
|
|
14
14
|
Space: {
|
|
15
15
|
type: "space";
|
|
@@ -52,6 +52,8 @@ export interface Tokens extends Record<string, BaseToken> {
|
|
|
52
52
|
raw: string;
|
|
53
53
|
text: string;
|
|
54
54
|
tokens: Token[];
|
|
55
|
+
header: boolean;
|
|
56
|
+
align: "center" | "left" | "right" | null;
|
|
55
57
|
};
|
|
56
58
|
Hr: {
|
|
57
59
|
type: "hr";
|
|
@@ -103,6 +105,7 @@ export interface Tokens extends Record<string, BaseToken> {
|
|
|
103
105
|
raw: string;
|
|
104
106
|
text: string;
|
|
105
107
|
tokens?: Token[];
|
|
108
|
+
escaped?: boolean;
|
|
106
109
|
sourceMap?: SourceMap;
|
|
107
110
|
};
|
|
108
111
|
Def: {
|
|
@@ -119,13 +122,18 @@ export interface Tokens extends Record<string, BaseToken> {
|
|
|
119
122
|
text: string;
|
|
120
123
|
};
|
|
121
124
|
Tag: {
|
|
122
|
-
type: "
|
|
125
|
+
type: "html";
|
|
123
126
|
raw: string;
|
|
124
127
|
text: string;
|
|
125
128
|
inLink: boolean;
|
|
126
129
|
inRawBlock: boolean;
|
|
127
130
|
block: boolean;
|
|
128
131
|
};
|
|
132
|
+
Checkbox: {
|
|
133
|
+
type: "checkbox";
|
|
134
|
+
raw: string;
|
|
135
|
+
checked: boolean;
|
|
136
|
+
};
|
|
129
137
|
Link: {
|
|
130
138
|
type: "link";
|
|
131
139
|
raw: string;
|
|
@@ -140,6 +148,7 @@ export interface Tokens extends Record<string, BaseToken> {
|
|
|
140
148
|
text: string;
|
|
141
149
|
href: string;
|
|
142
150
|
title: string | null;
|
|
151
|
+
tokens: Token[];
|
|
143
152
|
};
|
|
144
153
|
Strong: {
|
|
145
154
|
type: "strong";
|
|
@@ -222,6 +231,7 @@ export type Token =
|
|
|
222
231
|
| Tokens["Codespan"]
|
|
223
232
|
| Tokens["Br"]
|
|
224
233
|
| Tokens["Del"]
|
|
234
|
+
| Tokens["Checkbox"]
|
|
225
235
|
| Tokens["Alert"]
|
|
226
236
|
| Tokens["Footnote"]
|
|
227
237
|
| Tokens["FootnoteRef"]
|
package/src/utils.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { type Lexer } from "./lexer.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { other } from "./rules/other.ts";
|
|
3
|
+
import { type HTMLAttrs, type PantsdownConfig, type SourceMap, type Tokens } from "./types.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Helpers
|
|
6
7
|
*/
|
|
7
|
-
const escapeTest = /[&<>"']/;
|
|
8
|
-
const escapeReplace = new RegExp(escapeTest.source, "g");
|
|
9
|
-
const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/;
|
|
10
|
-
const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, "g");
|
|
11
8
|
const escapeReplacements: Record<string, string> = {
|
|
12
9
|
"&": "&",
|
|
13
10
|
"<": "<",
|
|
@@ -21,12 +18,12 @@ const getEscapeReplacement = (ch: string) => escapeReplacements[ch]!;
|
|
|
21
18
|
// https://bun.sh/docs/api/utils#bun-escapehtml
|
|
22
19
|
export function escape(html: string, encode?: boolean) {
|
|
23
20
|
if (encode) {
|
|
24
|
-
if (escapeTest.test(html)) {
|
|
25
|
-
return html.replace(escapeReplace, getEscapeReplacement);
|
|
21
|
+
if (other.escapeTest.test(html)) {
|
|
22
|
+
return html.replace(other.escapeReplace, getEscapeReplacement);
|
|
26
23
|
}
|
|
27
24
|
} else {
|
|
28
|
-
if (escapeTestNoEncode.test(html)) {
|
|
29
|
-
return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
|
|
25
|
+
if (other.escapeTestNoEncode.test(html)) {
|
|
26
|
+
return html.replace(other.escapeReplaceNoEncode, getEscapeReplacement);
|
|
30
27
|
}
|
|
31
28
|
}
|
|
32
29
|
|
|
@@ -37,10 +34,7 @@ export function getHtmlElementText(html: string) {
|
|
|
37
34
|
try {
|
|
38
35
|
const parser = new DOMParser();
|
|
39
36
|
const doc = parser.parseFromString(html, "text/html");
|
|
40
|
-
|
|
41
|
-
if (!doc.body) throw Error("Invalid HTML");
|
|
42
|
-
const element = doc.body.firstChild as HTMLElement;
|
|
43
|
-
// eslint-disable-next-line
|
|
37
|
+
const element = doc.body.firstChild;
|
|
44
38
|
if (!element) throw Error("No valid element found");
|
|
45
39
|
return element.textContent || html;
|
|
46
40
|
} catch (_) {
|
|
@@ -56,12 +50,15 @@ export function injectHtmlAttributes(html: string, attrs: HTMLAttrs, sourceMap?:
|
|
|
56
50
|
|
|
57
51
|
if (!attrs.length) return html;
|
|
58
52
|
|
|
53
|
+
// closing tags cannot carry attributes
|
|
54
|
+
if (/^\s*<\//.test(html)) return html;
|
|
55
|
+
|
|
59
56
|
const closingBracket = /[a-zA-Z0-9\/"]>/;
|
|
60
57
|
const match = closingBracket.exec(html);
|
|
61
58
|
if (match) {
|
|
62
59
|
let htmlAttrs = "";
|
|
63
60
|
attrs.forEach((atrr) => (htmlAttrs += ` ${atrr[0]}="${atrr[1]}"`));
|
|
64
|
-
const sliceIdx = match.index + (match[0] === "/>" ?
|
|
61
|
+
const sliceIdx = match.index + (match[0] === "/>" ? 0 : 1);
|
|
65
62
|
return html.slice(0, sliceIdx) + htmlAttrs + html.slice(sliceIdx);
|
|
66
63
|
}
|
|
67
64
|
return html;
|
|
@@ -150,7 +147,7 @@ export function fixLocalImageHref(href: string, config: PantsdownConfig): string
|
|
|
150
147
|
|
|
151
148
|
export function cleanUrl(href: string) {
|
|
152
149
|
try {
|
|
153
|
-
href = encodeURI(href).replace(
|
|
150
|
+
href = encodeURI(href).replace(other.percentDecode, "%");
|
|
154
151
|
} catch (_) {
|
|
155
152
|
return null;
|
|
156
153
|
}
|
|
@@ -162,7 +159,7 @@ export const noopTest = { exec: () => null };
|
|
|
162
159
|
export function splitCells(tableRow: string, count?: number) {
|
|
163
160
|
// ensure that every cell-delimiting pipe has a space
|
|
164
161
|
// before it to distinguish it from an escaped pipe
|
|
165
|
-
const row = tableRow.replace(
|
|
162
|
+
const row = tableRow.replace(other.findPipe, (_match, offset: number, str: string) => {
|
|
166
163
|
let escaped = false;
|
|
167
164
|
let curr = offset;
|
|
168
165
|
while (--curr >= 0 && str[curr] === "\\") escaped = !escaped;
|
|
@@ -175,7 +172,7 @@ export function splitCells(tableRow: string, count?: number) {
|
|
|
175
172
|
return " |";
|
|
176
173
|
}
|
|
177
174
|
}),
|
|
178
|
-
cells = row.split(
|
|
175
|
+
cells = row.split(other.splitPipe);
|
|
179
176
|
|
|
180
177
|
// First/last cell in a row cannot be empty if it has no leading/trailing pipe
|
|
181
178
|
if (!cells[0]?.trim()) {
|
|
@@ -195,7 +192,7 @@ export function splitCells(tableRow: string, count?: number) {
|
|
|
195
192
|
|
|
196
193
|
for (let i = 0, len = cells.length; i < len; i++) {
|
|
197
194
|
// leading or trailing whitespace is ignored per the gfm spec
|
|
198
|
-
cells[i] = cells[i]!.trim().replace(
|
|
195
|
+
cells[i] = cells[i]!.trim().replace(other.slashPipe, "|");
|
|
199
196
|
}
|
|
200
197
|
return cells;
|
|
201
198
|
}
|
|
@@ -232,6 +229,20 @@ export function rtrim(str: string, c: string, invert?: boolean) {
|
|
|
232
229
|
return str.slice(0, l - suffLen);
|
|
233
230
|
}
|
|
234
231
|
|
|
232
|
+
export function trimTrailingBlankLines(str: string) {
|
|
233
|
+
const lines = str.split("\n");
|
|
234
|
+
let end = lines.length - 1;
|
|
235
|
+
while (end >= 0 && other.blankLine.test(lines[end]!)) {
|
|
236
|
+
end--;
|
|
237
|
+
}
|
|
238
|
+
if (lines.length - end <= 2) {
|
|
239
|
+
// we want to keep single trailing blank lines
|
|
240
|
+
return str;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return lines.slice(0, end + 1).join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
235
246
|
export function findClosingBracket(str: string, b: string) {
|
|
236
247
|
if (!b[1] || !str.includes(b[1])) {
|
|
237
248
|
return -1;
|
|
@@ -250,6 +261,9 @@ export function findClosingBracket(str: string, b: string) {
|
|
|
250
261
|
}
|
|
251
262
|
}
|
|
252
263
|
}
|
|
264
|
+
if (level > 0) {
|
|
265
|
+
return -2;
|
|
266
|
+
}
|
|
253
267
|
return -1;
|
|
254
268
|
}
|
|
255
269
|
|
|
@@ -260,33 +274,24 @@ export function outputLink(
|
|
|
260
274
|
lexer: Lexer,
|
|
261
275
|
): Tokens["Link"] | Tokens["Image"] {
|
|
262
276
|
const href = link.href;
|
|
263
|
-
const title = link.title
|
|
264
|
-
const text = cap[1]?.replace(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
type: "link",
|
|
270
|
-
raw,
|
|
271
|
-
href,
|
|
272
|
-
title,
|
|
273
|
-
text,
|
|
274
|
-
tokens: lexer.inlineTokens(text),
|
|
275
|
-
};
|
|
276
|
-
lexer.state.inLink = false;
|
|
277
|
-
return token;
|
|
278
|
-
}
|
|
279
|
-
return {
|
|
280
|
-
type: "image",
|
|
277
|
+
const title = link.title || null;
|
|
278
|
+
const text = cap[1]?.replace(other.outputLinkReplace, "$1") ?? "";
|
|
279
|
+
|
|
280
|
+
lexer.state.inLink = true;
|
|
281
|
+
const token: Tokens["Link"] | Tokens["Image"] = {
|
|
282
|
+
type: cap[0]?.charAt(0) === "!" ? "image" : "link",
|
|
281
283
|
raw,
|
|
282
284
|
href,
|
|
283
285
|
title,
|
|
284
|
-
text
|
|
286
|
+
text,
|
|
287
|
+
tokens: lexer.inlineTokens(text),
|
|
285
288
|
};
|
|
289
|
+
lexer.state.inLink = false;
|
|
290
|
+
return token;
|
|
286
291
|
}
|
|
287
292
|
|
|
288
293
|
export function indentCodeCompensation(raw: string, text: string) {
|
|
289
|
-
const matchIndentToCode =
|
|
294
|
+
const matchIndentToCode = other.indentCodeCompensation.exec(raw);
|
|
290
295
|
|
|
291
296
|
if (matchIndentToCode === null) {
|
|
292
297
|
return text;
|
|
@@ -297,7 +302,7 @@ export function indentCodeCompensation(raw: string, text: string) {
|
|
|
297
302
|
return text
|
|
298
303
|
.split("\n")
|
|
299
304
|
.map((node) => {
|
|
300
|
-
const matchIndentInNode =
|
|
305
|
+
const matchIndentInNode = other.beginningSpace.exec(node);
|
|
301
306
|
if (matchIndentInNode === null) {
|
|
302
307
|
return node;
|
|
303
308
|
}
|
|
@@ -314,7 +319,7 @@ export function indentCodeCompensation(raw: string, text: string) {
|
|
|
314
319
|
}
|
|
315
320
|
|
|
316
321
|
function makeAlertRegex(type: string) {
|
|
317
|
-
return new RegExp(`^(?:\\[\\!${type.toUpperCase()}\\]|[\\*]{2}${type}[\\*]{2})[
|
|
322
|
+
return new RegExp(`^(?:\\[\\!${type.toUpperCase()}\\]|[\\*]{2}${type}[\\*]{2})[ \\t]*\\n?`);
|
|
318
323
|
}
|
|
319
324
|
|
|
320
325
|
export const ALERTS = [
|
|
@@ -344,3 +349,20 @@ export const ALERTS = [
|
|
|
344
349
|
icon: '<svg class="octicon octicon-alert" style="margin-right: 0.5rem;" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/></svg>',
|
|
345
350
|
},
|
|
346
351
|
];
|
|
352
|
+
|
|
353
|
+
export function expandTabs(line: string, indent = 0) {
|
|
354
|
+
let col = indent;
|
|
355
|
+
let expanded = "";
|
|
356
|
+
for (const char of line) {
|
|
357
|
+
if (char === "\t") {
|
|
358
|
+
const added = 4 - (col % 4);
|
|
359
|
+
expanded += " ".repeat(added);
|
|
360
|
+
col += added;
|
|
361
|
+
} else {
|
|
362
|
+
expanded += char;
|
|
363
|
+
col++;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return expanded;
|
|
368
|
+
}
|
package/.oxfmtrc.json
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
3
|
-
"ignorePatterns": [".github"],
|
|
4
|
-
"printWidth": 100,
|
|
5
|
-
"tabWidth": 3,
|
|
6
|
-
"sortImports": {
|
|
7
|
-
"newlinesBetween": false
|
|
8
|
-
},
|
|
9
|
-
"overrides": [
|
|
10
|
-
{
|
|
11
|
-
"files": ["*.json", "*.md"],
|
|
12
|
-
"options": {
|
|
13
|
-
"tabWidth": 2
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
]
|
|
17
|
-
}
|
package/CLAUDE.md
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
|
|
68
|
-
- `html`: The rendered HTML string
|
|
69
|
-
- `javascript`: A script for interactive features (task list checkboxes, copy buttons)
|
|
70
|
-
|
|
71
|
-
## Styling
|
|
72
|
-
|
|
73
|
-
CSS is in `src/css/styles.css`. Requires a parent element with classes `pantsdown light` or `pantsdown dark` (optionally with `high-contrast`).
|