pantsdown 1.4.4 → 1.5.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/README.md CHANGED
@@ -5,12 +5,11 @@
5
5
  <img src="https://raw.githubusercontent.com/wallpants/pantsdown/main/docs/github.svg" height="60px" align="right" />
6
6
  <img src="https://raw.githubusercontent.com/wallpants/pantsdown/main/docs/markdown.svg" height="60px" align="right" />
7
7
 
8
- Pantsdown is a **Markdown** to **"GitHub HTML"** converter based on [Marked](https://github.com/markedjs/marked).
9
- Basically this is a non customizable version of Marked, optimized to render markdown similar to how GitHub does it plus
8
+ Pantsdown is a **Markdown** to **HTML** converter. It attempts to render markdown similar to how GitHub does it plus
10
9
  some features developed specifically for [github-preview.nvim](https://github.com/wallpants/github-preview.nvim).
11
10
 
12
11
  If you need a feature that's supported by GitHub and is not already listed in the [Roadmap](#roadmap),
13
- feel free to open an issue. If you need anything else, use Marked or another library.
12
+ feel free to open an issue.
14
13
 
15
14
  ### [Demo](https://wallpants.github.io/pantsdown/)
16
15
 
@@ -132,8 +131,6 @@ console.log(html);
132
131
 
133
132
  ## Roadmap
134
133
 
135
- - [x] Add id & anchors to headings
136
- - [ ] Add sourcemap to list items
137
134
  - [ ] Add button to copy code in fences
138
- - [ ] [Mermaid](https://mermaid.js.org/) support
135
+ - [ ] [Mermaid](https://mermaid.js.org/) usage docs
139
136
  - [ ] [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,6 +1,6 @@
1
1
  {
2
2
  "name": "pantsdown",
3
- "version": "1.4.4",
3
+ "version": "1.5.0",
4
4
  "author": "wallpants",
5
5
  "description": "Markdown to 'GitHub HTML' parser",
6
6
  "license": "MIT",
@@ -64,6 +64,13 @@
64
64
  word-wrap: break-word;
65
65
  }
66
66
 
67
+ .pantsdown .mermaid-container {
68
+ margin-bottom: 16px;
69
+ }
70
+
71
+ /* .pantsdown .mermaid { */
72
+ /* } */
73
+
67
74
  .pantsdown .octicon {
68
75
  display: inline-block;
69
76
  fill: currentColor;
package/src/parser.ts CHANGED
@@ -126,13 +126,13 @@ export class Parser {
126
126
  itemBody,
127
127
  task,
128
128
  Boolean(checked),
129
- token.sourceMap ? token.sourceMap[0] + j : undefined,
129
+ item.sourceMap,
130
130
  );
131
131
  }
132
132
 
133
133
  const listClasses: string[] = [];
134
134
  if (containsTaskList) listClasses.push("contains-task-list");
135
- out += this.renderer.list(body, ordered, start, token.sourceMap, listClasses);
135
+ out += this.renderer.list(body, ordered, start, listClasses);
136
136
  continue;
137
137
  }
138
138
  case "html": {
package/src/renderer.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import GithubSlugger from "github-slugger";
2
2
  import hljs from "highlight.js";
3
3
  import { inline } from "./rules/inline.ts";
4
- import { type PantsdownConfig, type SourceMap } from "./types.ts";
4
+ import { type HTMLAttrs, type PantsdownConfig, type SourceMap } from "./types.ts";
5
5
  import {
6
6
  cleanUrl,
7
7
  escape,
@@ -9,8 +9,6 @@ import {
9
9
  fixLocalImageHref,
10
10
  getHtmlElementText,
11
11
  injectHtmlAttributes,
12
- renderHtmlClasses,
13
- renderSourceMap,
14
12
  } from "./utils.ts";
15
13
 
16
14
  const defaultConfig: NonNullable<PantsdownConfig["renderer"]> = {
@@ -32,19 +30,27 @@ export class Renderer {
32
30
 
33
31
  code(code: string, infostring: string | undefined, sourceMap: SourceMap): string {
34
32
  const lang = (infostring ?? "").match(/^\S*/)?.[0];
35
-
36
33
  code = code.replace(/\n$/, "") + "\n";
37
34
 
35
+ if (lang === "mermaid") {
36
+ return injectHtmlAttributes(
37
+ `<section><div class="mermaid">${code}</div></section>`,
38
+ [["class", "mermaid-container"]],
39
+ sourceMap,
40
+ );
41
+ }
42
+
38
43
  const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
39
44
  const highlightedCode = hljs.highlight(code, { language }).value;
40
45
 
41
- return (
42
- `<pre${renderSourceMap(sourceMap)}><code class="hljs language-` +
46
+ const result =
47
+ `<pre><code class="hljs language-` +
43
48
  escape(language) +
44
49
  '">' +
45
50
  highlightedCode +
46
- "</code></pre>\n"
47
- );
51
+ "</code></pre>\n";
52
+
53
+ return injectHtmlAttributes(result, [], sourceMap);
48
54
  }
49
55
 
50
56
  blockquote(quote: string): string {
@@ -54,81 +60,64 @@ export class Renderer {
54
60
  html(html: string, _block: boolean, sourceMap?: SourceMap | undefined): string {
55
61
  const result = fixHtmlLocalImageHref(html, this.rendererConfig.relativeImageUrlPrefix);
56
62
 
57
- const htmlAttributes: [name: string, value: string | number][] = [];
58
-
59
- if (sourceMap) {
60
- htmlAttributes.push(["line-start", sourceMap[0]], ["line-end", sourceMap[1]]);
61
- }
63
+ const attrs: HTMLAttrs = [];
62
64
 
63
65
  if (this.rendererConfig.detailsTagDefaultOpen) {
64
66
  const tag = inline.tag.exec(html);
65
67
  if (tag?.[0] === "<details>") {
66
- htmlAttributes.push(["open", ""]);
68
+ attrs.push(["open", ""]);
67
69
  }
68
70
  }
69
71
 
70
- return injectHtmlAttributes(result, htmlAttributes);
72
+ return injectHtmlAttributes(result, attrs, sourceMap);
71
73
  }
72
74
 
73
75
  heading(text: string, level: number, sourceMap: SourceMap): string {
74
76
  const elementText = getHtmlElementText(text);
75
77
  const slug = this.slugger.slug(elementText);
76
- let result = `<h${level}${renderSourceMap(sourceMap)} style="position: relative;">`;
78
+ let result = `<h${level}>`;
77
79
  // span with negative top to add some offset when scrolling to #slug
78
80
  result += `<span style="position: absolute; top: -50px;" id="${slug}"></span>`;
79
81
  result += `${text}<a class="anchor octicon-link" href="#${slug}"></a>`;
80
82
  result += `</h${level}>\n`;
81
- return result;
83
+ return injectHtmlAttributes(result, [["style", "position: relative;"]], sourceMap);
82
84
  }
83
85
 
84
86
  hr(sourceMap: SourceMap): string {
85
- return `<hr${renderSourceMap(sourceMap)}>\n`;
87
+ return injectHtmlAttributes(`<hr>\n`, [], sourceMap);
86
88
  }
87
89
 
88
- list(
89
- body: string,
90
- ordered: boolean,
91
- start: number | "",
92
- sourceMap: SourceMap,
93
- classes: string[] = [],
94
- ): string {
90
+ list(body: string, ordered: boolean, start: number | "", classes: string[] = []): string {
95
91
  const type = ordered ? "ol" : "ul";
96
- const startatt = ordered && start !== 1 ? ' start="' + start + '"' : "";
97
- return (
98
- "<" +
99
- type +
100
- startatt +
101
- `${renderSourceMap(sourceMap)}${renderHtmlClasses(classes)}>\n` +
102
- body +
103
- "</" +
104
- type +
105
- ">\n"
106
- );
107
- }
108
-
109
- listitem(
110
- text: string,
111
- task: boolean,
112
- _checked: boolean,
113
- lineStart: number | undefined,
114
- ): string {
115
- const classes: string[] = [];
116
- if (task) classes.push("task-list-item");
117
- return `<li${renderHtmlClasses(classes)}${renderSourceMap(
118
- lineStart ? [lineStart, lineStart] : undefined,
119
- )}>${text}</li>\n`;
92
+ const attrs: HTMLAttrs = [["class", classes.join(" ")]];
93
+ if (ordered && start && start !== 1) {
94
+ attrs.push(["start", String(start)]);
95
+ }
96
+ const result = `<${type}>\n${body}</${type}>\n`;
97
+ return injectHtmlAttributes(result, attrs);
98
+ }
99
+
100
+ listitem(text: string, task: boolean, _checked: boolean, sourceMap: SourceMap): string {
101
+ const attrs: [string, string][] = [];
102
+ if (task) attrs.push(["class", "task-list-item"]);
103
+ const result = `<li>${text}</li>\n`;
104
+ return injectHtmlAttributes(result, attrs, sourceMap);
120
105
  }
121
106
 
122
107
  checkbox(checked: boolean, classes: string[] = []): string {
123
- return (
124
- "<input " +
125
- (checked ? 'checked="" ' : "") +
126
- `disabled="" type="checkbox"${renderHtmlClasses(classes)}>`
127
- );
108
+ const result = "<input>";
109
+ const attrs: HTMLAttrs = [
110
+ ["disabled", ""],
111
+ ["type", "checkbox"],
112
+ ["class", classes.join(" ")],
113
+ ];
114
+ if (checked) attrs.push(["checked", ""]);
115
+ return injectHtmlAttributes(result, attrs);
128
116
  }
129
117
 
130
118
  paragraph(text: string, sourceMap: SourceMap): string {
131
- return `<p${renderSourceMap(sourceMap)}>${text}</p>\n`;
119
+ const result = `<p>${text}</p>\n`;
120
+ return injectHtmlAttributes(result, [], sourceMap);
132
121
  }
133
122
 
134
123
  table(header: string, body: string): string {
@@ -137,9 +126,8 @@ export class Renderer {
137
126
  }
138
127
 
139
128
  tablerow(content: string, sourceMapStart: number | undefined): string {
140
- return `<tr${renderSourceMap(
141
- sourceMapStart ? [sourceMapStart, sourceMapStart] : undefined,
142
- )}>\n${content}</tr>\n`;
129
+ const sourceMap: SourceMap = sourceMapStart ? [sourceMapStart, sourceMapStart] : undefined;
130
+ return injectHtmlAttributes(`<tr>\n${content}</tr>\n`, [], sourceMap);
143
131
  }
144
132
 
145
133
  tablecell(
@@ -150,8 +138,10 @@ export class Renderer {
150
138
  },
151
139
  ): string {
152
140
  const type = flags.header ? "th" : "td";
153
- const tag = flags.align ? `<${type} align="${flags.align}">` : `<${type}>`;
154
- return tag + content + `</${type}>\n`;
141
+ const attrs: HTMLAttrs = [];
142
+ if (flags.align) attrs.push(["align", flags.align]);
143
+ const result = `<${type}>` + content + `</${type}>\n`;
144
+ return injectHtmlAttributes(result, attrs);
155
145
  }
156
146
 
157
147
  /**
@@ -182,13 +172,9 @@ export class Renderer {
182
172
  if (cleanHref === null) {
183
173
  return text;
184
174
  }
185
- href = cleanHref;
186
- let out = '<a href="' + href + '"';
187
- if (title) {
188
- out += ' title="' + title + '"';
189
- }
190
- out += ">" + text + "</a>";
191
- return out;
175
+ const attrs: HTMLAttrs = [["href", cleanHref]];
176
+ if (title) attrs.push(["title", title]);
177
+ return injectHtmlAttributes(`<a>${text}</a>`, attrs);
192
178
  }
193
179
 
194
180
  image(href: string, title: string | null, text: string): string {
@@ -196,15 +182,12 @@ export class Renderer {
196
182
  if (cleanHref === null) {
197
183
  return text;
198
184
  }
199
- href = fixLocalImageHref(cleanHref, this.rendererConfig.relativeImageUrlPrefix);
200
-
201
- let out = `<img src="${href}" alt="${text}"`;
202
-
203
- if (title) {
204
- out += ` title="${title}"`;
205
- }
206
- out += ">";
207
- return out;
185
+ const attrs: HTMLAttrs = [
186
+ ["src", fixLocalImageHref(cleanHref, this.rendererConfig.relativeImageUrlPrefix)],
187
+ ["alt", text],
188
+ ];
189
+ if (title) attrs.push(["title", title]);
190
+ return injectHtmlAttributes("<img>", attrs);
208
191
  }
209
192
 
210
193
  text(text: string): string {
package/src/tokenizer.ts CHANGED
@@ -132,7 +132,6 @@ export class Tokenizer {
132
132
  start: isordered ? +bull.slice(0, -1) : "",
133
133
  loose: false,
134
134
  items: [] as Tokens["ListItem"][],
135
- sourceMap: [0, 0], // updated below once token.raw is built
136
135
  };
137
136
 
138
137
  bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`;
@@ -294,10 +293,9 @@ export class Tokenizer {
294
293
  list.items[list.items.length - 1]!.text = itemContents.trimEnd();
295
294
  list.raw = list.raw.trimEnd();
296
295
 
297
- list.sourceMap = this.lexer.getSourceMap(list.raw);
298
-
299
296
  // Item child tokens handled here at end because we needed to have the final item to trim it first
300
297
  for (let i = 0, listItemsLen = list.items.length; i < listItemsLen; i++) {
298
+ list.items[i]!.sourceMap = this.lexer.getSourceMap(list.items[i]!.raw);
301
299
  this.lexer.state.top = false;
302
300
  list.items[i]!.tokens = this.lexer.blockTokens(list.items[i]!.text, []);
303
301
 
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export type HTMLAttrs = [name: string, value: string][];
2
+
1
3
  type BaseToken = {
2
4
  type: string;
3
5
  raw: string;
@@ -61,7 +63,6 @@ export interface Tokens extends Record<string, BaseToken> {
61
63
  start: number | "";
62
64
  loose: boolean;
63
65
  items: Tokens["ListItem"][];
64
- sourceMap: SourceMap;
65
66
  };
66
67
  ListItem: {
67
68
  type: "list_item";
@@ -71,6 +72,7 @@ export interface Tokens extends Record<string, BaseToken> {
71
72
  checked?: boolean | undefined;
72
73
  loose: boolean;
73
74
  tokens: Token[];
75
+ sourceMap?: SourceMap;
74
76
  };
75
77
  Paragraph: {
76
78
  type: "paragraph";
package/src/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Lexer } from "./lexer.ts";
2
- import { type SourceMap, type Tokens } from "./types.ts";
2
+ import { type HTMLAttrs, type SourceMap, type Tokens } from "./types.ts";
3
3
 
4
4
  /**
5
5
  * Helpers
@@ -33,51 +33,34 @@ export function escape(html: string, encode?: boolean) {
33
33
  return html;
34
34
  }
35
35
 
36
- export function renderHtmlClasses(classes: string[]) {
37
- if (!classes.length) return "";
38
- let result = ' class="';
39
- result += classes.join(" ");
40
- result += '"';
41
- return result;
42
- }
43
-
44
- function parseHtmlElement(html: string) {
45
- const parser = new DOMParser();
46
- const doc = parser.parseFromString(html, "text/html");
47
- // eslint-disable-next-line
48
- if (!doc.body) throw Error("Invalid HTML");
49
- const element = doc.body.firstChild as HTMLElement;
50
- // eslint-disable-next-line
51
- if (!element) throw Error("No valid element found");
52
- return element;
53
- }
54
-
55
36
  export function getHtmlElementText(html: string) {
56
37
  try {
57
- const element = parseHtmlElement(html);
38
+ const parser = new DOMParser();
39
+ const doc = parser.parseFromString(html, "text/html");
40
+ // eslint-disable-next-line
41
+ if (!doc.body) throw Error("Invalid HTML");
42
+ const element = doc.body.firstChild as HTMLElement;
43
+ // eslint-disable-next-line
44
+ if (!element) throw Error("No valid element found");
58
45
  return element.textContent ?? html;
59
46
  } catch (_) {
60
47
  return html;
61
48
  }
62
49
  }
63
50
 
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
- */
70
- export function injectHtmlAttributes(
71
- html: string,
72
- attrs: [name: string, value: string | number][],
73
- ) {
51
+ export function injectHtmlAttributes(html: string, attrs: HTMLAttrs, sourceMap?: SourceMap) {
52
+ if (sourceMap) {
53
+ attrs.push(["line-start", String(sourceMap[0])]);
54
+ attrs.push(["line-end", String(sourceMap[1])]);
55
+ }
56
+
74
57
  if (!attrs.length) return html;
75
58
 
76
- const closingBracket = /[a-zA-Z0-9]>|\/>/;
59
+ const closingBracket = /[a-zA-Z0-9\/"]>/;
77
60
  const match = html.match(closingBracket);
78
61
  if (match) {
79
62
  let htmlAttrs = "";
80
- attrs.forEach((atrr) => (htmlAttrs += ` ${atrr[0]}=${atrr[1]}`));
63
+ attrs.forEach((atrr) => (htmlAttrs += ` ${atrr[0]}="${atrr[1]}"`));
81
64
  const sliceIdx = match.index! + (match[0] === "/>" ? -1 : 1);
82
65
  return html.slice(0, sliceIdx) + htmlAttrs + html.slice(sliceIdx);
83
66
  }
@@ -128,11 +111,6 @@ export function fixLocalImageHref(
128
111
  }
129
112
  }
130
113
 
131
- export function renderSourceMap(sourceMap: SourceMap) {
132
- if (!sourceMap) return "";
133
- return ` line-start=${sourceMap[0]} line-end=${sourceMap[1]}`;
134
- }
135
-
136
114
  export function cleanUrl(href: string) {
137
115
  try {
138
116
  href = encodeURI(href).replace(/%25/g, "%");