From ffeef30b4c79bef65e63e23037a2037dba498dde Mon Sep 17 00:00:00 2001 From: Edward Woolhouse Date: Mon, 9 Sep 2024 10:49:25 +0100 Subject: [PATCH 1/4] Add support for using markdown-it instead of marked markdown-it is the markdown parser used by Visual Studio Code, so using it grants access to a rich ecosystem of plugins. --- package-lock.json | 98 +++++++++++++++++++++ package.json | 4 + readme.md | 25 +++++- src/cli.ts | 12 +++ src/lib/config.ts | 28 +++++- src/lib/get-html.ts | 19 +++- src/lib/get-markdown-it-with-highlighter.ts | 39 ++++++++ src/lib/help.ts | 6 ++ src/lib/md-to-pdf.ts | 2 +- src/test/api.spec.ts | 11 +++ src/test/cli.spec.ts | 58 +++++++++++- src/test/lib.spec.ts | 47 ++++++++++ src/test/markdown-it-extensions/config.js | 62 +++++++++++++ src/test/markdown-it-extensions/doc.md | 19 ++++ src/test/marked-extensions/config.js | 27 ++++++ src/test/marked-extensions/doc.md | 16 ++++ 16 files changed, 467 insertions(+), 6 deletions(-) create mode 100644 src/lib/get-markdown-it-with-highlighter.ts create mode 100644 src/test/markdown-it-extensions/config.js create mode 100644 src/test/markdown-it-extensions/doc.md create mode 100644 src/test/marked-extensions/config.js create mode 100644 src/test/marked-extensions/doc.md diff --git a/package-lock.json b/package-lock.json index 4f433e6..00d7187 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,9 @@ "highlight.js": "^11.7.0", "iconv-lite": "^0.6.3", "listr": "^0.14.3", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^9.2.0", + "markdown-it-highlightjs": "^4.1.0", "marked": "^4.2.12", "puppeteer": ">=8.0.0", "semver": "^7.3.7", @@ -29,6 +32,7 @@ }, "devDependencies": { "@types/listr": "0.14.5", + "@types/markdown-it": "^14.1.2", "@types/marked": "4.3.0", "@types/semver": "7.5.4", "@types/serve-handler": "6.1.3", @@ -1011,6 +1015,11 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==" + }, "node_modules/@types/listr": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/@types/listr/-/listr-0.14.5.tgz", @@ -1021,12 +1030,26 @@ "rxjs": "^6.5.1" } }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "node_modules/@types/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.0.tgz", "integrity": "sha512-zK4gSFMjgslsv5Lyvr3O1yCjgmnE4pr8jbG8qVn4QglMwtpvPCf4YT2Wma7Nk95OxUUJI8Z+kzdXohbM7mVpGw==", "dev": true }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==" + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -3422,6 +3445,17 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -6874,6 +6908,14 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/listr": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", @@ -7448,6 +7490,44 @@ "node": ">=0.10.0" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-anchor": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-9.2.0.tgz", + "integrity": "sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg==", + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/markdown-it-highlightjs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/markdown-it-highlightjs/-/markdown-it-highlightjs-4.1.0.tgz", + "integrity": "sha512-aYcgme5aYn10BHEvLZaCNgwxU2oaAX9inK9dwCv38wJdq7tal5FzZrLdQQY8MR3I1H07S3BKgYGRX2kKuPT+sA==", + "dependencies": { + "highlight.js": "^11.9.0" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", @@ -7495,6 +7575,11 @@ "node": ">=8" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" + }, "node_modules/mem": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz", @@ -9170,6 +9255,14 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "engines": { + "node": ">=6" + } + }, "node_modules/pupa": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", @@ -11388,6 +11481,11 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/package.json b/package.json index 7d01644..0c37a3e 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,9 @@ "highlight.js": "^11.7.0", "iconv-lite": "^0.6.3", "listr": "^0.14.3", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^9.2.0", + "markdown-it-highlightjs": "^4.1.0", "marked": "^4.2.12", "puppeteer": ">=8.0.0", "semver": "^7.3.7", @@ -59,6 +62,7 @@ }, "devDependencies": { "@types/listr": "0.14.5", + "@types/markdown-it": "^14.1.2", "@types/marked": "4.3.0", "@types/semver": "7.5.4", "@types/serve-handler": "6.1.3", diff --git a/readme.md b/readme.md index cde2aad..baae078 100644 --- a/readme.md +++ b/readme.md @@ -5,7 +5,7 @@ ![Screenshot of markdown file and resulting PDF](https://files-iiiuxybjc.now.sh) -**A simple and hackable CLI tool for converting markdown to pdf**. It uses [Marked](https://github.com/markedjs/marked) to convert `markdown` to `html` and [Puppeteer](https://github.com/GoogleChrome/puppeteer) (headless Chromium) to further convert the `html` to `pdf`. It also uses [highlight.js](https://github.com/isagalaev/highlight.js) for code highlighting. The whole source code of this tool is ~only \~250 lines of JS~ ~500 lines of Typescript and ~100 lines of CSS, so it is easy to clone and customize. +**A simple and hackable CLI tool for converting markdown to pdf**. It uses [Marked](https://github.com/markedjs/marked) or [markdown-it](https://github.com/markdown-it/markdown-it) to convert `markdown` to `html` and [Puppeteer](https://github.com/GoogleChrome/puppeteer) (headless Chromium) to further convert the `html` to `pdf`. It also uses [highlight.js](https://github.com/isagalaev/highlight.js) for code highlighting. The whole source code of this tool is ~only \~250 lines of JS~ ~500 lines of Typescript and ~100 lines of CSS, so it is easy to clone and customize. **Highlights:** @@ -63,7 +63,9 @@ Options: --body-class ............. Classes to be added to the body tag (can be passed multiple times) --page-media-type ........ Media type to emulate the page with (default: screen) --highlight-style ........ Style to be used by highlight.js (default: github) + --markdown-parser ........ Set the markdown parser to use. Defaults to marked, but accept `marked` or `markdown-it` --marked-options ......... Set custom options for marked (as a JSON string) + --markdown-it-options .... Sets custom options for markdown-it (as a JSON string) --pdf-options ............ Set custom options for the generated PDF (as a JSON string) --launch-options ......... Set custom launch options for Puppeteer --gray-matter-options .... Set custom options for gray-matter @@ -175,6 +177,7 @@ This can be achieved with [MathJax](https://www.mathjax.org/). A simple example For default and advanced options see the following links. The default highlight.js styling for code blocks is `github`. The default PDF options are the A4 format and some margin (see `lib/config.ts` for the full default config). - [Marked Advanced Options](https://marked.js.org/using_advanced) +- [Markdown-it Advanced Options](https://markdown-it.github.io/markdown-it/#MarkdownIt.new) - [Puppeteer PDF Options](https://pptr.dev/api/puppeteer.pdfoptions) - [Puppeteer Launch Options](https://pptr.dev/next/api/puppeteer.launchoptions) - [highlight.js Styles](https://github.com/highlightjs/highlight.js/tree/main/src/styles) @@ -190,7 +193,9 @@ For default and advanced options see the following links. The default highlight. | `--body-class` | `markdown-body` | | `--page-media-type` | `print` | | `--highlight-style` | `monokai`, `solarized-light` | +| `--markdown-parser` | `marked`, `markdown-it` | | `--marked-options` | `'{ "gfm": false }'` | +| `--markdown-it-options` | `{ "linkify": true }` | | `--pdf-options` | `'{ "format": "Letter", "margin": "20mm", "printBackground": true }'` | | `--launch-options` | `'{ "args": ["--no-sandbox"] }'` | | `--gray-matter-options` | `null` | @@ -256,6 +261,24 @@ Example `config.json`: } ``` +Example `config.json`: + +```json +{ + "highlight_style": "monokai", + "body_class": ["dark", "content"], + "markdown_parser": "markdown-it", + "markdown_it_options": { + "linkify": true, + "html": false + } +} +``` + +If you want to add extensions to marked or markdown-it, you need to use a `.js` file. +See `src/test/marked-extensions/config.js` for an example of using extensions with marked. +See `src/test/markdown-it-extensions/config.js` for an example of using extensions with markdown-it. + #### Github Styles Here is an example front-matter for how to get Github-like output: diff --git a/src/cli.ts b/src/cli.ts index c0bbc97..5fb241f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -44,6 +44,8 @@ export const cliFlags = arg({ '--as-html': Boolean, '--config-file': String, '--devtools': Boolean, + '--markdown-parser': String, + '--markdown-it-options': String, // aliases '-h': '--help', @@ -116,6 +118,16 @@ async function main(args: typeof cliFlags, config: Config) { config.basedir = args['--basedir']; } + const VALID_MARKDOWN_PARSER_VALUES: Array = ['marked', 'markdown-it']; + if (args['--markdown-parser']) { + config.markdown_parser = args['--markdown-parser'] as typeof config.markdown_parser; + + // Validate the parser argument + if (!VALID_MARKDOWN_PARSER_VALUES.includes(config.markdown_parser)) { + throw new Error(`Unsupported markdown parser '${config.markdown_parser}'`); + } + } + config.port = args['--port'] ?? (await getPort()); const server = await serveDirectory(config); diff --git a/src/lib/config.ts b/src/lib/config.ts index a1e1c06..77ebb86 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,5 +1,6 @@ import { WatchOptions } from 'chokidar'; import { GrayMatterOption } from 'gray-matter'; +import type MarkdownIt from 'markdown-it'; import { marked } from 'marked'; import { resolve } from 'path'; import { FrameAddScriptTagOptions, launch, PDFOptions } from 'puppeteer'; @@ -38,6 +39,9 @@ export const defaultConfig: Config = { as_html: false, devtools: false, marked_extensions: [], + markdown_parser: 'marked', + markdown_it_options: {}, + markdown_it_plugins: [], }; /** @@ -168,11 +172,33 @@ interface BasicConfig { watch_options?: WatchOptions; /** - * Custm Extensions to be passed to marked. + * Custom Extensions to be passed to marked. * * @see https://marked.js.org/using_pro#extensions */ marked_extensions: marked.MarkedExtension[]; + + /** + * The parser to use. Defaults to marked + */ + markdown_parser: 'marked' | 'markdown-it'; + + /** + * Options for markdown-it parser + */ + markdown_it_options: MarkdownIt.Options; + + /** + * Plugins to be passed to markdown-it + * + * If the extension uses arguments, wrap it in an arrow function + * + * markdown_it_plugins: [ + * extensionWithoutOptions, + * (md) => extensionWithOptions(md, {strict: true}) + * ] + */ + markdown_it_plugins: MarkdownIt.PluginSimple[]; } export type PuppeteerLaunchOptions = Parameters[0]; diff --git a/src/lib/get-html.ts b/src/lib/get-html.ts index 738b11c..357b9ff 100644 --- a/src/lib/get-html.ts +++ b/src/lib/get-html.ts @@ -1,6 +1,23 @@ import { Config } from './config'; +import { getMarkdownIt } from './get-markdown-it-with-highlighter'; import { getMarked } from './get-marked-with-highlighter'; +/** + * Gets a markdown parser based on the configuration + */ +const getMarkdownConverter = (config: Config): ((_: string) => string) => { + switch (config.markdown_parser) { + case 'markdown-it': { + const markdownIt = getMarkdownIt(config.markdown_it_options, config.markdown_it_plugins); + return markdownIt.render.bind(markdownIt); + } + + case 'marked': + default: + return getMarked(config.marked_options, config.marked_extensions); + } +}; + /** * Generates a HTML document from a markdown string and returns it as a string. */ @@ -8,7 +25,7 @@ export const getHtml = (md: string, config: Config) => ` ${config.document_title} - ${getMarked(config.marked_options, config.marked_extensions)(md)} + ${getMarkdownConverter(config)(md)} `; diff --git a/src/lib/get-markdown-it-with-highlighter.ts b/src/lib/get-markdown-it-with-highlighter.ts new file mode 100644 index 0000000..36d4d9f --- /dev/null +++ b/src/lib/get-markdown-it-with-highlighter.ts @@ -0,0 +1,39 @@ +import type MarkdownIt from 'markdown-it'; +import markdownit from 'markdown-it'; +import markdownItAnchor from 'markdown-it-anchor'; +import markdownItHljs from 'markdown-it-highlightjs'; + +// Unless otherwise specified: +// * Allow HTML snippets +// * Use ''' as the language prefix +// This matches the behavior of marked +const defaultOptions: markdownit.Options = { + html: true, + langPrefix: '', +}; + +/** + * Returns an instance of markdown-it + * + * Two plugins are added: + * 1. Highlight.js for syntax highlighting + * 2. markdown-it-anchor. This adds id attributes to header elements + */ +export const getMarkdownIt = (options: markdownit.Options, plugins: MarkdownIt.PluginSimple[]): MarkdownIt => { + const mergedOptions = { ...defaultOptions, ...options }; + const markdownIt = markdownit(mergedOptions); + + // Add highlightjs + markdownIt.use(markdownItHljs); + + // Add markdown-it-anchor + // This adds 'id' anchors to header elements, matching the default behavior of marked + markdownIt.use(markdownItAnchor, { tabIndex: false }); + + // Add custom plugins + for (const plugin of plugins) { + markdownIt.use(plugin); + } + + return markdownIt; +}; diff --git a/src/lib/help.ts b/src/lib/help.ts index 846cecd..141530a 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -16,7 +16,9 @@ const helpText = ` --body-class ${chalk.dim('.............')} Classes to be added to the body tag (can be passed multiple times) --page-media-type ${chalk.dim('........')} Media type to emulate the page with (default: screen) --highlight-style ${chalk.dim('........')} Style to be used by highlight.js (default: github) + | --markdown-parser ${chalk.dim('........')} Markdown parser to use (default: marked) --marked-options ${chalk.dim('.........')} Set custom options for marked (as a JSON string) + --markdown-it-options ${chalk.dim('....')} Set custom options for markdown-it (as a JSON string) --pdf-options ${chalk.dim('............')} Set custom options for the generated PDF (as a JSON string) --launch-options ${chalk.dim('.........')} Set custom launch options for Puppeteer --gray-matter-options ${chalk.dim('....')} Set custom options for gray-matter @@ -68,6 +70,10 @@ const helpText = ` ${chalk.gray('–')} Convert file.md but save the intermediate HTML instead ${chalk.cyan('$ md-to-pdf file.md --as-html')} + + ${chalk.gray('–')} Convert file.md using markdown-it + + ${chalk.cyan('$ md-to-pdf file.md --markdown-parser markdown-it')} `; export const help = () => console.log(helpText); diff --git a/src/lib/md-to-pdf.ts b/src/lib/md-to-pdf.ts index e03b83c..1464b3f 100644 --- a/src/lib/md-to-pdf.ts +++ b/src/lib/md-to-pdf.ts @@ -61,7 +61,7 @@ export const convertMdToPdf = async ( } } - const jsonArgs = new Set(['--marked-options', '--pdf-options', '--launch-options']); + const jsonArgs = new Set(['--marked-options', '--pdf-options', '--launch-options', '--markdown-it-options']); // merge cli args into config for (const arg of Object.entries(args)) { diff --git a/src/test/api.spec.ts b/src/test/api.spec.ts index 245d09d..e568100 100644 --- a/src/test/api.spec.ts +++ b/src/test/api.spec.ts @@ -79,6 +79,17 @@ test('compile the basic example to html and write to disk', async (t) => { t.notThrows(() => readFileSync(resolve(__dirname, 'basic', 'api-test.html'), 'utf-8')); }); +test('compile the basic example using markdown-it', async (t) => { + const html = await mdToPdf( + { path: resolve(__dirname, 'basic', 'test.md') }, + { dest: resolve(__dirname, 'basic', 'api-markdown-it.html'), markdown_parser: 'markdown-it' }, + ); + + t.is(basename(html.filename!), 'api-markdown-it.html'); + + t.notThrows(() => readFileSync(resolve(__dirname, 'basic', 'api-markdown-it.html'), 'utf-8')); +}); + test('compile the MathJax test', async (t) => { const pdf = await mdToPdf({ path: resolve(__dirname, 'mathjax', 'math.md') }); diff --git a/src/test/cli.spec.ts b/src/test/cli.spec.ts index 1528c1f..a62dbf1 100644 --- a/src/test/cli.spec.ts +++ b/src/test/cli.spec.ts @@ -1,9 +1,9 @@ -import test, { before } from 'ava'; +import test, { beforeEach } from 'ava'; import { execSync } from 'child_process'; import { readFileSync, unlinkSync } from 'fs'; import { join, resolve } from 'path'; -before(() => { +beforeEach(() => { const filesToDelete = [ resolve(__dirname, 'basic', 'test.pdf'), resolve(__dirname, 'basic', 'test-stdio.pdf'), @@ -37,6 +37,20 @@ test('compile the basic example to pdf using --basedir', (t) => { t.notThrows(() => readFileSync(resolve(__dirname, 'basic', 'test.pdf'), 'utf-8')); }); +test('compile the basic example to pdf using --markdown-parser markdown-it', (t) => { + const cmd = [ + resolve(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node'), // ts-node binary + resolve(__dirname, '..', 'cli'), // md-to-pdf cli script (typescript) + resolve(__dirname, 'basic', 'test.md'), // file to convert + '--markdown-parser', + 'markdown-it', + ].join(' '); + + t.notThrows(() => execSync(cmd)); + + t.notThrows(() => readFileSync(resolve(__dirname, 'basic', 'test.pdf'), 'utf-8')); +}); + test('compile the basic example using stdio', (t) => { const cmd = [ 'cat', @@ -70,3 +84,43 @@ test('compile the nested example to pdfs', (t) => { t.notThrows(() => readFileSync(resolve(__dirname, 'nested', 'level-one', 'one.pdf'), 'utf-8')); t.notThrows(() => readFileSync(resolve(__dirname, 'nested', 'level-one', 'level-two', 'two.pdf'), 'utf-8')); }); + +test('compile with a marked extension', (t) => { + const cmd = [ + resolve(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node'), // ts-node binary + resolve(__dirname, '..', 'cli'), // md-to-pdf cli script (typescript) + resolve(__dirname, 'marked-extensions', 'doc.md'), // file to convert + '--config-file', + resolve(__dirname, 'marked-extensions', 'config.js'), + ].join(' '); + + t.notThrows(() => execSync(cmd)); + + t.notThrows(() => readFileSync(resolve(__dirname, 'basic', 'test.pdf'), 'utf-8')); +}); + +test('compile with a markdown-it extension', (t) => { + const cmd = [ + resolve(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node'), // ts-node binary + resolve(__dirname, '..', 'cli'), // md-to-pdf cli script (typescript) + resolve(__dirname, 'markdown-it-extensions', 'doc.md'), // file to convert + '--config-file', + resolve(__dirname, 'markdown-it-extensions', 'config.js'), + ].join(' '); + + t.notThrows(() => execSync(cmd)); + + t.notThrows(() => readFileSync(resolve(__dirname, 'basic', 'test.pdf'), 'utf-8')); +}); + +test('invalid markdown parser detected', (t) => { + const cmd = [ + resolve(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node'), // ts-node binary + resolve(__dirname, '..', 'cli'), // md-to-pdf cli script (typescript) + resolve(__dirname, 'basic', 'test.md'), // file to convert + '--markdown-parser', + 'not_a_real_parser', + ].join(' '); + + t.throws(() => execSync(cmd)); +}); diff --git a/src/test/lib.spec.ts b/src/test/lib.spec.ts index da360c1..93fc381 100644 --- a/src/test/lib.spec.ts +++ b/src/test/lib.spec.ts @@ -1,9 +1,11 @@ import test from 'ava'; +import MarkdownIt from 'markdown-it'; import { Renderer } from 'marked'; import { EOL } from 'os'; import { posix, resolve, sep } from 'path'; import { defaultConfig } from '../lib/config'; import { getHtml } from '../lib/get-html'; +import { getMarkdownIt } from '../lib/get-markdown-it-with-highlighter'; import { getMarked } from '../lib/get-marked-with-highlighter'; import { getOutputFilePath } from '../lib/get-output-file-path'; import { getDir, getMarginObject } from '../lib/helpers'; @@ -69,6 +71,12 @@ test('getHtml should have the title set', (t) => { t.regex(html, /Foo<\/title>/); }); +test('getHtml use a markdown-it parser if requested', (t) => { + const html = getHtml('# Foo', { ...defaultConfig, markdown_parser: 'markdown-it' }).replace(/\n/g, ''); + + t.regex(html, /<body class="">\s*<h1 id="foo">Foo<\/h1>\s*<\/body>/); +}); + // -- // get-marked @@ -161,3 +169,42 @@ test('isUrl should return true for strings that are valid http(s) urls', (t) => t.is(isHttpUrl('file:///foobar'), false); t.is(isHttpUrl('C:\\foo\\bar'), false); }); + +// -- +// get-markdown-it +test('getMarkdownIt should highlight js code', (t) => { + const markdownIt = getMarkdownIt({}, []); + const html = markdownIt.render('```js\nvar foo="bar";\n```'); + t.true(html.includes('<code class="hljs js">')); +}); + +test('getMarkdownIt should highlight unknown code as plaintext', (t) => { + const markdownIt = getMarkdownIt({}, []); + const html = markdownIt.render('```\nvar foo="bar";\n```'); + + t.true(html.includes('<code class="hljs">')); +}); + +test('getMarkdownIt should allow HTML snippets by default', (t) => { + const markdownIt = getMarkdownIt({}, []); + const html = markdownIt.render('# Header\n<figure>!</figure>'); + + t.true(html.includes('<figure>!</figure>')); +}); + +test('getMarkdownIt can disable HTML', (t) => { + const markdownIt = getMarkdownIt({ html: false }, []); + const html = markdownIt.render('# Header\n<figure>!</figure>'); + t.true(html.includes('<figure>!</figure>')); +}); + +test('getMarkdownIt allows plugins', (t) => { + const plugin = (_: MarkdownIt) => { + plugin.callCount++; + }; + + plugin.callCount = 0; + + getMarkdownIt({}, [plugin]); + t.is(plugin.callCount, 1); +}); diff --git a/src/test/markdown-it-extensions/config.js b/src/test/markdown-it-extensions/config.js new file mode 100644 index 0000000..91cb5aa --- /dev/null +++ b/src/test/markdown-it-extensions/config.js @@ -0,0 +1,62 @@ +/** + * A simple markdown-it plugin that adds a # Magic! header. + * + * This plugin is provided to demonstrate how a plugin without options can simply be used. + * The details of how it actually works are not important to the example. + */ +const magicHeaderPlugin = (md) => { + // Use the `core` ruler to add a new rule + md.core.ruler.push('magic_header', (state) => { + // Create a token for the closing tag of the header + const closeToken = new state.Token('heading_close', 'h1', -1); + closeToken.markup = '#'; + closeToken.block = true; + closeToken.map = [0, 1]; + state.tokens.unshift(closeToken); + + // Add the content for the header + const textToken = new state.Token('inline', '', 0); + textToken.content = 'Magic!'; + textToken.map = [0, 1]; + textToken.children = []; + const textTokenChild = new state.Token('text', '', 0); + textTokenChild.content = 'Magic!'; + textToken.children.push(textTokenChild); + state.tokens.unshift(textToken); + + // Create a token for the new header + const token = new state.Token('heading_open', 'h1', 1); + token.markup = '#'; + state.tokens.unshift(token); + }); +}; + +/** + * A markdown-it plugin that extends highlight-js with new language support. + * + * This plugin is provided to demonstrate how a plugin with options can be used, + * and how markdown-it highlighting can be extended . + * The details of how the plugin works are not important to the example. + * + * Note how an arrow function is used to bind this argument below + */ +function magicHighlightPlugin(md, options) { + // Save existing highlight. This is highlight.js + const extantHighlight = md.options.highlight; + + // Add ourselves as the highlight handler + md.options.highlight = (code, lang, attrs) => { + const magicCount = options?.magicCount ?? 1; + if (lang && lang === 'magic') { + return `<pre><code class="hljs magic">${'Magic!\n'.repeat(magicCount)}${code}</code></pre>`; + } + + // Fallback to existing highlighting + return extantHighlight(code, lang, attrs); + }; +} + +module.exports = { + markdown_parser: 'markdown-it', + markdown_it_plugins: [magicHeaderPlugin, (md) => magicHighlightPlugin(md, { magicCount: 5 })], +}; diff --git a/src/test/markdown-it-extensions/doc.md b/src/test/markdown-it-extensions/doc.md new file mode 100644 index 0000000..61b23bd --- /dev/null +++ b/src/test/markdown-it-extensions/doc.md @@ -0,0 +1,19 @@ +# Tests + +This document uses plugins defined in config.js to: + +1. Add an extra header. +2. Add code highlighting support for the `magic` language.<br> +Existing languages continue to be highlighted. + +```magic +There should be five Magic! exclamations above +``` + +```C +// This C code should continue to be highlighted as C +int main(int argv, char** argv) { + printf("Hello world"); + return 0; +} +``` diff --git a/src/test/marked-extensions/config.js b/src/test/marked-extensions/config.js new file mode 100644 index 0000000..f1ca4d0 --- /dev/null +++ b/src/test/marked-extensions/config.js @@ -0,0 +1,27 @@ +/** + * A marked extension that extends highlight-js with new language support. + */ +function magicHighlightExtension(options) { + return { + renderer: { + code: (code, infostring) => { + if (typeof code === 'object') { + infostring = code.lang; + code = code.text; + } + + if (infostring !== 'magic') { + return false; + } + + const magicCount = options?.magicCount ?? 1; + return `<pre><code class="hljs magic">${'Magic!\n'.repeat(magicCount)}${code}</code></pre>`; + }, + }, + }; +} + +module.exports = { + markdown_parser: 'marked', + marked_extensions: [magicHighlightExtension({ magicCount: 5 })], +}; diff --git a/src/test/marked-extensions/doc.md b/src/test/marked-extensions/doc.md new file mode 100644 index 0000000..6beaf02 --- /dev/null +++ b/src/test/marked-extensions/doc.md @@ -0,0 +1,16 @@ +# Tests + +This document uses plugins defined in config.js to add code highlighting support for the `magic` language.<br> +Existing languages continue to be highlighted. + +```magic +There should be five Magic! exclamations above +``` + +```C +// This C code should continue to be highlighted as C +int main(int argv, char** argv) { + printf("Hello world"); + return 0; +} +``` From a035f89b5a44910e796ae47a286ee9110817b724 Mon Sep 17 00:00:00 2001 From: Edward Woolhouse <danishcake@hotmail.com> Date: Tue, 17 Sep 2024 06:37:18 +0100 Subject: [PATCH 2/4] fix: markdown-it header ids now match those from marked --- src/lib/get-markdown-it-with-highlighter.ts | 16 +++++++++++++++- src/test/lib.spec.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/lib/get-markdown-it-with-highlighter.ts b/src/lib/get-markdown-it-with-highlighter.ts index 36d4d9f..964d373 100644 --- a/src/lib/get-markdown-it-with-highlighter.ts +++ b/src/lib/get-markdown-it-with-highlighter.ts @@ -28,7 +28,21 @@ export const getMarkdownIt = (options: markdownit.Options, plugins: MarkdownIt.P // Add markdown-it-anchor // This adds 'id' anchors to header elements, matching the default behavior of marked - markdownIt.use(markdownItAnchor, { tabIndex: false }); + // We also tweak it to 'slugify' in the same way + markdownIt.use(markdownItAnchor, { + tabIndex: false, + slugify: (text: string) => { + const id = text + .toLowerCase() + .trim() + // remove html tags + .replace(/<[!/a-z].*?>/gi, '') + // remove unwanted chars + .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, '') + .replace(/\s/g, '-'); + return id; + }, + }); // Add custom plugins for (const plugin of plugins) { diff --git a/src/test/lib.spec.ts b/src/test/lib.spec.ts index 93fc381..4ad3188 100644 --- a/src/test/lib.spec.ts +++ b/src/test/lib.spec.ts @@ -198,6 +198,18 @@ test('getMarkdownIt can disable HTML', (t) => { t.true(html.includes('<figure>!</figure>')); }); +test('getMarkdownIt slugifies as per marked', (t) => { + const markdownIt = getMarkdownIt({}, []); + const marked = getMarked({}, []); + + const markdownItHtml = markdownIt.render('# Markdown.it is an Italian website\n'); + const markedHtml = marked('# Markdown.it is an Italian website\n'); + const expected = '<h1 id="markdownit-is-an-italian-website">'; + + t.true(markdownItHtml.includes(expected)); + t.true(markedHtml.includes(expected)); +}); + test('getMarkdownIt allows plugins', (t) => { const plugin = (_: MarkdownIt) => { plugin.callCount++; From 50b4944924e7ae8c067a8651594192578f42be4d Mon Sep 17 00:00:00 2001 From: Edward Woolhouse <danishcake@hotmail.com> Date: Thu, 19 Sep 2024 06:04:42 +0100 Subject: [PATCH 3/4] Update markdown-it-highlightjs plugin --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00d7187..1c163f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "listr": "^0.14.3", "markdown-it": "^14.1.0", "markdown-it-anchor": "^9.2.0", - "markdown-it-highlightjs": "^4.1.0", + "markdown-it-highlightjs": "^4.2.0", "marked": "^4.2.12", "puppeteer": ">=8.0.0", "semver": "^7.3.7", @@ -7516,9 +7516,10 @@ } }, "node_modules/markdown-it-highlightjs": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/markdown-it-highlightjs/-/markdown-it-highlightjs-4.1.0.tgz", - "integrity": "sha512-aYcgme5aYn10BHEvLZaCNgwxU2oaAX9inK9dwCv38wJdq7tal5FzZrLdQQY8MR3I1H07S3BKgYGRX2kKuPT+sA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/markdown-it-highlightjs/-/markdown-it-highlightjs-4.2.0.tgz", + "integrity": "sha512-NC7pXE8KkOl6xWJVRNt8p6wgJVznXKsE0HgYGdk6DD2tn1l4L9f0ALf3VIoGVkotNU1uGQatSxfBF1zZPUMmuQ==", + "license": "Unlicense", "dependencies": { "highlight.js": "^11.9.0" } diff --git a/package.json b/package.json index 0c37a3e..797f9ea 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "listr": "^0.14.3", "markdown-it": "^14.1.0", "markdown-it-anchor": "^9.2.0", - "markdown-it-highlightjs": "^4.1.0", + "markdown-it-highlightjs": "^4.2.0", "marked": "^4.2.12", "puppeteer": ">=8.0.0", "semver": "^7.3.7", From 54b485a73b2a48293746b5b57498e847c49817d2 Mon Sep 17 00:00:00 2001 From: Edward Woolhouse <danishcake@hotmail.com> Date: Thu, 3 Oct 2024 06:33:43 +0100 Subject: [PATCH 4/4] review: Remove the anchor extension --- package-lock.json | 17 +++-------- package.json | 1 - readme.md | 34 +++++++++++++++++++++ src/lib/get-markdown-it-with-highlighter.ts | 19 ------------ src/test/lib.spec.ts | 12 -------- 5 files changed, 39 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1c163f3..d64f93b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "iconv-lite": "^0.6.3", "listr": "^0.14.3", "markdown-it": "^14.1.0", - "markdown-it-anchor": "^9.2.0", "markdown-it-highlightjs": "^4.2.0", "marked": "^4.2.12", "puppeteer": ">=8.0.0", @@ -1018,7 +1017,8 @@ "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==" + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true }, "node_modules/@types/listr": { "version": "0.14.5", @@ -1034,6 +1034,7 @@ "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -1048,7 +1049,8 @@ "node_modules/@types/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==" + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true }, "node_modules/@types/minimatch": { "version": "5.1.2", @@ -7506,15 +7508,6 @@ "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/markdown-it-anchor": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-9.2.0.tgz", - "integrity": "sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg==", - "peerDependencies": { - "@types/markdown-it": "*", - "markdown-it": "*" - } - }, "node_modules/markdown-it-highlightjs": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/markdown-it-highlightjs/-/markdown-it-highlightjs-4.2.0.tgz", diff --git a/package.json b/package.json index 797f9ea..bc7ac60 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "iconv-lite": "^0.6.3", "listr": "^0.14.3", "markdown-it": "^14.1.0", - "markdown-it-anchor": "^9.2.0", "markdown-it-highlightjs": "^4.2.0", "marked": "^4.2.12", "puppeteer": ">=8.0.0", diff --git a/readme.md b/readme.md index baae078..884470f 100644 --- a/readme.md +++ b/readme.md @@ -294,6 +294,40 @@ css: |- --- ``` +### Markdown-it header IDs + +Markdown-it does not add `id` attributes to header elements by default. You can enable this using [markdown-it-anchor](https://www.npmjs.com/package/markdown-it-anchor). +Just using the extension is enough to generate the `id` tags, but if you want to exactly match the marked behavior you can customize the extension. + +```javascript +// config.js +const markdownItAnchor = require('markdown-it-anchor'); + +module.exports = { + markdown_parser: 'markdown-it', + // This will generate id tags, and may be sufficient for your use case + // markdown_it_plugins: [markdownItAnchor], + + // This will match the output of marked + markdown_it_plugins: [(markdownIt) => { + markdownIt.use(markdownItAnchor, { + tabIndex: false, + slugify: (text: string) => { + const id = text + .toLowerCase() + .trim() + // remove html tags + .replace(/<[!/a-z].*?>/gi, '') + // remove unwanted chars + .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, '') + .replace(/\s/g, '-'); + return id; + }, + }); + }] +}; +``` + ## Security Considerations ### Local file server diff --git a/src/lib/get-markdown-it-with-highlighter.ts b/src/lib/get-markdown-it-with-highlighter.ts index 964d373..c32c613 100644 --- a/src/lib/get-markdown-it-with-highlighter.ts +++ b/src/lib/get-markdown-it-with-highlighter.ts @@ -1,6 +1,5 @@ import type MarkdownIt from 'markdown-it'; import markdownit from 'markdown-it'; -import markdownItAnchor from 'markdown-it-anchor'; import markdownItHljs from 'markdown-it-highlightjs'; // Unless otherwise specified: @@ -26,24 +25,6 @@ export const getMarkdownIt = (options: markdownit.Options, plugins: MarkdownIt.P // Add highlightjs markdownIt.use(markdownItHljs); - // Add markdown-it-anchor - // This adds 'id' anchors to header elements, matching the default behavior of marked - // We also tweak it to 'slugify' in the same way - markdownIt.use(markdownItAnchor, { - tabIndex: false, - slugify: (text: string) => { - const id = text - .toLowerCase() - .trim() - // remove html tags - .replace(/<[!/a-z].*?>/gi, '') - // remove unwanted chars - .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, '') - .replace(/\s/g, '-'); - return id; - }, - }); - // Add custom plugins for (const plugin of plugins) { markdownIt.use(plugin); diff --git a/src/test/lib.spec.ts b/src/test/lib.spec.ts index 4ad3188..93fc381 100644 --- a/src/test/lib.spec.ts +++ b/src/test/lib.spec.ts @@ -198,18 +198,6 @@ test('getMarkdownIt can disable HTML', (t) => { t.true(html.includes('<figure>!</figure>')); }); -test('getMarkdownIt slugifies as per marked', (t) => { - const markdownIt = getMarkdownIt({}, []); - const marked = getMarked({}, []); - - const markdownItHtml = markdownIt.render('# Markdown.it is an Italian website\n'); - const markedHtml = marked('# Markdown.it is an Italian website\n'); - const expected = '<h1 id="markdownit-is-an-italian-website">'; - - t.true(markdownItHtml.includes(expected)); - t.true(markedHtml.includes(expected)); -}); - test('getMarkdownIt allows plugins', (t) => { const plugin = (_: MarkdownIt) => { plugin.callCount++;