md-pdf-studio 2.0.0 → 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/README.md CHANGED
@@ -27,7 +27,9 @@
27
27
  - 🚀 **CLI & Batch Conversion**: Convert single files or batch process directories with wildcard patterns.
28
28
  - 🎨 **Custom CSS Injection**: Inject custom branding CSS stylesheets (`-c, --css custom.css`).
29
29
  - 📊 **Mermaid Diagrams**: Sequence diagrams (with auto note wrapping), flowcharts, class diagrams, and gantt charts.
30
+ - 🌈 **Syntax Highlighting**: Fenced code blocks that name a language (` ```js `, ` ```python `, ` ```diff `, …) are coloured at build time with highlight.js — no CDN, so it also works offline and in static HTML export. Colours follow the theme's code block background. Works in the CLI and in the extension preview.
30
31
  - 📐 **LaTeX Math Support**: Mathematical equations rendered with KaTeX.
32
+ - 📝 **Footnotes**: `text[^1]` with `[^1]: the note` anywhere in the file. Notes are numbered in the order they are first referenced, gathered at the end of the document, and linked both ways. Works in the CLI and in the extension preview.
31
33
  - 💡 **GitHub Callout Box Alerts**: Support for `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!IMPORTANT]`, and `> [!CAUTION]`.
32
34
  - 💻 **ASCII UI Mockups**: Styled dark-theme boxes for terminal output & ASCII wireframe mockups.
33
35
 
@@ -103,6 +105,7 @@ npx md-pdf-studio ./docs/ -o ./dist/
103
105
  | `-k, --keep-html` | | Keep temporary HTML file after conversion | `false` |
104
106
  | `--no-mermaid` | | Disable Mermaid diagram rendering | `false` |
105
107
  | `--no-katex` | | Disable KaTeX math formula rendering | `false` |
108
+ | `--no-highlight` | | Disable syntax highlighting of code blocks | `false` |
106
109
 
107
110
  ---
108
111
 
@@ -122,6 +125,7 @@ theme: github
122
125
  font: Inter
123
126
  pageSize: A4
124
127
  landscape: false
128
+ highlight: true
125
129
  ---
126
130
  # Document Executive Summary
127
131
  ...
@@ -134,15 +138,18 @@ landscape: false
134
138
  This project follows standard **Git Flow** branching and automated release publishing:
135
139
 
136
140
  ### 🔀 Branching Strategy
137
- - **`main`**: Production-ready branch. Stores stable releases. Merging code into `main` automatically triggers a GitHub Release.
141
+ - **`main`**: Production-ready branch. Stores stable releases. Pushing a version tag `v*` from `main` triggers a release.
138
142
  - **`develop`**: Active development branch. Daily commits, feature additions, and refactoring take place here.
139
143
 
140
144
  ### 🚀 Automated Release Pipeline
141
- Whenever code is merged from `develop` into `main` (or a version tag `v*` is pushed), GitHub Actions ([`.github/workflows/release.yml`](.github/workflows/release.yml)) automatically:
142
- 1. Runs full unit test suite (`npm test`).
145
+ Whenever a version tag `v*` is pushed, GitHub Actions ([`.github/workflows/release.yml`](.github/workflows/release.yml)) builds from that tag and automatically:
146
+ 1. Checks that the tag matches the version in `package.json` and `extension/manifest.json`, then runs the full unit test suite (`npm test`).
143
147
  2. Bundles cross-platform extension zip artifacts (`dist/extension-vX.Y.Z.zip`).
144
148
  3. Auto-generates release notes from commit history.
145
149
  4. Publishes a new official **[GitHub Release](https://github.com/vannt-dev/convert-md-to-pdf/releases)** with downloadable ZIP binaries attached!
150
+ 5. Publishes [`md-pdf-studio`](https://www.npmjs.com/package/md-pdf-studio) to npm with provenance, through npm trusted publishing (no token secret). A version already on npm is skipped.
151
+
152
+ To release an existing tag again (for example after a failed run), run the workflow manually with `tag_name`.
146
153
 
147
154
  ### 💡 1-Click CLI Release Command
148
155
  You can also bump version and trigger a release with a single CLI command:
package/bin/cli.js CHANGED
@@ -28,6 +28,7 @@ program
28
28
  .option('-k, --keep-html', 'Keep temporary HTML file after conversion', false)
29
29
  .option('--no-mermaid', 'Disable Mermaid diagram rendering')
30
30
  .option('--no-katex', 'Disable KaTeX math formula rendering')
31
+ .option('--no-highlight', 'Disable syntax highlighting of code blocks')
31
32
  .option('-b, --browser <path>', 'Custom Chrome/Edge executable path')
32
33
  .action(async (inputArgs, options) => {
33
34
  try {
@@ -89,6 +90,7 @@ program
89
90
  keepHtml: options.keepHtml,
90
91
  mermaid: options.mermaid,
91
92
  katex: options.katex,
93
+ highlight: options.highlight,
92
94
  executablePath: options.browser
93
95
  });
94
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-pdf-studio",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "A powerful CLI tool and Manifest V3 Browser Extension to convert Markdown files, text, or web pages into beautifully styled PDFs with Mermaid diagrams, LaTeX math, TOC, and themes.",
5
5
  "main": "src/core/index.js",
6
6
  "bin": {
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "commander": "^15.0.0",
50
+ "highlight.js": "^11.12.0",
50
51
  "marked": "^18.0.14"
51
52
  },
52
53
  "engines": {
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Markdown footnotes: `text[^id]` in the body, `[^id]: note` anywhere on a line of its own.
3
+ *
4
+ * Works on the Markdown text, before it is parsed, and needs nothing from Node: the same file
5
+ * is shipped in the extension as extension/lib/footnotes.js (a test keeps the two identical).
6
+ */
7
+ (function (scope) {
8
+ const DEFINITION = /^ {0,3}\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
9
+ const REFERENCE = /\[\^([^\]\s]+)\]/g;
10
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
11
+ const CONTINUATION = /^(?: {2,}|\t)\S/;
12
+
13
+ /**
14
+ * Splits the text into lines tagged as code or prose. Footnote syntax inside a fenced
15
+ * block is an example of the syntax, not a footnote.
16
+ * @param {string} markdown
17
+ * @returns {{ text: string, code: boolean }[]}
18
+ */
19
+ function tagLines(markdown) {
20
+ const lines = [];
21
+ let fence = null;
22
+ for (const text of markdown.split(/\r?\n/)) {
23
+ const match = FENCE.exec(text);
24
+ if (fence) {
25
+ lines.push({ text, code: true });
26
+ if (match && match[1][0] === fence[0] && match[1].length >= fence.length) fence = null;
27
+ } else if (match) {
28
+ fence = match[1];
29
+ lines.push({ text, code: true });
30
+ } else {
31
+ lines.push({ text, code: false });
32
+ }
33
+ }
34
+ return lines;
35
+ }
36
+
37
+ /**
38
+ * Applies `replace` to the parts of a line that are not inline code.
39
+ * @param {string} line
40
+ * @param {(prose: string) => string} replace
41
+ * @returns {string}
42
+ */
43
+ function outsideInlineCode(line, replace) {
44
+ return line
45
+ .split(/(`+[^`]*`+)/)
46
+ .map((part, index) => (index % 2 === 1 ? part : replace(part)))
47
+ .join('');
48
+ }
49
+
50
+ /**
51
+ * Numbers the footnotes of a Markdown document in the order they are first referenced,
52
+ * replaces each reference with a superscript link, and appends the notes as a list.
53
+ *
54
+ * A reference to a note that is never defined is left as the author typed it; a note that
55
+ * is never referenced is left out, as GitHub does.
56
+ * @param {string} markdown
57
+ * @returns {string} Markdown, with HTML for the references and the notes section
58
+ */
59
+ function applyFootnotes(markdown = '') {
60
+ if (!markdown || markdown.indexOf('[^') === -1) return markdown;
61
+
62
+ const lines = tagLines(markdown);
63
+ const definitions = new Map();
64
+ const body = [];
65
+
66
+ for (let index = 0; index < lines.length; index++) {
67
+ const line = lines[index];
68
+ const match = line.code ? null : DEFINITION.exec(line.text);
69
+ if (!match) {
70
+ body.push(line);
71
+ continue;
72
+ }
73
+ const parts = [match[2].trim()];
74
+ // Indented lines that follow belong to the note.
75
+ while (
76
+ index + 1 < lines.length &&
77
+ !lines[index + 1].code &&
78
+ CONTINUATION.test(lines[index + 1].text)
79
+ ) {
80
+ index += 1;
81
+ parts.push(lines[index].text.trim());
82
+ }
83
+ // The first definition of an id wins, as with link reference definitions.
84
+ if (!definitions.has(match[1])) definitions.set(match[1], parts.join(' ').trim());
85
+ }
86
+
87
+ if (definitions.size === 0) return markdown;
88
+
89
+ const numbers = new Map();
90
+ const uses = new Map();
91
+ const text = body
92
+ .map((line) => {
93
+ if (line.code) return line.text;
94
+ return outsideInlineCode(line.text, (prose) =>
95
+ prose.replace(REFERENCE, (whole, id) => {
96
+ if (!definitions.has(id)) return whole;
97
+ if (!numbers.has(id)) numbers.set(id, numbers.size + 1);
98
+ const number = numbers.get(id);
99
+ const use = (uses.get(id) || 0) + 1;
100
+ uses.set(id, use);
101
+ const anchor = use === 1 ? `fnref-${number}` : `fnref-${number}-${use}`;
102
+ return `<sup class="footnote-ref"><a href="#fn-${number}" id="${anchor}">${number}</a></sup>`;
103
+ })
104
+ );
105
+ })
106
+ .join('\n');
107
+
108
+ if (numbers.size === 0) return text;
109
+
110
+ const notes = Array.from(numbers.entries()).map(([id, number]) => {
111
+ const note = definitions.get(id);
112
+ return `${number}. <span id="fn-${number}"></span>${note} <a href="#fnref-${number}" class="footnote-backref" aria-label="Back to the text">↩</a>`;
113
+ });
114
+
115
+ // The blank lines let Markdown inside the section be parsed as Markdown.
116
+ return `${text.replace(/\s+$/, '')}\n\n<section class="footnotes">\n\n${notes.join('\n')}\n\n</section>\n`;
117
+ }
118
+
119
+ const api = { applyFootnotes };
120
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
121
+ else scope.MdPdfFootnotes = api;
122
+ })(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -1,4 +1,5 @@
1
1
  const { marked } = require('marked');
2
+ const hljs = require('highlight.js/lib/common');
2
3
  const {
3
4
  parseFrontMatter,
4
5
  generateCoverPage,
@@ -9,13 +10,15 @@ const {
9
10
  escapeHtml
10
11
  } = require('./utils');
11
12
  const { getHtmlTemplate } = require('./templates');
13
+ const { applyFootnotes } = require('./footnotes');
12
14
 
13
15
  /**
14
16
  * Builds marked custom renderer for diagrams, UI mockups, and headings
15
17
  * @param {Map<string, string>} headingsMap
18
+ * @param {boolean} highlight Colour fenced code blocks that name a known language
16
19
  * @returns {marked.Renderer}
17
20
  */
18
- function buildMarkedRenderer(headingsMap) {
21
+ function buildMarkedRenderer(headingsMap, highlight = true) {
19
22
  const renderer = new marked.Renderer();
20
23
  const originalCodeRenderer = renderer.code.bind(renderer);
21
24
 
@@ -31,6 +34,13 @@ function buildMarkedRenderer(headingsMap) {
31
34
  return `<div class="ui-mockup-container"><pre class="ui-mockup"><code>${escapeHtml(text)}</code></pre></div>`;
32
35
  }
33
36
 
37
+ // Only fences that name a language are coloured: guessing one gets short snippets wrong.
38
+ const language = lang.trim().split(/\s+/)[0];
39
+ if (highlight && language && hljs.getLanguage(language)) {
40
+ const highlighted = hljs.highlight(text, { language, ignoreIllegals: true }).value;
41
+ return `<pre><code class="hljs language-${escapeHtml(language)}">${highlighted}</code></pre>\n`;
42
+ }
43
+
34
44
  return originalCodeRenderer.call(this, token);
35
45
  };
36
46
 
@@ -86,12 +96,13 @@ function parseMarkdownToHtml(rawMarkdownContent, options = {}) {
86
96
  footer: options.footer !== undefined ? options.footer : (frontMatter.footer || true),
87
97
  mermaid: options.mermaid !== undefined ? options.mermaid : (frontMatter.mermaid !== false),
88
98
  katex: options.katex !== undefined ? options.katex : (frontMatter.katex !== false),
99
+ highlight: options.highlight !== undefined ? options.highlight : (frontMatter.highlight !== false),
89
100
  customCss: options.customCss || '',
90
101
  cssFile: options.cssFile || frontMatter.cssFile || frontMatter.css
91
102
  };
92
103
 
93
- // 3. Preprocess Markdown (LaTeX, pagebreaks, alerts)
94
- const processedMd = preprocessMarkdown(markdownContent);
104
+ // 3. Preprocess Markdown (footnotes, LaTeX, pagebreaks, alerts)
105
+ const processedMd = preprocessMarkdown(applyFootnotes(markdownContent));
95
106
 
96
107
  // 4. Generate TOC & Cover Page
97
108
  let tocHtml = '';
@@ -118,7 +129,7 @@ function parseMarkdownToHtml(rawMarkdownContent, options = {}) {
118
129
 
119
130
  // 6. Parse Markdown using custom renderer
120
131
  marked.setOptions({ gfm: true, breaks: true });
121
- const renderer = buildMarkedRenderer(headingsMap);
132
+ const renderer = buildMarkedRenderer(headingsMap, mergedOptions.highlight);
122
133
  marked.use({ renderer });
123
134
 
124
135
  let bodyHtml = marked.parse(processedMd);
@@ -11,7 +11,29 @@ function sanitizeHtml(html) {
11
11
  if (!html) return '';
12
12
  return html
13
13
  .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
14
- .replace(/\s*on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
14
+ .replace(OPENING_TAG, stripEventHandlers);
15
+ }
16
+
17
+ // An opening tag, with quoted attribute values allowed to contain ">". A quote that is never
18
+ // closed still ends at the next ">", as the tag would in a lenient parser.
19
+ const OPENING_TAG = /<([a-zA-Z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>])*)>/g;
20
+ // One attribute: its name, then an optional value. Consuming the value keeps it from being
21
+ // read as another attribute.
22
+ const ATTRIBUTE = /[\s/]*([^\s"'<>/=]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g;
23
+
24
+ /**
25
+ * Drops on* attributes from one opening tag. Text between tags is never touched, so code
26
+ * such as `el.onclick = fn` survives.
27
+ * @param {string} tag
28
+ * @param {string} name
29
+ * @param {string} attributes
30
+ * @returns {string}
31
+ */
32
+ function stripEventHandlers(tag, name, attributes) {
33
+ const kept = attributes.replace(ATTRIBUTE, (attribute, attributeName) =>
34
+ /^on/i.test(attributeName) ? '' : attribute
35
+ );
36
+ return `<${name}${kept}>`;
15
37
  }
16
38
 
17
39
  /**
@@ -99,6 +99,12 @@ function getHtmlTemplate({ title, author = '', bodyHtml, options = {} }) {
99
99
  page-break-after: always;
100
100
  }
101
101
 
102
+ .footnote-ref { font-size: 0.75em; line-height: 0; }
103
+ .footnote-ref a, .footnote-backref { text-decoration: none; }
104
+ .footnotes { margin-top: 28px; padding-top: 10px; border-top: 1px solid currentColor; font-size: 0.85em; break-inside: avoid-page; }
105
+ .footnotes ol { margin: 0; padding-left: 1.6em; }
106
+ .footnotes li { margin: 3px 0; }
107
+
102
108
  img {
103
109
  max-width: 100% !important;
104
110
  height: auto !important;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Syntax Highlighting Token Colors
3
+ * Two palettes, picked by the background each theme gives its code blocks.
4
+ */
5
+ // Inline-code chrome must not repeat on every line of a block.
6
+ const base = `
7
+ pre code { background: none; padding: 0; border: none; }
8
+ `;
9
+
10
+ const light = `
11
+ .hljs-comment, .hljs-quote, .hljs-meta { color: #6e7781; font-style: italic; }
12
+ .hljs-keyword, .hljs-selector-tag, .hljs-doctag, .hljs-template-tag { color: #cf222e; }
13
+ .hljs-string, .hljs-regexp, .hljs-char.escape_ { color: #0a3069; }
14
+ .hljs-number, .hljs-literal, .hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-symbol, .hljs-bullet { color: #0550ae; }
15
+ .hljs-title, .hljs-section, .hljs-selector-id, .hljs-selector-class { color: #8250df; }
16
+ .hljs-type, .hljs-built_in, .hljs-params { color: #953800; }
17
+ .hljs-name, .hljs-tag { color: #116329; }
18
+ .hljs-addition { color: #116329; background-color: #dafbe1; }
19
+ .hljs-deletion { color: #82071e; background-color: #ffebe9; }
20
+ .hljs-emphasis { font-style: italic; }
21
+ .hljs-strong { font-weight: 700; }
22
+ `;
23
+
24
+ const dark = `
25
+ .hljs-comment, .hljs-quote, .hljs-meta { color: #8b949e; font-style: italic; }
26
+ .hljs-keyword, .hljs-selector-tag, .hljs-doctag, .hljs-template-tag { color: #ff7b72; }
27
+ .hljs-string, .hljs-regexp, .hljs-char.escape_ { color: #a5d6ff; }
28
+ .hljs-number, .hljs-literal, .hljs-attr, .hljs-attribute, .hljs-variable, .hljs-template-variable, .hljs-symbol, .hljs-bullet { color: #79c0ff; }
29
+ .hljs-title, .hljs-section, .hljs-selector-id, .hljs-selector-class { color: #d2a8ff; }
30
+ .hljs-type, .hljs-built_in, .hljs-params { color: #ffa657; }
31
+ .hljs-name, .hljs-tag { color: #7ee787; }
32
+ .hljs-addition { color: #aff5b4; background-color: #033a16; }
33
+ .hljs-deletion { color: #ffdcd7; background-color: #67060c; }
34
+ .hljs-emphasis { font-style: italic; }
35
+ .hljs-strong { font-weight: 700; }
36
+ `;
37
+
38
+ module.exports = { light: base + light, dark: base + dark };
@@ -6,6 +6,7 @@ const githubTheme = require('./github');
6
6
  const ebookTheme = require('./ebook');
7
7
  const cyberpunkTheme = require('./cyberpunk');
8
8
  const minimalTheme = require('./minimal');
9
+ const highlightCss = require('./highlight');
9
10
 
10
11
  const THEMES = {
11
12
  modern: modernTheme,
@@ -17,6 +18,9 @@ const THEMES = {
17
18
  minimal: minimalTheme
18
19
  };
19
20
 
21
+ // Themes whose code blocks sit on a dark background; the others use the light palette.
22
+ const DARK_CODE_THEMES = new Set(['modern', 'dark', 'ebook', 'cyberpunk']);
23
+
20
24
  /**
21
25
  * Builds font override CSS string
22
26
  * @param {string} font
@@ -40,10 +44,12 @@ function getFontOverrideCss(font) {
40
44
  * @returns {string} Consolidated CSS string
41
45
  */
42
46
  function getThemeStyles(theme, font = '') {
43
- const themeCss = THEMES[theme] || THEMES.modern;
47
+ const themeName = THEMES[theme] ? theme : 'modern';
48
+ const themeCss = THEMES[themeName];
49
+ const tokenCss = DARK_CODE_THEMES.has(themeName) ? highlightCss.dark : highlightCss.light;
44
50
  const fontOverrideCss = getFontOverrideCss(font);
45
51
 
46
- return `${coverCss}\n${fontOverrideCss}${themeCss}`;
52
+ return `${coverCss}\n${fontOverrideCss}${themeCss}${tokenCss}`;
47
53
  }
48
54
 
49
55
  module.exports = {