pantsdown 1.4.1 → 1.4.3

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 CHANGED
@@ -2,26 +2,42 @@
2
2
 
3
3
  # Pantsdown
4
4
 
5
+ <img src="https://raw.githubusercontent.com/wallpants/pantsdown/main/docs/github.svg" height="60px" align="right" />
6
+ <img src="https://raw.githubusercontent.com/wallpants/pantsdown/main/docs/markdown.svg" height="60px" align="right" />
7
+
5
8
  Pantsdown is a **Markdown** to **"GitHub HTML"** converter based on [Marked](https://github.com/markedjs/marked).
6
9
  Basically this is a non customizable version of Marked, optimized to render markdown similar to how GitHub does it plus
7
10
  some features developed specifically for [github-preview.nvim](https://github.com/wallpants/github-preview.nvim).
8
11
 
9
- Applicable fixes/updates released by Marked will eventually be applied to Pantsdown.
10
-
11
- If you need a feature that's supported by GitHub and is not already listed in Pantsdown's [Roadmap](#roadmap),
12
+ If you need a feature that's supported by GitHub and is not already listed in the [Roadmap](#roadmap),
12
13
  feel free to open an issue. If you need anything else, use Marked or another library.
13
14
 
15
+ ### [Demo](https://wallpants.github.io/pantsdown/)
16
+
14
17
  ## Installation
15
18
 
16
19
  🚨 Pantsdown is distributed only as a TypeScript module. This means you'll need a bundler to handle transpilation.
17
20
  See below for usage examples.
18
21
 
19
22
  ```sh
23
+ # bun
20
24
  bun install pantsdown
25
+ # npm
26
+ npm install pantsdown
21
27
  ```
22
28
 
23
29
  ## Usage
24
30
 
31
+ ### Styles
32
+
33
+ For styles to be properly applied, either the element containing the generated html or one of its parents
34
+ must have the classes `class="pantsdown light"` or `class="pantsdown dark"` added.
35
+
36
+ ### [Bun](https://bun.sh/)
37
+
38
+ Take a look at [how Pantsdown's demo is built](https://github.com/wallpants/pantsdown/blob/main/docs/build.ts)
39
+ for a very simple usage example with Bun.
40
+
25
41
  ### [Vite](https://vitejs.dev/guide/#scaffolding-your-first-vite-project)
26
42
 
27
43
  Create a Vite Project & install dependencies:
@@ -64,6 +80,8 @@ function App() {
64
80
  if (container) container.innerHTML = html;
65
81
  }, []);
66
82
 
83
+ // 🚨 for styles to be applied, a parent element must have
84
+ // the classes "pantsdown light" or "pantsdown dark" added
67
85
  return <div id="markdown-container" className="pantsdown light" />;
68
86
  }
69
87
 
@@ -87,24 +105,35 @@ const config: PantsdownConfig = {
87
105
  * relativeImageUrlPrefix: "/__localimage__/"
88
106
  *
89
107
  * ![image](./wallpants-512.png)
90
- * relative src is updated and result in:
108
+ * relative src is updated and results in:
91
109
  * <img src="/__localimage__/wallpants-512.png" />
92
110
  *
93
111
  * ![image](https://avatars.githubusercontent.com/wallpants)
94
- * absolute path remains unchanged:
112
+ * absolute src remains unchanged:
95
113
  * <img src="https://avatars.githubusercontent.com/wallpants" />
96
114
  */
97
115
  relativeImageUrlPrefix: "/__localimage__/",
116
+
117
+ /**
118
+ * Whether to render <details> html tags with attribute `open=""`
119
+ *
120
+ * @default
121
+ * false
122
+ */
123
+ detailsTagDefaultOpen: true,
98
124
  },
99
125
  };
100
126
 
101
127
  const pantsdown = new Pantsdown(config);
102
- pantsdown.parse(markdown);
128
+ const html = pantsdown.parse(markdown);
129
+
130
+ console.log(html);
103
131
  ```
104
132
 
105
133
  ## Roadmap
106
134
 
107
135
  - [x] Add id & anchors to headings
136
+ - [ ] Add sourcemap to list items
108
137
  - [ ] Add button to copy code in fences
109
138
  - [ ] [Mermaid](https://mermaid.js.org/) support
110
139
  - [ ] [Footnote](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#footnotes) support [Base](https://github.com/bent10/marked-extensions/tree/main/packages/footnote)
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pantsdown",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "author": "wallpants",
5
- "description": "The markdown parser",
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 = 0;
31
+ this.line = 1;
31
32
 
32
33
  src = src.replace(/\r\n|\r/g, "\n");
33
34
 
package/src/parser.ts CHANGED
@@ -76,7 +76,7 @@ export class Parser {
76
76
  }
77
77
  case "blockquote": {
78
78
  const body = this.parse(token.tokens);
79
- out += this.renderer.blockquote(body, token.sourceMap);
79
+ out += this.renderer.blockquote(body);
80
80
  continue;
81
81
  }
82
82
  case "list": {
@@ -122,7 +122,12 @@ export class Parser {
122
122
  }
123
123
 
124
124
  itemBody += this.parse(item.tokens, loose);
125
- body += this.renderer.listitem(itemBody, task, Boolean(checked));
125
+ body += this.renderer.listitem(
126
+ itemBody,
127
+ task,
128
+ Boolean(checked),
129
+ token.sourceMap![0] + j,
130
+ );
126
131
  }
127
132
 
128
133
  const listClasses: string[] = [];
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, sourceMap: SourceMap): string {
49
- return `<blockquote${renderSourceMap(sourceMap)}>\n${quote}</blockquote>\n`;
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
- let result = fixHtmlLocalImageHref(html, this.rendererConfig.relativeImageUrlPrefix);
55
+ const result = fixHtmlLocalImageHref(html, this.rendererConfig.relativeImageUrlPrefix);
56
+
57
+ const htmlAttributes: [name: string, value: string | number][] = [];
58
+
54
59
  if (sourceMap) {
55
- result = injectHtmlAttributes(result, [
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
- return result;
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 {
@@ -96,10 +106,12 @@ export class Renderer {
96
106
  );
97
107
  }
98
108
 
99
- listitem(text: string, task: boolean, _checked: boolean): string {
109
+ listitem(text: string, task: boolean, _checked: boolean, lineStart: number): string {
100
110
  const classes: string[] = [];
101
111
  if (task) classes.push("task-list-item");
102
- return `<li${renderHtmlClasses(classes)}>${text}</li>\n`;
112
+ return `<li${renderHtmlClasses(
113
+ classes,
114
+ )} line-start="${lineStart}" line-end="${lineStart}">${text}</li>\n`;
103
115
  }
104
116
 
105
117
  checkbox(checked: boolean, classes: string[] = []): 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
  * ![image](./wallpants-512.png)
200
- * relative src is updated and result in:
199
+ * relative src is updated and results in:
201
200
  * <img src="/__localimage__/wallpants-512.png" />
202
201
  *
203
202
  * ![image](https://avatars.githubusercontent.com/wallpants)
204
- * absolute path remains unchanged:
203
+ * absolute src remains unchanged:
205
204
  * <img src="https://avatars.githubusercontent.com/wallpants" />
206
205
  */
207
- relativeImageUrlPrefix: string;
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
- try {
69
- const element = parseHtmlElement(html);
70
- attrs.forEach(([name, value]) => {
71
- element.setAttribute(name, JSON.stringify(value));
72
- });
73
- return element.outerHTML;
74
- } catch (_) {
75
- return html;
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(html: string, relativeImageUrlPrefix: string): string {
80
- return html.replace(
81
- /<img\s+([^>]*?)src\s*=\s*(["'])([^\2>]+?)\2([^>]*)>/gm,
82
- (_m, g1, _g2, g3: string, g4) => {
83
- const href = fixLocalImageHref(g3, relativeImageUrlPrefix);
84
- return `<img ${g1}src="${href}"${g4}>`;
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(href: string, relativeImageUrlPrefix: string): string {
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
@@ -1,11 +0,0 @@
1
- {
2
- "tabWidth": 4,
3
- "printWidth": 100,
4
- "plugins": ["prettier-plugin-organize-imports"],
5
- "overrides": [
6
- {
7
- "files": ["*.json"],
8
- "options": { "tabWidth": 2 }
9
- }
10
- ]
11
- }
package/bun.lockb DELETED
Binary file