use Webpack code-splitting to dynamically import Highlight.js languages as they are detected by Showdown Markdown extension

This commit is contained in:
Alec Merdler 2017-08-01 13:28:24 -04:00
parent 8dc2a99926
commit 41c12c853c
9 changed files with 70 additions and 33 deletions

View file

@ -20,12 +20,11 @@ RUN virtualenv --distribute venv \
# Install front-end dependencies # Install front-end dependencies
# JS depedencies # JS depedencies
COPY yarn.lock ./ COPY yarn.lock package.json tsconfig.json webpack.config.js tslint.json ./
RUN yarn install --ignore-engines RUN yarn install --ignore-engines
# JS compile # JS compile
COPY static static COPY static static
COPY package.json tsconfig.json webpack.config.js tslint.json ./
RUN yarn build \ RUN yarn build \
&& jpegoptim static/img/**/*.jpg \ && jpegoptim static/img/**/*.jpg \
&& optipng -clobber -quiet static/img/**/*.png && optipng -clobber -quiet static/img/**/*.png

View file

@ -56,10 +56,10 @@ def common_login(user_uuid, permanent_session=True):
return False return False
def _list_files(path, extension): def _list_files(path, extension, contains=""):
""" Returns a list of all the files with the given extension found under the given path. """ """ Returns a list of all the files with the given extension found under the given path. """
def matches(f): def matches(f):
return os.path.splitext(f)[1] == '.' + extension and f.split(os.path.extsep)[1] != 'spec' return os.path.splitext(f)[1] == '.' + extension and contains in os.path.splitext(f)[0]
def join_path(dp, f): def join_path(dp, f):
# Remove the static/ prefix. It is added in the template. # Remove the static/ prefix. It is added in the template.
@ -74,7 +74,7 @@ def render_page_template(name, route_data=None, **kwargs):
library_styles = [] library_styles = []
main_styles = [] main_styles = []
library_scripts = [] library_scripts = []
main_scripts = _list_files('build', 'js') main_scripts = _list_files('build', 'js', "bundle")
use_cdn = app.config.get('USE_CDN', True) use_cdn = app.config.get('USE_CDN', True)
if request.args.get('use_cdn') is not None: if request.args.get('use_cdn') is not None:

View file

@ -53,8 +53,6 @@
"@types/core-js": "^0.9.39", "@types/core-js": "^0.9.39",
"@types/jasmine": "^2.5.41", "@types/jasmine": "^2.5.41",
"@types/jquery": "^2.0.40", "@types/jquery": "^2.0.40",
"@types/react": "0.14.39",
"@types/react-dom": "0.14.17",
"@types/showdown": "^1.4.32", "@types/showdown": "^1.4.32",
"angular-mocks": "1.6.2", "angular-mocks": "1.6.2",
"css-loader": "0.25.0", "css-loader": "0.25.0",

View file

