Skip to content

Commit

Permalink
feat: highlight inline code #599
Browse files Browse the repository at this point in the history
  • Loading branch information
jGleitz committed Aug 30, 2022
1 parent 4bdae88 commit e7c91ac
Show file tree
Hide file tree
Showing 38 changed files with 402 additions and 211 deletions.
48 changes: 34 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
# markdown-it-prism [![Build Status](https://travis-ci.org/jGleitz/markdown-it-prism.svg?branch=master)](https://travis-ci.org/jGleitz/markdown-it-prism) [![npm version](https://badge.fury.io/js/markdown-it-prism.svg)](https://badge.fury.io/js/markdown-it-prism) [![Bower version](https://badge.fury.io/bo/markdown-it-prism.svg)](https://badge.fury.io/bo/markdown-it-prism)

> [markdown-it](https://github.com/markdown-it/markdown-it) plugin to highlight code blocks using [Prism](http://prismjs.com/)
## Usage

```js
const md = require('markdown-it')();
const prism = require('markdown-it-prism');

md.use(prism, options);
```

The plugin will insert the necessary markup into all code blocks. [Include one of Prism’s stylesheets](http://prismjs.com/#basic-usage) in your HTML to get highlighted code.
The plugin will insert the necessary markup into all code blocks. [Include one of Prism’s stylesheets](http://prismjs.com/#basic-usage) in
your HTML to get highlighted code.

### Options

The `options` object may contain:

Name | Description | Default
-------|-------------|--------
`plugins` | Array of [Prism Plugins](http://prismjs.com/#plugins) to load. The names to use can be found [here](https://github.com/PrismJS/prism/tree/master/plugins). Please note that some prism plugins (notably line-numbers) rely on the DOM being present and can thus not be used with this package (see [#1](https://github.com/jGleitz/markdown-it-prism/issues/1)). | `[]`
`init` | A function called after setting up prism. Will receive the prism instance as only argument. Useful for plugins needing further intialisation. | `() => {}`
`defaultLanguageForUnknown` | The language to use for code blocks that specify a language that Prism does not know. No default will be used if this option is `undefined`. | `undefined`
`defaultLanguageForUnspecified` | The language to use for code block that do not specify a language. No default will be used if this option is `undefined`. | `undefined`
`defaultLanguage` | Shorthand to set both `defaultLanguageForUnknown` and `defaultLanguageForUnspecified` to the same value. | `undefined`
| Name | Description | Default |
|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------|
| `highlightInlineCode` | Whether to highlight inline code. | `false` |
| `plugins` | Array of [Prism Plugins](http://prismjs.com/#plugins) to load. The names to use can be found [here](https://github.com/PrismJS/prism/tree/master/plugins). Please note that some prism plugins (notably line-numbers) rely on the DOM being present and can thus not be used with this package (see [#1](https://github.com/jGleitz/markdown-it-prism/issues/1)). | `[]` |
| `init` | A function called after setting up prism. Will receive the prism instance as only argument. Useful for plugins needing further intialisation. | `() => {}` |
| `defaultLanguageForUnknown` | The language to use for code blocks that specify a language that Prism does not know. No default will be used if this option is `undefined`. | `undefined` |
| `defaultLanguageForUnspecified` | The language to use for code block that do not specify a language. No default will be used if this option is `undefined`. | `undefined` |
| `defaultLanguage` | Shorthand to set both `defaultLanguageForUnknown` and `defaultLanguageForUnspecified` to the same value. | `undefined` |

### Inline Code

When `highlightInlineCode` is set, inline code will be highlighted just like fenced code blocks are.
To specifiy the language of inline code, add `{language=<your-language>}` after the code segment:

```markdown
`class Demo { };`{language=cpp}
```

This syntax is compatible with [markdown-it-attrs](https://github.com/arve0/markdown-it-attrs):
The `language=<x>` part will be stripped, but everything else between `{` and `}` will work
with [markdown-it-attrs](https://github.com/arve0/markdown-it-attrs) as usual.

## Usage with Webpack

If you want to use this plugin together with [Webpack](https://webpack.js.org/), you need to import all languages you intend to use:

```javascript
Expand All @@ -33,10 +52,10 @@ import "prismjs/components/prism-clike"
import "prismjs/components/prism-java"

function component() {
const md = new MarkdownIt();
md.use(prism);
const element = document.createElement('div');
element.innerHTML = md.render(`
const md = new MarkdownIt();
md.use(prism);
const element = document.createElement('div');
element.innerHTML = md.render(`
Here is some *code*:
\`\`\`java
public class Test {
Expand All @@ -45,10 +64,11 @@ public class Test {
\`\`\`
`);

return element;
return element;
}

document.body.appendChild(component());
```

*Beware*: Prisms languages have dependencies onto each other. You need to import the languages together with their dependencies in the correct order.
*Beware*: Prisms languages have dependencies onto each other. You need to import the languages together with their dependencies in the
correct order.
111 changes: 105 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import Prism, {Grammar} from 'prismjs'
import loadLanguages from 'prismjs/components/'
import MarkdownIt from 'markdown-it'
import {RenderRule} from 'markdown-it/lib/renderer'
import Renderer, {RenderRule} from 'markdown-it/lib/renderer'
import StateCore from 'markdown-it/lib/rules_core/state_core'
import Token from 'markdown-it/lib/token'

const SPECIFIED_LANGUAGE_META_KEY = 'de.joshuagleitze.markdown-it-prism.specifiedLanguage'
type SelectedPrismLanguage = [string, Grammar | undefined]

interface Options {
/**
* Whether to highlight inline code. Defaults to `false`.
*/
highlightInlineCode: boolean
/**
* Prism plugins to load.
*/
Expand All @@ -29,6 +38,7 @@ interface Options {
}

const DEFAULTS: Options = {
highlightInlineCode: false,
plugins: [],
init: () => {
// do nothing by default
Expand Down Expand Up @@ -108,7 +118,24 @@ function selectLanguage(options: Options, lang: string): [string, Grammar | unde
* html-escaped.
*/
function highlight(markdownit: MarkdownIt, options: Options, text: string, lang: string): string {
const [langToUse, prismLang] = selectLanguage(options, lang)
return highlightWithSelectedLanguage(markdownit, options, text, selectLanguage(options, lang))
}

/**
* Highlights the provided text using Prism.
*
* @param markdownit
* The markdown-it instance.
* @param options
* The options that have been used to initialise the plugin.
* @param text
* The text to highlight.
* @param lang
* The selected Prism language to use for highlighting.
* @return If Prism knows the language that {@link selectLanguage} returns for `lang`, the `text` highlighted for that language. Otherwise, `text`
* html-escaped.
*/
function highlightWithSelectedLanguage(markdownit: MarkdownIt, options: Options, text: string, [langToUse, prismLang]: SelectedPrismLanguage): string {
return prismLang ? Prism.highlight(text, prismLang, langToUse) : markdownit.utils.escapeHtml(text)
}

Expand All @@ -122,7 +149,43 @@ function highlight(markdownit: MarkdownIt, options: Options, text: string, lang:
* @return the class to use for `lang`.
*/
function languageClass(markdownit: MarkdownIt, lang: string): string {
return markdownit.options.langPrefix + markdownit.utils.escapeHtml(lang)
return markdownit.options.langPrefix + lang
}


/**
* A {@link RuleCore} that searches for and extracts language specifications on inline code tokens.
*/
function inlineCodeLanguageRule(state: StateCore) {
for (const inlineToken of state.tokens) {
if (inlineToken.type === 'inline' && inlineToken.children !== null) {
for (const [index, token] of inlineToken.children.entries()) {
if (token.type === 'code_inline' && index + 1 < inlineToken.children.length) {
extractInlineCodeSpecifiedLanguage(token, inlineToken.children[index + 1])
}
}
}
}
}

/**
* Searches for a language specification after an inline code token (e.g. ``{language=cpp}). If present, extracts the language, sets
* it on `inlineCodeToken`’s meta, and removes the specification.
*
* @param inlineCodeToken
* The inline code token for which to extract the language.
* @param followingToken
* The token immediately following the `inlineCodeToken`.
*/
function extractInlineCodeSpecifiedLanguage(inlineCodeToken: Token, followingToken: Token) {
const languageSpecificationMatch = followingToken.content.match(/^\{((?:[^\s}]+\s)*)language=([^\s}]+)((?:\s[^\s}]+)*)}/)
if (languageSpecificationMatch !== null) {
inlineCodeToken.meta = {...inlineCodeToken.meta, [SPECIFIED_LANGUAGE_META_KEY]: languageSpecificationMatch[2]}
followingToken.content = followingToken.content.slice(languageSpecificationMatch[0].length)
if (languageSpecificationMatch[1] || languageSpecificationMatch[3]) {
followingToken.content = `{${languageSpecificationMatch[1] || ''}${(languageSpecificationMatch[3] || ' ').slice(1)}}${followingToken.content}`
}
}
}

/**
Expand All @@ -133,7 +196,7 @@ function languageClass(markdownit: MarkdownIt, lang: string): string {
* @param options
* The options that have been used to initialise the plugin.
* @param existingRule
* The currently configured render rule for fenced code blocks.
* The previously configured render rule for fenced code blocks.
*/
function applyCodeAttributes(markdownit: MarkdownIt, options: Options, existingRule: RenderRule): RenderRule {
return (tokens, idx, renderOptions, env, self) => {
Expand All @@ -146,7 +209,7 @@ function applyCodeAttributes(markdownit: MarkdownIt, options: Options, existingR
} else {
fenceToken.info = langToUse
const existingResult = existingRule(tokens, idx, renderOptions, env, self)
const langClass = languageClass(markdownit, langToUse)
const langClass = languageClass(markdownit, markdownit.utils.escapeHtml(langToUse))
return existingResult.replace(
/<((?:pre|code)[^>]*?)(?:\s+class="([^"]*)"([^>]*))?>/g,
(match, tagStart, existingClasses?: string, tagEnd?) =>
Expand All @@ -157,6 +220,31 @@ function applyCodeAttributes(markdownit: MarkdownIt, options: Options, existingR
}
}

/**
* Renders inline code tokens by highlighting them with Prism.
*
* @param markdownit
* The markdown-it instance.
* @param options
* The options that have been used to initialise the plugin.
* @param existingRule
* The previously configured render rule for inline code.
*/
function renderInlineCode(markdownit: MarkdownIt, options: Options, existingRule: RenderRule): RenderRule {
return (tokens, idx, renderOptions, env, self) => {
const inlineCodeToken = tokens[idx]
const specifiedLanguage = inlineCodeToken.meta ? (inlineCodeToken.meta[SPECIFIED_LANGUAGE_META_KEY] || '') : ''
const [langToUse, prismLang] = selectLanguage(options, specifiedLanguage)
if (!langToUse) {
return existingRule(tokens, idx, renderOptions, env, self)
} else {
const highlighted = highlightWithSelectedLanguage(markdownit, options, inlineCodeToken.content, [langToUse, prismLang])
inlineCodeToken.attrJoin('class', languageClass(markdownit, langToUse))
return `<code${self.renderAttrs(inlineCodeToken)}>${highlighted}</code>`
}
}
}

/**
* Checks whether an option represents a valid Prism language
*
Expand All @@ -176,6 +264,13 @@ function checkLanguageOption(
}
}

/**
* ‘the most basic rule to render a token’ (https://github.com/markdown-it/markdown-it/blob/master/docs/examples/renderer_rules.md)
*/
function renderFallback(tokens: Token[], idx: number, options: MarkdownIt.Options, env: unknown, self: Renderer): string {
return self.renderToken(tokens, idx, options)
}

/**
* Initialisation function of the plugin. This function is not called directly by clients, but is rather provided
* to MarkdownIt’s {@link MarkdownIt.use} function.
Expand All @@ -199,5 +294,9 @@ export default function markdownItPrism(markdownit: MarkdownIt, useroptions: Opt

// register ourselves as highlighter
markdownit.options.highlight = (text, lang) => highlight(markdownit, options, text, lang)
markdownit.renderer.rules.fence = applyCodeAttributes(markdownit, options, markdownit.renderer.rules.fence || (() => ''))
markdownit.renderer.rules.fence = applyCodeAttributes(markdownit, options, markdownit.renderer.rules.fence || renderFallback)
if (options.highlightInlineCode) {
markdownit.core.ruler.after('inline', 'prism_inline_code_language', inlineCodeLanguageRule)
markdownit.renderer.rules.code_inline = renderInlineCode(markdownit, options, markdownit.renderer.rules.code_inline || renderFallback)
}
}
113 changes: 113 additions & 0 deletions test/highlighting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import markdownit from 'markdown-it'

import markdownItPrism from '../src'
import {read} from './util'

const codeSectionTypeSettings = {
'fenced': {},
'inline': {highlightInlineCode: true}
}

describe('code highlighting', () => {
Object.entries(codeSectionTypeSettings).forEach(([codeSectionType, options]) => describe(`${codeSectionType} code`, () => {

it('highlights code with a language specification using Prism', async () => {
expect(markdownit()
.use(markdownItPrism, options)
.render(await read(`input/${codeSectionType}/with-language.md`))
).toEqual(await read(`expected/${codeSectionType}/with-language.html`))
})

it('does not add classes to code without language specification', async () => {
expect(markdownit()
.use(markdownItPrism, options)
.render(await read(`input/${codeSectionType}/without-language.md`))
).toEqual(await read(`expected/${codeSectionType}/without-language.html`))
})

it('falls back to defaultLanguageForUnspecified if no language is specified', async () => {
expect(markdownit()
.use(markdownItPrism, {
defaultLanguageForUnspecified: 'java',
...options
})
.render(await read(`input/${codeSectionType}/without-language.md`))
).toEqual(await read(`expected/${codeSectionType}/with-language.html`))
})

it('falls back to defaultLanguage if no language and no defaultLanguageForUnspecified is specified', async () => {
expect(markdownit()
.use(markdownItPrism, {
defaultLanguage: 'java',
...options
})
.render(await read(`input/${codeSectionType}/without-language.md`))
).toEqual(await read(`expected/${codeSectionType}/with-language.html`))
})

it('does not add classes to indented code blocks', async () => {
expect(markdownit()
.use(markdownItPrism, options)
.render(await read('input/indented.md'))
).toEqual(await read('expected/indented.html'))
})

it('adds classes even if the language is unknown', async () => {
expect(markdownit()
.use(markdownItPrism, options)
.render(await read(`input/${codeSectionType}/with-unknown-language.md`))
).toEqual(await read(`expected/${codeSectionType}/with-unknown-language.html`))
})

it('escapes HTML in the language name', async () => {
expect(markdownit()
.use(markdownItPrism, options)
.render(await read(`input/${codeSectionType}/with-html-in-language.md`))
).toEqual(await read(`expected/${codeSectionType}/with-html-in-language.html`))
})

it('falls back to defaultLanguageForUnknown if the specified language is unknown', async () => {
expect(markdownit()
.use(markdownItPrism, {
defaultLanguageForUnknown: 'java',
...options
})
.render(await read(`input/${codeSectionType}/with-unknown-language.md`))
).toEqual(await read(`expected/${codeSectionType}/with-language.html`))
})

it('falls back to defaultLanguage if the specified language is unknown and no defaultLanguageForUnknown is specified', async () => {
expect(markdownit()
.use(markdownItPrism, {
defaultLanguage: 'java',
...options
})
.render(await read(`input/${codeSectionType}/with-unknown-language.md`))
).toEqual(await read(`expected/${codeSectionType}/with-language.html`))
})

it('respects markdown-it’s langPrefix setting', async () => {
expect(
markdownit({
langPrefix: 'test-',
})
.use(markdownItPrism, options)
.render(await read(`input/${codeSectionType}/with-language.md`))
).toEqual(await read(`expected/${codeSectionType}/with-language-prefix.html`))
})

it('is able to resolve C++ correctly', async () => {
expect(markdownit()
.use(markdownItPrism, options)
.render(await read(`input/${codeSectionType}/cpp.md`))
).toEqual(await read(`expected/${codeSectionType}/cpp.html`))
})
}))

it('does not highlight inline code unless configured', async () => {
expect(markdownit()
.use(markdownItPrism, codeSectionTypeSettings.fenced)
.render(await read('input/inline/with-language.md'))
).toEqual(await (read('expected/inline/not-highlighted.html')))
})
})
Loading

0 comments on commit e7c91ac

Please sign in to comment.