Skip to content

Commit

Permalink
Add util to restrict Prism bundle in Webpack (#69)
Browse files Browse the repository at this point in the history
* Add util for webpack plugin to restrict languages

* Don't require js extension for the restricted imports

* Cleanup logic for ensuring values are arrays

* Tweak how dependencies are resolved for Prism

* Document bundle restriction

* Add to changelog
  • Loading branch information
MattIPv4 committed May 9, 2023
1 parent 05e3a54 commit 11bda93
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 14 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Any non-code changes should be prefixed with `(docs)`.
See `PUBLISH.md` for instructions on how to publish a new version.
-->

- (minor) Add util to restrict Prism bundle in Webpack


## v1.7.1 - ddeb4ff6

Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,43 @@ require('@digitalocean/do-markdownit/vendor/prismjs/plugins/copy-to-clipboard/pr
Prism.highlightAll();
```

### Reducing bundle size

PrismJS is a large library that includes a *lot* of language definitions. By default, we use a
dynamic require statement that allows for any language to be loaded for Prism. For bundlers like
Webpack, this will result in a very large chunk being generated that contains all the possible
languages that could be used -- these will all be in a single chunk as they need to be consumed
synchronously in the plugin, so a dynamic async import is not possible.

To reduce the size of the bundle you generate, you may wish to restrict what language definitions
are included. You can do this by restricting what files from
`@digitalocean/do-markdownit/vendor/prismjs/components` are included in the bundle --
`prism-core.js` is the only "required" file, all others are language definitions.

We expose two utilities in the `util/prism_util.js` file to help with this. First is the
`getDependencies` method which takes a PrismJS language and will return all the language
dependencies that also need to be loaded for it. For Webpack users specifically, we also expose a
`restrictWebpack` method that takes an array of PrismJS languages to include and returns a Webpack
plugin you can include to restrict the paths included in the bundle.

```js
const { getDependencies } = require('@digitalocean/do-markdownit/util/prism_util');

console.log(getDependencies('javascript')); // [ 'clike', 'regex', 'markup' ]
console.log(getDependencies('javascript', false)); // [ 'clike', 'markup' ]
```

```js
const { restrictWebpack } = require('@digitalocean/do-markdownit/util/prism_util');

module.exports = {
// ...
plugins: [
restrictWebpack([ 'javascript', 'nginx' ]),
],
};
```

### Keep HTML plugin

Alongside the modified version of Prism, this package also includes a custom Prism plugin designed
Expand Down
27 changes: 13 additions & 14 deletions modifiers/prismjs.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2022 DigitalOcean
Copyright 2023 DigitalOcean
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -21,24 +21,17 @@ limitations under the License.
*/

const Prism = require('../vendor/prismjs');
const components = require('../vendor/prismjs/components');

const safeObject = require('../util/safe_object');
const findTagOpen = require('../util/find_tag_open');
const findAttr = require('../util/find_attr');
const { languages, languageAliases, getDependencies } = require('../util/prism_util');

/**
* @typedef {Object} PrismJsOptions
* @property {string} [delimiter=','] String to split fence information on.
*/

// Get languages that Prism supports
const languages = new Set(Object.keys(components.languages).filter(lang => lang !== 'meta'));
const languageAliases = Object.entries(components.languages).reduce((aliases, [ lang, { alias } ]) => {
if (alias) (Array.isArray(alias) ? alias : [ alias ]).forEach(a => { aliases[a] = lang; });
return aliases;
}, {});

/**
* Helper to load in a language if not yet loaded.
*
Expand All @@ -47,8 +40,12 @@ const languageAliases = Object.entries(components.languages).reduce((aliases, [
*/
const loadLanguage = language => {
if (language in Prism.languages) return;
// eslint-disable-next-line import/no-dynamic-require
require(`../vendor/prismjs/components/prism-${language}`)(Prism);
try {
// eslint-disable-next-line import/no-dynamic-require
require(`../vendor/prismjs/components/prism-${language}`)(Prism);
} catch (err) {
console.error('Failed to load Prism language', language, err);
}
};

// Load our HTML plugin
Expand Down Expand Up @@ -156,7 +153,7 @@ module.exports = (md, options) => {
const tokenInfo = (token.info || '').split(optsObj.delimiter || ',');
const language = tokenInfo.map(info => {
const clean = info.toLowerCase().trim();
return { clean: languageAliases[clean] || clean, original: info };
return { clean: languageAliases.get(clean) || clean, original: info };
}).find(({ clean }) => languages.has(clean));

// If no language, return original
Expand All @@ -166,10 +163,12 @@ module.exports = (md, options) => {
const { before, inside, after } = extractCodeBlock(rendered, language);

// Load requirements for language
const comp = components.languages[language.clean];
if (comp.require) (Array.isArray(comp.require) ? comp.require : [ comp.require ]).forEach(loadLanguage);
getDependencies(language.clean).forEach(loadLanguage);
loadLanguage(language.clean);

// If we failed to load the language (it's a dynamic require), return original
if (!(language.clean in Prism.languages)) return rendered;

// Highlight the code with Prism
const highlighted = Prism.highlight(inside, Prism.languages[language.clean], language.clean);

Expand Down
104 changes: 104 additions & 0 deletions util/prism_util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
Copyright 2023 DigitalOcean
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

'use strict';

/**
* @module util/prism_util
*/

const regexEscape = require('./regex_escape');
const { languages: languagesData } = require('../vendor/prismjs/components');

/**
* Deduplicate an array.
*
* @param {any[]} arr Array to deduplicate.
* @returns {any[]}
* @private
*/
const dedupe = arr => Array.from(new Set(arr));

/**
* Ensure a value is an array, wrapping it if it is not.
*
* @param {any|any[]} val Value to ensure is an array.
* @returns {any[]}
* @private
*/
const array = val => (Array.isArray(val) ? val : [ val ]);

/**
* All languages that Prism supports.
*
* @type {Readonly<Set<string>>}
*/
const languages = Object.freeze(new Set(Object.keys(languagesData).filter(lang => lang !== 'meta')));

/**
* Mapped Prism aliases to their language.
*
* @type {Readonly<Map<string, string>>}
*/
const languageAliases = Object.freeze(Object.entries(languagesData).reduce((aliases, [ lang, { alias } ]) => {
if (alias) array(alias).forEach(a => { aliases.set(a, lang); });
return aliases;
}, new Map()));

/**
* Get all language dependencies for a Prism given language.
*
* @param {string} lang Prism language name to get dependencies for.
* @param {boolean} [optional=true] Whether to include optional dependencies.
* @returns {string[]}
*/
const getDependencies = (lang, optional = true) => {
if (!languages.has(lang)) throw new Error(`Unknown Prism language: ${lang}`);

return dedupe([
array(languagesData[lang].require || []),
optional ? array(languagesData[lang].optional || []) : [],
array(languagesData[lang].modify || []),
].reduce((acc, deps) => [ ...acc, ...deps.flatMap(dep => getDependencies(dep)), ...deps ], []));
};

/**
* Plugin to restrict the languages that are bundled for Prism.
*
* Automatically resolves and includes all dependencies for the given languages.
* This plugin requires that Webpack is installed as a dependency with `ContextReplacementPlugin` available.
*
* @param {string[]} langs Prism languages to restrict to.
* @returns {import('webpack').Plugin}
*/
const restrictWebpack = langs => {
// Webpack is not a dependency, so we only load it here if the user uses this
// eslint-disable-next-line import/no-extraneous-dependencies
const { ContextReplacementPlugin } = require('webpack');

const withDependencies = dedupe(langs.flatMap(lang => [ lang, ...getDependencies(lang) ]));
return new ContextReplacementPlugin(
/@digitalocean[/\\]do-markdownit[/\\]vendor[/\\]prismjs[/\\]components$/,
new RegExp(`prism-(${[ 'core', ...withDependencies.map(dep => regexEscape(dep)) ].join('|')})(\\.js)?$`),
);
};

module.exports = {
languages,
languageAliases,
getDependencies,
restrictWebpack,
};

0 comments on commit 11bda93

Please sign in to comment.