@ -29,6 +29,16 @@ describe("MarkdownEditorComponent", () => {
}); });
}); });
describe("ngOnDestroy", () => {
it("removes 'beforeunload' event listener", () => {
$windowMock.setup(mock => mock.onbeforeunload).is(() => 1);
component.ngOnDestroy();
expect($windowMock.Object.onbeforeunload.call(this)).toEqual(null);
});
});
describe("changeEditMode", () => { describe("changeEditMode", () => {
it("sets component's edit mode to given mode", () => { it("sets component's edit mode to given mode", () => {
@ -147,7 +157,15 @@ describe("MarkdownEditorComponent", () => {
describe("discardChanges", () => { describe("discardChanges", () => {
it("emits output event with no content", (done) => { it("prompts user to confirm discarding changes", () => {
const confirmSpy: Spy = $windowMock.setup(mock => mock.confirm).is((message) => false).Spy;
component.discardChanges();
expect(confirmSpy.calls.argsFor(0)[0]).toEqual(`Are you sure you want to discard your changes?`);
});
it("emits output event with no content if user confirms discarding changes", (done) => {
$windowMock.setup(mock => mock.confirm).is((message) => true);
component.discard.subscribe((event: {}) => { component.discard.subscribe((event: {}) => {
expect(event).toEqual({}); expect(event).toEqual({});
done(); done();
@ -155,5 +173,16 @@ describe("MarkdownEditorComponent", () => {
component.discardChanges(); component.discardChanges();
}); });
it("does not emit output event if user declines confirmation of discarding changes", (done) => {
$windowMock.setup(mock => mock.confirm).is((message) => false);
component.discard.subscribe((event: {}) => {
fail(`Should not emit output event`);
done();
});
component.discardChanges();
done();
});
}); });
}); });

View file

@ -1,4 +1,4 @@
import { Component, Inject, Input, Output, EventEmitter, ViewChild, HostListener } from 'ng-metadata/core'; import { Component, Inject, Input, Output, EventEmitter, ViewChild, HostListener, OnDestroy } from 'ng-metadata/core';
import { MarkdownSymbol } from '../../../types/common.types'; import { MarkdownSymbol } from '../../../types/common.types';
import { BrowserPlatform } from '../../../constants/platform.constant'; import { BrowserPlatform } from '../../../constants/platform.constant';
import './markdown-editor.component.css'; import './markdown-editor.component.css';
@ -11,7 +11,7 @@ import './markdown-editor.component.css';
selector: 'markdown-editor', selector: 'markdown-editor',
templateUrl: '/static/js/directives/ui/markdown/markdown-editor.component.html' templateUrl: '/static/js/directives/ui/markdown/markdown-editor.component.html'
}) })
export class MarkdownEditorComponent { export class MarkdownEditorComponent implements OnDestroy {
@Input('<') public content: string; @Input('<') public content: string;
@ -34,6 +34,10 @@ export class MarkdownEditorComponent {
return false; return false;
} }
public ngOnDestroy(): void {
this.$window.onbeforeunload = () => null;
}
public changeEditMode(newMode: EditMode): void { public changeEditMode(newMode: EditMode): void {
this.editMode = newMode; this.editMode = newMode;
} }
@ -110,8 +114,10 @@ export class MarkdownEditorComponent {
} }
public discardChanges(): void { public discardChanges(): void {
if (this.$window.confirm(`Are you sure you want to discard your changes?`)) {
this.discard.emit({}); this.discard.emit({});
} }
}
public get currentEditMode(): EditMode { public get currentEditMode(): EditMode {
return this.editMode; return this.editMode;

View file

@ -1,5 +1,5 @@
import { NgModule } from 'ng-metadata/core'; import { NgModule } from 'ng-metadata/core';
import { Converter, ConverterOptions } from 'showdown'; import { Converter } from 'showdown';
import * as showdown from 'showdown'; import * as showdown from 'showdown';
import { registerLanguage, highlightAuto } from 'highlight.js/lib/highlight'; import { registerLanguage, highlightAuto } from 'highlight.js/lib/highlight';
import 'highlight.js/styles/vs.css'; import 'highlight.js/styles/vs.css';
@ -10,14 +10,15 @@ const highlightedLanguages: string[] = require('../../../constants/highlighted-l
* Dynamically fetch and register a new language with Highlight.js * Dynamically fetch and register a new language with Highlight.js
*/ */
export const addHighlightedLanguage = (language: string): Promise<{}> => { export const addHighlightedLanguage = (language: string): Promise<{}> => {
return new Promise((resolve, reject) => { return new Promise(async(resolve, reject) => {
try { try {
// TODO(alecmerdler): Use `import()` here instead of `require()` // TODO(alecmerdler): Use `import()` here instead of `System.import()` after upgrading to TypeScript 2.4
const langModule = require(`highlight.js/lib/languages/${language}`); const langModule = await System.import(`highlight.js/lib/languages/${language}`);
registerLanguage(language, langModule); registerLanguage(language, langModule);
console.debug(`Language ${language} registered for syntax highlighting`);
resolve(); resolve();
} catch (error) { } catch (error) {
console.log(`Language ${language} not supported for syntax highlighting`); console.debug(`Language ${language} not supported for syntax highlighting`);
reject(error); reject(error);
} }
}); });
@ -25,7 +26,7 @@ export const addHighlightedLanguage = (language: string): Promise<{}> => {
/** /**
* Showdown JS extension for syntax highlighting using Highlight.js * Showdown JS extension for syntax highlighting using Highlight.js. Will attempt to register detected languages.
*/ */
export const showdownHighlight = (): showdown.FilterExtension => { export const showdownHighlight = (): showdown.FilterExtension => {
const htmlunencode = (text: string) => { const htmlunencode = (text: string) => {
@ -39,7 +40,10 @@ export const showdownHighlight = (): showdown.FilterExtension => {
const right = '</code></pre>'; const right = '</code></pre>';
const flags = 'g'; const flags = 'g';
const replacement = (wholeMatch: string, match: string, leftSide: string, rightSide: string) => { const replacement = (wholeMatch: string, match: string, leftSide: string, rightSide: string) => {
// TODO(alecmerdler): Call `addHighlightedLanguage` to load new languages that are detected using code-splitting const language: string = leftSide.slice(leftSide.indexOf('language-') + ('language-').length,
leftSide.indexOf('"', leftSide.indexOf('language-')));
addHighlightedLanguage(language).catch(error => null);
match = htmlunencode(match); match = htmlunencode(match);
return leftSide + highlightAuto(match).value + rightSide; return leftSide + highlightAuto(match).value + rightSide;
}; };
@ -53,7 +57,7 @@ export const showdownHighlight = (): showdown.FilterExtension => {
}; };
// Dynamically import syntax-highlighting supported languages // Import default syntax-highlighting supported languages
highlightedLanguages.forEach((langName) => addHighlightedLanguage(langName)); highlightedLanguages.forEach((langName) => addHighlightedLanguage(langName));

3
static/js/types/custom.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare var System: {
import: (module: string) => Promise<any>;
};

View file

@ -1,4 +1,4 @@
declare var require: any; declare var require: NodeRequire;
// Require all modules ending in ".spec.ts" from the js directory and all subdirectories // Require all modules ending in ".spec.ts" from the js directory and all subdirectories
var testsContext = (<any>require).context("../js", true, /\.spec\.ts$/); var testsContext = (<any>require).context("../js", true, /\.spec\.ts$/);

View file

@ -1,13 +1,14 @@
const webpack = require('webpack'); const webpack = require('webpack');
const path = require('path'); const path = require('path');
const highlightedLanguages = require('./static/js/constants/highlighted-languages.constant.json');
let config = { let config = {
entry: "./static/js/main.ts", entry: "./static/js/main.ts",
output: { output: {
path: path.resolve(__dirname, "static/build"), path: path.resolve(__dirname, "static/build"),
filename: 'quay-frontend.js' publicPath: "/static/build/",
filename: '[name]-quay-frontend.bundle.js',
chunkFilename: '[name]-quay-frontend.chunk.js'
}, },
resolve: { resolve: {
extensions: [".ts", ".js"], extensions: [".ts", ".js"],
@ -47,10 +48,6 @@ let config = {
angular: "angular", angular: "angular",
$: "jquery", $: "jquery",
}), }),
// Whitelist highlight-supported languages (based on https://bjacobel.com/2016/12/04/highlight-bundle-size/)
new webpack.ContextReplacementPlugin(
/highlight\.js\/lib\/languages$/,
new RegExp(`^./(${highlightedLanguages.join('|')})$`)),
], ],
devtool: "cheap-module-source-map", devtool: "cheap-module-source-map",
}; };
@ -60,14 +57,15 @@ let config = {
* Production settings * Production settings
*/ */
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
config.plugins.push( config.plugins.concat([
new webpack.optimize.UglifyJsPlugin({ new webpack.optimize.UglifyJsPlugin({
sourceMap: true, sourceMap: true,
// Disable mangle to prevent AngularJS errors // Disable mangle to prevent AngularJS errors
mangle: false mangle: false
}) }),
); new webpack.optimize.CommonsChunkPlugin({name: 'common'}),
config.output.filename = 'quay-frontend-[hash].js'; ]);
config.output.filename = '[name]-quay-frontend-[hash].bundle.js';
} }
module.exports = config; module.exports = config;