Skip to content

Commit

Permalink
enh(parser) pre/post-highlightBlock callbacks via plugin (#2285)
Browse files Browse the repository at this point in the history
* adds simple plugin API
* adds `before:highlightBlock` plugin hook
* adds `after:highlightBlock` plugin hook
* add plugin documentation
* add plugin recipes for documentation common plugin examples/patterns
* refactor browser tests to avoid global index being necessary
  • Loading branch information
joshgoebel authored Jan 31, 2020
1 parent ff0fb39 commit 07a93c5
Show file tree
Hide file tree
Showing 9 changed files with 329 additions and 54 deletions.
2 changes: 2 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Contents:
reference
css-classes-reference
style-guide
plugin-api
plugin-recipes
language-contribution
building-testing
maintainers-guide
Expand Down
89 changes: 89 additions & 0 deletions docs/plugin-api.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
.. highlight:: javascript

Plugins
=======

Highlight.js supports plugins.

API
---

You can add a plugin via the ``addPlugin`` API.

::

// a plugin can be a class
addPlugin(new SimplePlugin())
addPlugin(new MoreComplexPlugin(options))

// or simply a keyed object of functions
addPlugin({
'after:highlightBlock': (args) => {
...
}
})

Class based plugins
^^^^^^^^^^^^^^^^^^^

This approach is useful for more complex plugins that need to deal with
configuration options or managing state. Highlight.js will instantiate
a single instance of
your class and execute it's callbacks as necessary.

::

class DataLanguagePlugin {
constructor(options) {
self.prefix = options.dataPrefix;
}

'after:highlightBlock'({block, result}) {
// ...
}
}

hljs.addPlugin(new DataLanguagePlugin({dataPrefix: "hljs"}))

Function based plugins
^^^^^^^^^^^^^^^^^^^^^

This approach is best for simpler plugins.

::

hljs.addPlugin( {
'after:highlightBlock': ({block, result}) => {
// move the language from the result into the dataset
block.dataset.language = result.language }
})

Callbacks
---------

after:highlightBlock({block, result})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This callback function is passed an object with two keys:

block
The HTML element of the block that's been highlighted

result
The result object returned by `highlight` or `highlightAuto`.

It returns nothing.


before:highlightBlock({block, language})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This callback function is passed an object with two keys:

block
The HTML element of the block that will be highlighted

language
The language determined from the class attribute (or undefined).

It returns nothing.
26 changes: 26 additions & 0 deletions docs/plugin-recipes.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.. highlight:: javascript

Recipes
==============

Below is a collection of useful plugin "recipes" that you might find helpful.


data-language
-------------

Let's say you'd like to track the language that was auto-detected via a
`data attribute <https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes>`_.
This might prove useful if you desired to add a dynamic label
via CSS with ``:before``, etc.

::

hljs.addPlugin( {
afterHighlightBlock: ({block, result}) => {
// move the language from the result into the dataset
block.dataset.language = result.language }
})



28 changes: 27 additions & 1 deletion src/highlight.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ https://highlightjs.org/

// Global internal variables used within the highlight.js library.
var languages = {},
aliases = {};
aliases = {},
plugins = [];

// safe/production mode - swallows more errors, tries to keep running
// even if a single syntax or parse hits a fatal error
Expand Down Expand Up @@ -868,6 +869,9 @@ https://highlightjs.org/
if (isNotHighlighted(language))
return;

fire("before:highlightBlock",
{ block: block, language: language});

if (options.useBR) {
node = document.createElement('div');
node.innerHTML = block.innerHTML.replace(/\n/g, '').replace(/<br[ \/]*>/g, '\n');
Expand All @@ -885,6 +889,8 @@ https://highlightjs.org/
}
result.value = fixMarkup(result.value);

fire("after:highlightBlock", { block: block, result: result});

block.innerHTML = result.value;
block.className = buildClassName(block.className, language, result.language);
block.result = {
Expand Down Expand Up @@ -976,6 +982,25 @@ https://highlightjs.org/
return lang && !lang.disableAutodetect;
}

function addPlugin(plugin, options) {
plugins.push(plugin);
}

function fire(event, args) {
// var cb = eventToFuncName(event);
var cb = event;
plugins.forEach(function (plugin) {
if (plugin[cb]) {
plugin[cb](args);
}
});
}


function eventToFuncName(event) {
return event.replace(/:([a-z])/, function(el) { return el.toUpperCase().slice(1) })
}

/* Interface definition */

hljs.highlight = highlight;
Expand All @@ -991,6 +1016,7 @@ https://highlightjs.org/
hljs.requireLanguage = requireLanguage;
hljs.autoDetection = autoDetection;
hljs.inherit = inherit;
hljs.addPlugin = addPlugin;
hljs.debugMode = function() { SAFE_MODE = false; }

// Common regexps
Expand Down
122 changes: 122 additions & 0 deletions test/browser/highlight_block_callbacks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use strict';

const {promisify} = require('util');

const {newTestCase, defaultCase, buildFakeDOM } = require('./test_case')

class ContentAdder {
constructor(params) {
this.content = params.content
}
'before:highlightBlock'({block,language}) {
block.innerHTML += this.content;
}
}

describe('callback system', function() {
it("supports class based plugins", async function() {
const testCase = newTestCase({
code: "var b",
language: "javascript",
expect: `<span class="hljs-keyword">var</span> b = <span class="hljs-number">5</span>;`
});
await buildFakeDOM.bind(this)(testCase);

this.hljs.addPlugin(new ContentAdder({content:" = 5;"}))
this.hljs.highlightBlock(this.block);
const actual = this.block.innerHTML;
actual.should.equal(testCase.expect);

})
})

describe('before:highlightBlock', function() {
it('is called', async function() {
await buildFakeDOM.bind(this)(defaultCase);
var called = false;
this.hljs.addPlugin({
'before:highlightBlock': ({block, result}) => {
called = true;
}
});
this.hljs.highlightBlock(this.block);
called.should.equal(true);
})
it('can modify block content before highlight', async function() {
const testCase = newTestCase({
code: "This is the original content.",
language: "javascript"
})
await buildFakeDOM.bind(this)(testCase);

this.hljs.addPlugin({
'before:highlightBlock': ({block, language}) => {
language.should.equal("javascript")
block.innerHTML = "var a;"
}
});

this.hljs.highlightBlock(this.block);
const actual = this.block.innerHTML;
actual.should.equal(
`<span class="hljs-keyword">var</span> a;`);
});

})

describe('after:highlightBlock', function() {
it('is called', async function() {
await buildFakeDOM.bind(this)(defaultCase);
var called = false;
this.hljs.addPlugin({
'after:highlightBlock': ({block, result}) => {
called = true;
}
});
this.hljs.highlightBlock(this.block);
called.should.equal(true);
})
it('receives result data', async function() {
await buildFakeDOM.bind(this)(defaultCase);

this.hljs.addPlugin({
'after:highlightBlock': ({block, result}) => {
result.language.should.equal("javascript")
result.relevance.should.above(0)
}
});

this.hljs.highlightBlock(this.block);
});
it('can override language if not originally provided (in class)', async function() {
var test = newTestCase({
code: "anothingstring",
language: ""
});
await buildFakeDOM.bind(this)(test);
this.hljs.addPlugin({
'after:highlightBlock': ({block, result}) => {
result.language="basic";
}
});

this.hljs.highlightBlock(this.block);
should(this.block.outerHTML.includes(`class="hljs basic"`)).equal(true);

})
it('can modify result and affect the render output', async function() {
var test = newTestCase({
code: "var a = 4;",
language: "javascript"
})
await buildFakeDOM.bind(this)(test);
this.hljs.addPlugin({
'after:highlightBlock': ({block, result}) => {
result.value="redacted";
}
});

this.hljs.highlightBlock(this.block);
this.block.outerHTML.should.equal(`<code class="javascript hljs">redacted</code>`);
})
})
15 changes: 3 additions & 12 deletions test/browser/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,5 @@
'use strict';

describe('browser build', function() {
before(function() {
this.text = 'var say = "Hello";';
this.html = `<pre><code class="javascript">${this.text}</code></pre>`;
this.expect = '<span class="hljs-keyword">' +
'var</span> say = <span class="hljs-string">' +
'"Hello"</span>;';
});

require('./plain');
require('./worker');
});
require('./plain');
require('./worker');
require('./highlight_block_callbacks');
51 changes: 12 additions & 39 deletions test/browser/plain.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,24 @@
'use strict';

const { JSDOM } = require('jsdom');
const utility = require('../utility');
const {promisify} = require('util');
const glob = promisify(require('glob'));
const fs = require('fs');

const buildFakeDOM = async function() {
// Will match both `highlight.pack.js` and `highlight.min.js`
const filePath = utility.buildPath('..', 'build', 'highlight.*.js');
const hljsPath = await glob(filePath)
const hljsFiles = await hljsPath.map(path => fs.readFileSync(path, 'utf8'))
const hljsScript = await hljsFiles.map(file => `<script>${file}</script>`).join("")
const { window} = await new JSDOM(hljsScript + this.html, { runScripts: "dangerously" })

this.block = window.document.querySelector('pre code');
this.hljs = window.hljs;
};
const {newTestCase, defaultCase, buildFakeDOM } = require('./test_case')

describe('browser with html with quotes in attributes', function() {
it('should property escape all quotes', async function() {
this.text = "const oops = pick(employee, <span data-title=\" Type '&quot;height&quot;' is not assignable to type '&quot;name&quot; | &quot;age'&quot; | &quot;profession&quot;'.\">['name', 'height']</span>)\n"
this.html = `<pre><code class="javascript">${this.text}</code></pre>`;

// can't use before because we need to do setup first
await buildFakeDOM.bind(this)();

this.hljs.highlightBlock(this.block);
const actual = this.block.innerHTML;
actual.should.equal(
`<span class="hljs-keyword">const</span> oops = pick(employee, <span data-title=" Type '&quot;height&quot;' is not assignable to type '&quot;name&quot; | &quot;age'&quot; | &quot;profession&quot;'.">[<span class="hljs-string">'name'</span>, <span class="hljs-string">'height'</span>]</span>)\n`);
});
it('should property escape all quotes',
newTestCase({
code: "const oops = pick(employee, <span data-title=\" Type '&quot;height&quot;' is not assignable to type '&quot;name&quot; | &quot;age'&quot; | &quot;profession&quot;'.\">['name', 'height']</span>)\n",
language: "javascript",
expect: `<span class="hljs-keyword">const</span> oops = pick(employee, <span data-title=" Type '&quot;height&quot;' is not assignable to type '&quot;name&quot; | &quot;age'&quot; | &quot;profession&quot;'.">[<span class="hljs-string">'name'</span>, <span class="hljs-string">'height'</span>]</span>)\n`
}).runner
);
})

describe('plain browser', function() {
before(async function() { await buildFakeDOM.bind(this)(); });

it('should return relevance key', function() {
it('should return relevance key', async function() {
await buildFakeDOM.bind(this, defaultCase)();
var out = this.hljs.highlight("javascript","");
out.relevance.should.equal(0);
})

it('should highlight block', function() {
this.hljs.highlightBlock(this.block);

const actual = this.block.innerHTML;

actual.should.equal(this.expect);
});

it('should highlight block', defaultCase.runner);
});
Loading

0 comments on commit 07a93c5

Please sign in to comment.