pantsdown 1.4.1 → 1.4.2
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 +31 -6
- package/package.json +4 -3
- package/src/lexer.ts +4 -3
- package/src/parser.ts +1 -1
- package/src/renderer.ts +18 -8
- package/src/tokenizer.ts +61 -1
- package/src/types.ts +11 -4
- package/src/utils.ts +35 -17
- package/.eslintrc.json +0 -26
- package/.github/workflows/release.yaml +0 -45
- package/.prettierrc.json +0 -11
- package/bun.lockb +0 -0
package/README.md
CHANGED
|
@@ -6,22 +6,35 @@ Pantsdown is a **Markdown** to **"GitHub HTML"** converter based on [Marked](htt
|
|
|
6
6
|
Basically this is a non customizable version of Marked, optimized to render markdown similar to how GitHub does it plus
|
|
7
7
|
some features developed specifically for [github-preview.nvim](https://github.com/wallpants/github-preview.nvim).
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
If you need a feature that's supported by GitHub and is not already listed in Pantsdown's [Roadmap](#roadmap),
|
|
9
|
+
If you need a feature that's supported by GitHub and is not already listed in the [Roadmap](#roadmap),
|
|
12
10
|
feel free to open an issue. If you need anything else, use Marked or another library.
|
|
13
11
|
|
|
12
|
+
### [Demo](https://wallpants.github.io/pantsdown/)
|
|
13
|
+
|
|
14
14
|
## Installation
|
|
15
15
|
|
|
16
16
|
🚨 Pantsdown is distributed only as a TypeScript module. This means you'll need a bundler to handle transpilation.
|
|
17
17
|
See below for usage examples.
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
|
+
# bun
|
|
20
21
|
bun install pantsdown
|
|
22
|
+
# npm
|
|
23
|
+
npm install pantsdown
|
|
21
24
|
```
|
|
22
25
|
|
|
23
26
|
## Usage
|
|
24
27
|
|
|
28
|
+
### Styles
|
|
29
|
+
|
|
30
|
+
For styles to be properly applied, either the element containing the generated html or one of its parents
|
|
31
|
+
must have the classes `class="pantsdown light"` or `class="pantsdown dark"` added.
|
|
32
|
+
|
|
33
|
+
### [Bun](https://bun.sh/)
|
|
34
|
+
|
|
35
|
+
Take a look at [how Pantsdown's demo is built](https://github.com/wallpants/pantsdown/blob/main/docs/build.ts)
|
|
36
|
+
for a very simple usage example with Bun.
|
|
37
|
+
|
|
25
38
|
### [Vite](https://vitejs.dev/guide/#scaffolding-your-first-vite-project)
|
|
26
39
|
|
|
27
40
|
Create a Vite Project & install dependencies:
|
|
@@ -64,6 +77,8 @@ function App() {
|
|
|
64
77
|
if (container) container.innerHTML = html;
|
|
65
78
|
}, []);
|
|
66
79
|
|
|
80
|
+
// 🚨 for styles to be applied, a parent element must have
|
|
81
|
+
// the classes "pantsdown light" or "pantsdown dark" added
|
|
67
82
|
return <div id="markdown-container" className="pantsdown light" />;
|
|
68
83
|
}
|
|
69
84
|
|
|
@@ -87,19 +102,29 @@ const config: PantsdownConfig = {
|
|
|
87
102
|
* relativeImageUrlPrefix: "/__localimage__/"
|
|
88
103
|
*
|
|
89
104
|
* 
|
|
90
|
-
* relative src is updated and
|
|
105
|
+
* relative src is updated and results in:
|
|
91
106
|
* <img src="/__localimage__/wallpants-512.png" />
|
|
92
107
|
*
|
|
93
108
|
* 
|
|
94
|
-
* absolute
|
|
109
|
+
* absolute src remains unchanged:
|
|
95
110
|
* <img src="https://avatars.githubusercontent.com/wallpants" />
|
|
96
111
|
*/
|
|
97
112
|
relativeImageUrlPrefix: "/__localimage__/",
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether to render <details> html tags with attribute `open=""`
|
|
116
|
+
*
|
|
117
|
+
* @default
|
|
118
|
+
* false
|
|
119
|
+
*/
|
|
120
|
+
detailsTagDefaultOpen: true,
|
|
98
121
|
},
|
|
99
122
|
};
|
|
100
123
|
|
|
101
124
|
const pantsdown = new Pantsdown(config);
|
|
102
|
-
pantsdown.parse(markdown);
|
|
125
|
+
const html = pantsdown.parse(markdown);
|
|
126
|
+
|
|
127
|
+
console.log(html);
|
|
103
128
|
```
|
|
104
129
|
|
|
105
130
|
## Roadmap
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantsdown",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"author": "wallpants",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Markdown to 'GitHub HTML' parser",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"repository": {
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"format": "prettier **/*{ts,js} --write",
|
|
31
31
|
"lint": "tsc && eslint . --ext ts --report-unused-disable-directives --max-warnings 0",
|
|
32
|
-
"commit": "cz"
|
|
32
|
+
"commit": "cz",
|
|
33
|
+
"docs:build": "bun run docs/build.ts"
|
|
33
34
|
},
|
|
34
35
|
"release": {
|
|
35
36
|
"branches": [
|
package/src/lexer.ts
CHANGED
|
@@ -5,10 +5,10 @@ import { type Links, type SourceMap, type Token } from "./types.ts";
|
|
|
5
5
|
export class Lexer {
|
|
6
6
|
private tokenizer: Tokenizer;
|
|
7
7
|
private inlineQueue: { src: string; tokens: Token[] }[];
|
|
8
|
-
private tokens: Token[] = [];
|
|
9
8
|
private links: Links = {};
|
|
10
|
-
private line = 0;
|
|
11
9
|
|
|
10
|
+
tokens: Token[] = [];
|
|
11
|
+
line = 1;
|
|
12
12
|
state = {
|
|
13
13
|
inLink: false,
|
|
14
14
|
inRawBlock: false,
|
|
@@ -25,9 +25,10 @@ export class Lexer {
|
|
|
25
25
|
*/
|
|
26
26
|
lex(src: string) {
|
|
27
27
|
// reset values from previous parse
|
|
28
|
+
this.tokenizer.pendingHtmlClose = [];
|
|
28
29
|
this.tokens = [];
|
|
29
30
|
this.links = {};
|
|
30
|
-
this.line =
|
|
31
|
+
this.line = 1;
|
|
31
32
|
|
|
32
33
|
src = src.replace(/\r\n|\r/g, "\n");
|
|
33
34
|
|
package/src/parser.ts
CHANGED
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 { inline } from "./rules/inline.ts";
|
|
3
4
|
import { type PantsdownConfig, type SourceMap } from "./types.ts";
|
|
4
5
|
import {
|
|
5
6
|
cleanUrl,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
|
|
15
16
|
const defaultConfig: NonNullable<PantsdownConfig["renderer"]> = {
|
|
16
17
|
relativeImageUrlPrefix: "",
|
|
18
|
+
detailsTagDefaultOpen: false,
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -45,19 +47,27 @@ export class Renderer {
|
|
|
45
47
|
);
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
blockquote(quote: string
|
|
49
|
-
return `<blockquote
|
|
50
|
+
blockquote(quote: string): string {
|
|
51
|
+
return `<blockquote>\n${quote}</blockquote>\n`;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
html(html: string, _block: boolean, sourceMap?: SourceMap | undefined): string {
|
|
53
|
-
|
|
55
|
+
const result = fixHtmlLocalImageHref(html, this.rendererConfig.relativeImageUrlPrefix);
|
|
56
|
+
|
|
57
|
+
const htmlAttributes: [name: string, value: string | number][] = [];
|
|
58
|
+
|
|
54
59
|
if (sourceMap) {
|
|
55
|
-
|
|
56
|
-
["line-start", sourceMap[0]],
|
|
57
|
-
["line-end", sourceMap[1]],
|
|
58
|
-
]);
|
|
60
|
+
htmlAttributes.push(["line-start", sourceMap[0]], ["line-end", sourceMap[1]]);
|
|
59
61
|
}
|
|
60
|
-
|
|
62
|
+
|
|
63
|
+
if (this.rendererConfig.detailsTagDefaultOpen) {
|
|
64
|
+
const tag = inline.tag.exec(html);
|
|
65
|
+
if (tag?.[0] === "<details>") {
|
|
66
|
+
htmlAttributes.push(["open", ""]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return injectHtmlAttributes(result, htmlAttributes);
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
heading(text: string, level: number, sourceMap: SourceMap): string {
|
package/src/tokenizer.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
*/
|
|
17
17
|
export class Tokenizer {
|
|
18
18
|
private lexer: Lexer;
|
|
19
|
+
pendingHtmlClose: [tag: string, index: number][] = [];
|
|
19
20
|
|
|
20
21
|
constructor(lexer: Lexer) {
|
|
21
22
|
this.lexer = lexer;
|
|
@@ -108,12 +109,12 @@ export class Tokenizer {
|
|
|
108
109
|
this.lexer.state.top = true;
|
|
109
110
|
const tokens = this.lexer.blockTokens(text, []);
|
|
110
111
|
this.lexer.state.top = top;
|
|
112
|
+
this.lexer.line++;
|
|
111
113
|
return {
|
|
112
114
|
type: "blockquote",
|
|
113
115
|
raw: cap[0],
|
|
114
116
|
tokens,
|
|
115
117
|
text,
|
|
116
|
-
sourceMap: this.lexer.getSourceMap(cap[0]),
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -333,6 +334,65 @@ export class Tokenizer {
|
|
|
333
334
|
text: cap[0],
|
|
334
335
|
sourceMap: this.lexer.getSourceMap(cap[0]),
|
|
335
336
|
};
|
|
337
|
+
|
|
338
|
+
/*
|
|
339
|
+
* Sometimes an html token does not contain its closing tag.
|
|
340
|
+
*
|
|
341
|
+
* The following markdown:
|
|
342
|
+
*
|
|
343
|
+
* 130 <details>
|
|
344
|
+
* 131 <summary><h4>Hello World</h4></summary>
|
|
345
|
+
* 132
|
|
346
|
+
* 133 Here some non-html markdown
|
|
347
|
+
* 134 </details>
|
|
348
|
+
*
|
|
349
|
+
* would result in 3 tokens:
|
|
350
|
+
* [
|
|
351
|
+
* {
|
|
352
|
+
* type: "html",
|
|
353
|
+
* raw: "<details>↵ <summary><h4>PUT</h4></summary>↵↵",
|
|
354
|
+
* sourceMap: [130, 131]
|
|
355
|
+
* },
|
|
356
|
+
* {
|
|
357
|
+
* type: "paragraph",
|
|
358
|
+
* raw: "Here some non-html markdown",
|
|
359
|
+
* sourceMap: [133, 133]
|
|
360
|
+
* },
|
|
361
|
+
* {
|
|
362
|
+
* type: "html",
|
|
363
|
+
* raw: "</details>",
|
|
364
|
+
* }
|
|
365
|
+
* ]
|
|
366
|
+
*
|
|
367
|
+
* sourceMap metadata in first token will be incorrect, because it will
|
|
368
|
+
* not take its children into consideration when it should.
|
|
369
|
+
*
|
|
370
|
+
* To solve that we keep track of html tokens that are pending to be closed
|
|
371
|
+
* and update their sourceMap once they're closed.
|
|
372
|
+
*/
|
|
373
|
+
|
|
374
|
+
const capEndsWith = (str: string) => cap[0].trimEnd().endsWith(str);
|
|
375
|
+
|
|
376
|
+
const tag = inline.tag.exec(src);
|
|
377
|
+
const isHtmlClosed = capEndsWith(tag![0].slice(1));
|
|
378
|
+
|
|
379
|
+
if (!isHtmlClosed) {
|
|
380
|
+
// index where the token we just created will be inserted
|
|
381
|
+
const tokenIdx = this.lexer.tokens.length;
|
|
382
|
+
// first in last out
|
|
383
|
+
this.pendingHtmlClose.unshift([tag![0], tokenIdx]);
|
|
384
|
+
} else if (this.pendingHtmlClose.length) {
|
|
385
|
+
for (const [pendingTag, index] of this.pendingHtmlClose) {
|
|
386
|
+
if (capEndsWith(pendingTag.slice(1))) {
|
|
387
|
+
const updateToken = this.lexer.tokens[index] as Tokens["HTML"];
|
|
388
|
+
if (updateToken.sourceMap?.[1] && token.sourceMap?.[1]) {
|
|
389
|
+
updateToken.sourceMap[1] = token.sourceMap[1];
|
|
390
|
+
}
|
|
391
|
+
this.pendingHtmlClose.shift();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
336
396
|
return token;
|
|
337
397
|
}
|
|
338
398
|
|
package/src/types.ts
CHANGED
|
@@ -53,7 +53,6 @@ export interface Tokens extends Record<string, BaseToken> {
|
|
|
53
53
|
raw: string;
|
|
54
54
|
text: string;
|
|
55
55
|
tokens: Token[];
|
|
56
|
-
sourceMap: SourceMap;
|
|
57
56
|
};
|
|
58
57
|
List: {
|
|
59
58
|
type: "list";
|
|
@@ -197,13 +196,21 @@ export type PantsdownConfig = {
|
|
|
197
196
|
* relativeImageUrlPrefix: "/__localimage__/"
|
|
198
197
|
*
|
|
199
198
|
* 
|
|
200
|
-
* relative src is updated and
|
|
199
|
+
* relative src is updated and results in:
|
|
201
200
|
* <img src="/__localimage__/wallpants-512.png" />
|
|
202
201
|
*
|
|
203
202
|
* 
|
|
204
|
-
* absolute
|
|
203
|
+
* absolute src remains unchanged:
|
|
205
204
|
* <img src="https://avatars.githubusercontent.com/wallpants" />
|
|
206
205
|
*/
|
|
207
|
-
relativeImageUrlPrefix
|
|
206
|
+
relativeImageUrlPrefix?: string;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Whether to render <details> html tags with attribute `open=""`
|
|
210
|
+
*
|
|
211
|
+
* @default
|
|
212
|
+
* false
|
|
213
|
+
*/
|
|
214
|
+
detailsTagDefaultOpen?: boolean;
|
|
208
215
|
};
|
|
209
216
|
};
|
package/src/utils.ts
CHANGED
|
@@ -61,32 +61,50 @@ export function getHtmlElementText(html: string) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Only works when html element has no other attributes already defined.
|
|
66
|
+
* For the regex to work the element must be clean of attributes:
|
|
67
|
+
*
|
|
68
|
+
* <div>{whatever}</div>
|
|
69
|
+
*/
|
|
64
70
|
export function injectHtmlAttributes(
|
|
65
71
|
html: string,
|
|
66
72
|
attrs: [name: string, value: string | number][],
|
|
67
73
|
) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
if (!attrs.length) return html;
|
|
75
|
+
|
|
76
|
+
const closingBracket = /[a-zA-Z0-9]>|\/>/;
|
|
77
|
+
const match = html.match(closingBracket);
|
|
78
|
+
if (match) {
|
|
79
|
+
let htmlAttrs = "";
|
|
80
|
+
attrs.forEach((atrr) => (htmlAttrs += ` ${atrr[0]}=${atrr[1]}`));
|
|
81
|
+
const sliceIdx = match.index! + (match[0] === "/>" ? -1 : 1);
|
|
82
|
+
return html.slice(0, sliceIdx) + htmlAttrs + html.slice(sliceIdx);
|
|
76
83
|
}
|
|
84
|
+
return html;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
export function fixHtmlLocalImageHref(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
export function fixHtmlLocalImageHref(
|
|
88
|
+
html: string,
|
|
89
|
+
relativeImageUrlPrefix: string | undefined,
|
|
90
|
+
): string {
|
|
91
|
+
return relativeImageUrlPrefix
|
|
92
|
+
? html.replace(
|
|
93
|
+
/<img\s+([^>]*?)src\s*=\s*(["'])([^\2>]+?)\2([^>]*)>/gm,
|
|
94
|
+
(_m, g1, _g2, g3: string, g4) => {
|
|
95
|
+
const href = fixLocalImageHref(g3, relativeImageUrlPrefix);
|
|
96
|
+
return `<img ${g1}src="${href}"${g4}>`;
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
: html;
|
|
87
100
|
}
|
|
88
101
|
|
|
89
|
-
export function fixLocalImageHref(
|
|
102
|
+
export function fixLocalImageHref(
|
|
103
|
+
href: string,
|
|
104
|
+
relativeImageUrlPrefix: string | undefined,
|
|
105
|
+
): string {
|
|
106
|
+
if (!relativeImageUrlPrefix) return href;
|
|
107
|
+
|
|
90
108
|
const reIsAbsolute = /^[\w+]+:\/\//;
|
|
91
109
|
const dummyUrl = "http://__dummy__";
|
|
92
110
|
const dummyBaseUrl = new URL(relativeImageUrlPrefix, dummyUrl);
|
package/.eslintrc.json
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"root": true,
|
|
3
|
-
"parser": "@typescript-eslint/parser",
|
|
4
|
-
"parserOptions": {
|
|
5
|
-
"ecmaVersion": "latest",
|
|
6
|
-
"sourceType": "module",
|
|
7
|
-
"project": ["tsconfig.json"]
|
|
8
|
-
},
|
|
9
|
-
"extends": [
|
|
10
|
-
"eslint:recommended",
|
|
11
|
-
"plugin:@typescript-eslint/strict-type-checked",
|
|
12
|
-
"plugin:@typescript-eslint/stylistic-type-checked"
|
|
13
|
-
],
|
|
14
|
-
"rules": {
|
|
15
|
-
"@typescript-eslint/no-non-null-assertion": "off",
|
|
16
|
-
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
|
|
17
|
-
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
|
18
|
-
"@typescript-eslint/consistent-type-imports": [
|
|
19
|
-
"warn",
|
|
20
|
-
{ "prefer": "type-imports", "fixStyle": "inline-type-imports" }
|
|
21
|
-
],
|
|
22
|
-
"no-useless-escape": "off",
|
|
23
|
-
"no-control-regex": "off",
|
|
24
|
-
"no-cond-assign": "off"
|
|
25
|
-
}
|
|
26
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
name: Release
|
|
2
|
-
on:
|
|
3
|
-
pull_request:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- main
|
|
7
|
-
|
|
8
|
-
permissions:
|
|
9
|
-
contents: read
|
|
10
|
-
|
|
11
|
-
jobs:
|
|
12
|
-
Test:
|
|
13
|
-
runs-on: ubuntu-latest
|
|
14
|
-
steps:
|
|
15
|
-
- name: Checkout repo
|
|
16
|
-
uses: actions/checkout@v4
|
|
17
|
-
- name: Install bun
|
|
18
|
-
uses: oven-sh/setup-bun@v1
|
|
19
|
-
- name: Install dependencies
|
|
20
|
-
run: bun install
|
|
21
|
-
- name: Lint
|
|
22
|
-
run: bun run lint
|
|
23
|
-
- name: Test
|
|
24
|
-
run: bun test
|
|
25
|
-
|
|
26
|
-
Release:
|
|
27
|
-
permissions:
|
|
28
|
-
contents: write
|
|
29
|
-
needs: [Test]
|
|
30
|
-
if: |
|
|
31
|
-
github.ref == 'refs/heads/main' &&
|
|
32
|
-
github.event.repository.fork == false
|
|
33
|
-
runs-on: ubuntu-latest
|
|
34
|
-
steps:
|
|
35
|
-
- name: Checkout Code
|
|
36
|
-
uses: actions/checkout@v4
|
|
37
|
-
- name: Install bun
|
|
38
|
-
uses: oven-sh/setup-bun@v1
|
|
39
|
-
- name: Install dependencies
|
|
40
|
-
run: bun install
|
|
41
|
-
- name: Release
|
|
42
|
-
env:
|
|
43
|
-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
44
|
-
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
45
|
-
run: bun run semantic-release
|
package/.prettierrc.json
DELETED
package/bun.lockb
DELETED
|
Binary file
|