feat: support building of multi page applications (mpa)

This commit is contained in:
Thomas Allmer
2020-08-01 17:25:24 +02:00
committed by Thomas Allmer
parent f2734ec623
commit a65453576c
29 changed files with 342 additions and 45 deletions

View File

@@ -0,0 +1,3 @@
import './js/homepage-side-effect-dep.js';
window.__homepageSideEffectMetaUrl = import.meta.url;

View File

@@ -0,0 +1,2 @@
export { homepageDepMetaUrl } from './js/homepage-dep.js';
export const homepageMetaUrl = import.meta.url;

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta http-equiv="expires" content="0" />
<title>My Demo</title>
</head>
<body>
<h1>Static content in index.html is preserved</h1>
<a href="./subpage/">SubPage</a>
<div id="test"></div>
<script type="module" src="./homepage-side-effect.js"></script>
<script type="module">
import { homepageMetaUrl, homepageDepMetaUrl } from './homepage.js';
import { navigationMetaUrl } from './navigation.js';
function extractServiceWorkerScriptUrl() {
const code = document.querySelectorAll('script')[2].textContent;
const start = code.indexOf('navigator.serviceWorker.register(');
const end = code.indexOf(')', start + 1);
return code.substring(start + 34, end - 1);
}
(async () => {
window.__tests = {
homepageMetaUrl,
homepageDepMetaUrl,
navigationMetaUrl,
__homepageSideEffectMetaUrl: window.__homepageSideEffectMetaUrl,
__homepageSideEffectDepMetaUrl: window.__homepageSideEffectDepMetaUrl,
serviceWorkerScriptUrl: extractServiceWorkerScriptUrl(),
};
document.getElementById('test').innerHTML = `<pre>${JSON.stringify(
window.__tests,
null,
2,
)}</pre>`;
})();
</script>
</body>
</html>

View File

@@ -0,0 +1 @@
export const homepageDepMetaUrl = import.meta.url;

View File

@@ -0,0 +1 @@
window.__homepageSideEffectDepMetaUrl = import.meta.url;

View File

@@ -0,0 +1 @@
export const subpageDepMetaUrl = import.meta.url;

View File

@@ -0,0 +1 @@
window.__subpageSideEffectDepMetaUrl = import.meta.url;

View File

@@ -0,0 +1 @@
export const navigationMetaUrl = import.meta.url;

View File

@@ -0,0 +1,12 @@
const merge = require('deepmerge');
const { createMpaConfig } = require('../../index.js');
const baseConfig = createMpaConfig({
developmentMode: true,
injectServiceWorker: true,
rootDir: 'demo/mpa',
});
module.exports = merge(baseConfig, {
input: '**/*.html',
});

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta http-equiv="expires" content="0" />
<title>My Demo Subpage</title>
</head>
<body>
<h1>Static content in subpage/index.html is preserved</h1>
<div id="test"></div>
<script type="module" src="./subpage-side-effect.js"></script>
<script type="module">
import { subpageMetaUrl, subpageDepMetaUrl } from './subpage.js';
// supports absolute URLs as well
import { navigationMetaUrl } from '/navigation.js';
function extractServiceWorkerScriptUrl() {
const code = document.querySelectorAll('script')[2].textContent;
const start = code.indexOf('navigator.serviceWorker.register(');
const end = code.indexOf(')', start + 1);
return code.substring(start + 34, end - 1);
}
(async () => {
window.__tests = {
subpageMetaUrl,
subpageDepMetaUrl,
navigationMetaUrl,
__subpageSideEffectMetaUrl: window.__subpageSideEffectMetaUrl,
__subpageSideEffectDepMetaUrl: window.__subpageSideEffectDepMetaUrl,
serviceWorkerScriptUrl: navigator.serviceWorker.controller.scriptURL,
serviceWorkerScriptUrl: extractServiceWorkerScriptUrl(),
};
document.getElementById('test').innerHTML = `<pre>${JSON.stringify(
window.__tests,
null,
2,
)}</pre>`;
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
import '../js/subpage-side-effect-dep.js';
window.__subpageSideEffectMetaUrl = import.meta.url;

View File

@@ -0,0 +1,2 @@
export { subpageDepMetaUrl } from '../js/subpage-dep.js';
export const subpageMetaUrl = import.meta.url;

View File

@@ -5,5 +5,6 @@
const { createBasicConfig } = require('./src/createBasicConfig');
const { createSpaConfig } = require('./src/createSpaConfig');
const { createMpaConfig } = require('./src/createMpaConfig');
module.exports = { createBasicConfig, createSpaConfig };
module.exports = { createBasicConfig, createSpaConfig, createMpaConfig };

View File

@@ -19,16 +19,18 @@
"build:basic": "rimraf dist && rollup -c demo/js/rollup.basic.config.js",
"build:cjs": "rimraf dist && rollup -c demo/cjs/rollup.spa.config.js",
"build:modify-babel-plugin": "rimraf dist && rollup -c demo/modify-babel-plugin/rollup.config.js",
"build:mpa": "rimraf dist && rollup -c demo/mpa/rollup.mpa.config.js",
"build:spa": "rimraf dist && rollup -c demo/js/rollup.spa.config.js",
"build:spa-js-input": "rimraf dist && rollup -c demo/js/rollup.spa-js-input.config.js",
"build:spa-nomodule": "rimraf dist && rollup -c demo/js/rollup.spa-nomodule.config.js",
"build:ts": "rimraf dist && rollup -c demo/ts/rollup.spa.config.js",
"prepublishOnly": "../../scripts/insert-header.js",
"start:babelrc": "npm run build:babelrc && npm run start:build",
"start:build": "es-dev-server --root-dir dist --compatibility none --open",
"start:build": "node ../es-dev-server/dist/cli.js --root-dir dist --compatibility none --open",
"start:cjs": "npm run build:cjs && npm run start:build",
"start:demo": "es-dev-server --app-index demo/js/index.html --open --compatibility none",
"start:demo": "node ../es-dev-server/dist/cli.js --app-index demo/js/index.html --open --compatibility none",
"start:modify-babel-plugin": "npm run build:modify-babel-plugin && npm run start:build",
"start:mpa": "npm run build:mpa && npm run start:build",
"start:spa": "npm run build:spa && npm run start:build",
"start:spa-js-input": "npm run build:spa-js-input && npm run start:build",
"start:spa-nomodule": "npm run build:spa-nomodule && npm run start:build",

View File

@@ -1,7 +1,7 @@
const { babelPluginBundledHelpers } = require('./babel-plugin-bundled-helpers');
const { isFalsy } = require('../utils');
const createBabelConfigRollupBuild = developmentMode => ({
const createBabelConfigRollupBuild = ({ developmentMode, rootDir }) => ({
babelHelpers: 'bundled',
compact: true,
plugins: [
@@ -11,7 +11,7 @@ const createBabelConfigRollupBuild = developmentMode => ({
// plugins that aren't part of @babel/preset-env should be applied regularly in
// the rollup build phase
require.resolve('babel-plugin-bundled-import-meta'),
[require.resolve('babel-plugin-bundled-import-meta'), { bundleDir: rootDir }],
!developmentMode && [
require.resolve('babel-plugin-template-html-minifier'),
{

View File

@@ -28,7 +28,7 @@ function createBasicConfig(userOptions = {}) {
},
userOptions,
);
const { developmentMode } = userOptions;
const { developmentMode, rootDir } = userOptions;
const fileName = `[${developmentMode ? 'name' : 'hash'}].js`;
const assetName = `[${developmentMode ? 'name' : 'hash'}][extname]`;
@@ -60,7 +60,11 @@ function createBasicConfig(userOptions = {}) {
// build non-standard syntax to standard syntax and other babel optimization plugins
// user plugins are deduped to allow overriding
dedupedBabelPlugin(babel, opts.babel, createBabelConfigRollupBuild(developmentMode)),
dedupedBabelPlugin(
babel,
opts.babel,
createBabelConfigRollupBuild({ developmentMode, rootDir }),
),
// minify js code
!developmentMode && pluginWithOptions(terser, opts.terser, { output: { comments: false } }),

View File

@@ -0,0 +1,20 @@
/* eslint-disable */
/** @typedef {import('./types').MpaOptions} MpaOptions */
const merge = require('deepmerge');
const { createSpaConfig } = require('./createSpaConfig.js');
/**
* @param {MpaOptions} options
*/
function createMpaConfig(options) {
const userOptions = merge(
{
html: { flatten: false },
},
options,
);
return createSpaConfig(userOptions);
}
module.exports = { createMpaConfig };

View File

@@ -8,12 +8,7 @@ const polyfillsLoader = require('@open-wc/rollup-plugin-polyfills-loader');
const path = require('path');
const { generateSW } = require('rollup-plugin-workbox');
const { createBasicConfig } = require('./createBasicConfig');
const {
pluginWithOptions,
applyServiceWorkerRegistration,
isFalsy,
createSwPath,
} = require('./utils');
const { pluginWithOptions, applyServiceWorkerRegistration, isFalsy } = require('./utils');
const { defaultPolyfills } = require('./polyfills');
/**
@@ -34,8 +29,19 @@ function createSpaConfig(options) {
);
let outputDir = basicConfig.output.dir;
const swPath = createSwPath(userOptions, outputDir);
const applySw = htmlString => applyServiceWorkerRegistration(htmlString, swPath);
if (userOptions.rootDir) {
if (typeof userOptions.html === 'boolean' && userOptions.html) {
userOptions.html = {
rootDir: userOptions.rootDir,
};
}
if (typeof userOptions.html === 'object') {
userOptions.html.rootDir = userOptions.rootDir;
}
}
const applySw = (htmlString, transformOptions) =>
applyServiceWorkerRegistration(htmlString, transformOptions, userOptions, outputDir);
const htmlPlugin = pluginWithOptions(html, userOptions.html, {
minify: !userOptions.developmentMode,

View File

@@ -1,3 +1,5 @@
import { PluginOptions } from '@open-wc/rollup-plugin-html';
export interface BasicOptions {
outputDir?: string;
nodeResolve?: boolean | object;
@@ -5,16 +7,19 @@ export interface BasicOptions {
terser?: boolean | object;
legacyBuild?: boolean;
developmentMode?: boolean;
rootDir?: string;
}
export interface SpaOptions extends BasicOptions {
html?: boolean | object;
html?: boolean | PluginOptions;
polyfillsLoader?: boolean | object;
workbox?: boolean | WorkboxOptions;
injectServiceWorker?: boolean;
}
export interface MpaOptions extends SpaOptions {}
interface WorkboxOptions {
swDest?: string,
globDirectory?: string,
}
swDest?: string;
globDirectory?: string;
}

View File

@@ -1,6 +1,7 @@
/** @typedef {import('./types').SpaOptions} SpaOptions */
const merge = require('deepmerge');
const path = require('path');
const { createScript } = require('@open-wc/building-utils');
const { parse, serialize } = require('parse5');
const { append, predicates, query } = require('@open-wc/building-utils/dom5-fork');
@@ -39,11 +40,30 @@ function pluginWithOptions(plugin, userConfig, defaultConfig, ...otherParams) {
return plugin(config, ...otherParams);
}
/**
*
* @param {SpaOptions} userOptions
* @param {string} outputDir
* @param {string} htmlFileName
*/
function createSwPath(userOptions, outputDir, htmlFileName) {
let swPath;
if (typeof userOptions.workbox === 'object' && userOptions.workbox.swDest) {
swPath = userOptions.workbox.swDest.replace(`${outputDir}/`, '');
} else {
swPath = './sw.js';
}
swPath = path.relative(path.dirname(htmlFileName), swPath);
return swPath;
}
/**
* @param {string} htmlString
* @returns {string}
*/
function applyServiceWorkerRegistration(htmlString, swPath) {
function applyServiceWorkerRegistration(htmlString, transformOptions, userOptions, outputDir) {
const swPath = createSwPath(userOptions, outputDir, transformOptions.htmlFileName);
const documentAst = parse(htmlString);
const body = query(documentAst, predicates.hasTagName('body'));
const swRegistration = createScript(
@@ -68,21 +88,6 @@ function applyServiceWorkerRegistration(htmlString, swPath) {
return serialize(documentAst);
}
/**
*
* @param {SpaOptions} userOptions
* @param {string} outputDir
*/
function createSwPath(userOptions, outputDir) {
let swPath;
if (typeof userOptions.workbox === 'object' && userOptions.workbox.swDest) {
swPath = userOptions.workbox.swDest.replace(`${outputDir}/`, '');
} else {
swPath = './sw.js';
}
return swPath;
}
module.exports = {
isFalsy,
pluginWithOptions,

View File

@@ -0,0 +1,96 @@
/* eslint-disable global-require, import/no-dynamic-require */
const puppeteer = require('puppeteer');
const { expect } = require('chai');
const path = require('path');
const fs = require('fs');
const rimraf = require('rimraf');
const { rollup } = require('rollup');
const { startServer, createConfig } = require('es-dev-server');
const rootDir = path.resolve(__dirname, '..', 'dist');
describe('integration tests', () => {
let server;
let serverConfig;
/** @type {import('puppeteer').Browser} */
let browser;
before(async () => {
serverConfig = createConfig({
port: 8081,
rootDir,
});
({ server } = await startServer(serverConfig));
browser = await puppeteer.launch();
rimraf.sync(rootDir);
});
after(async () => {
await browser.close();
await new Promise(resolve =>
server.close(() => {
resolve();
}),
);
});
describe(`Mpa Config`, function describe() {
this.timeout(10000);
let page;
before(async () => {
rimraf.sync(rootDir);
const configPath = path.join(__dirname, '..', 'demo', 'mpa', 'rollup.mpa.config.js');
const config = require(configPath);
const bundle = await rollup(config);
if (Array.isArray(config.output)) {
await Promise.all([bundle.write(config.output[0]), bundle.write(config.output[1])]);
} else {
await bundle.write(config.output);
}
page = await browser.newPage();
});
after(() => {
rimraf.sync(rootDir);
});
it('passes the in-browser tests for index.html', async () => {
await page.goto('http://localhost:8081/', {
waitUntil: 'networkidle0',
});
// @ts-ignore
const browserTests = await page.evaluate(() => window.__tests);
expect(browserTests).to.eql({
homepageMetaUrl: 'http://localhost:8081/homepage.js',
homepageDepMetaUrl: 'http://localhost:8081/js/homepage-dep.js',
__homepageSideEffectMetaUrl: 'http://localhost:8081/homepage-side-effect.js',
__homepageSideEffectDepMetaUrl: 'http://localhost:8081/js/homepage-side-effect-dep.js',
navigationMetaUrl: 'http://localhost:8081/navigation.js',
serviceWorkerScriptUrl: 'sw.js',
});
});
it('passes the in-browser tests for subpage/index.html', async () => {
await page.goto('http://localhost:8081/subpage/', {
waitUntil: 'networkidle0',
});
// @ts-ignore
const browserTests = await page.evaluate(() => window.__tests);
expect(browserTests).to.eql({
subpageMetaUrl: 'http://localhost:8081/subpage/subpage.js',
subpageDepMetaUrl: 'http://localhost:8081/js/subpage-dep.js',
__subpageSideEffectMetaUrl: 'http://localhost:8081/subpage/subpage-side-effect.js',
__subpageSideEffectDepMetaUrl: 'http://localhost:8081/js/subpage-side-effect-dep.js',
navigationMetaUrl: 'http://localhost:8081/navigation.js',
serviceWorkerScriptUrl: '../sw.js',
});
});
it('outputs a service worker', () => {
expect(fs.existsSync(path.join(rootDir, 'sw.js'))).to.be.true;
});
});
});

View File

@@ -13,11 +13,12 @@ describe('createSwPath', () => {
},
},
'dist',
'index.html',
),
).to.equal('./foo.js');
).to.equal('foo.js');
});
it('uses "./sw.js" as swPath if no swDest is provided by the workbox config', () => {
it('uses "sw.js" as swPath if no swDest is provided by the workbox config', () => {
expect(
createSwPath(
{
@@ -26,26 +27,29 @@ describe('createSwPath', () => {
},
},
'dist',
'index.html',
),
).to.equal('./sw.js');
).to.equal('sw.js');
});
it('returns "./sw.js" as swPath if the workbox property is a boolean', () => {
it('returns "sw.js" as swPath if the workbox property is a boolean', () => {
expect(
createSwPath(
{
workbox: true,
},
'dist',
'index.html',
),
).to.equal('./sw.js');
).to.equal('sw.js');
expect(
createSwPath(
{
workbox: false,
},
'dist',
'index.html',
),
).to.equal('./sw.js');
).to.equal('sw.js');
});
});

View File

@@ -61,4 +61,4 @@
"lit-html": "^1.0.0",
"lit-element": "^2.0.1"
}
}
}

View File

@@ -24,6 +24,15 @@ const { createHtmlAsset, createHtmlAssets } = require('./src/createHtmlAssets');
const watchMode = process.env.ROLLUP_WATCH === 'true';
const defaultFileName = 'index.html';
/**
* @param {string} id
* @param {string} rootDir
* @return {boolean}
*/
function isAbsoluteUrl(id, rootDir) {
return id.startsWith('/') && !id.startsWith(rootDir);
}
/**
* @param {PluginOptions} pluginOptions
* @returns {RollupPluginHtml}
@@ -128,6 +137,9 @@ function rollupPluginHtml(pluginOptions) {
},
resolveId(id) {
if (pluginOptions.rootDir && isAbsoluteUrl(id, pluginOptions.rootDir)) {
return path.join(pluginOptions.rootDir, id);
}
for (const file of htmlFiles) {
if (file.inlineModules && file.inlineModules.has(id)) {
return id;

View File

@@ -39,6 +39,7 @@ async function createHtmlAsset(
entrypointBundles,
html,
externalTransformFns,
htmlFileName,
});
return {
fileName: htmlFileName,

View File

@@ -39,7 +39,7 @@ function createImportPath({ publicPath, mainOutputDir, fileOutputDir, htmlFileNa
* @param {PluginOptions} args.pluginOptions
* @param {GeneratedBundle[]} args.generatedBundles
* @param {string} args.mainOutputDir
* @param {string} args.htmlFileName
* @param {string | undefined} [args.htmlFileName]
* @param {string[] | undefined} [args.inputModuleIds]
*/
function getEntrypointBundles({

View File

@@ -15,8 +15,15 @@ const defaultHtml = '<!DOCTYPE html><html><head><meta charset="utf-8"></head><bo
* @param {Record<string, EntrypointBundle>} args.entrypointBundles
* @param {TransformFunction[]} [args.externalTransformFns]
* @param {string | undefined} [args.html]
* @param {string | undefined} [args.htmlFileName]
*/
async function getOutputHtml({ pluginOptions, entrypointBundles, externalTransformFns, html }) {
async function getOutputHtml({
pluginOptions,
entrypointBundles,
externalTransformFns,
html,
htmlFileName,
}) {
const { template, inject, minify } = pluginOptions;
let outputHtml;
@@ -55,6 +62,7 @@ async function getOutputHtml({ pluginOptions, entrypointBundles, externalTransfo
outputHtml = await transform(outputHtml, {
bundle: defaultBundle,
bundles: multiBundles,
htmlFileName,
});
}

View File

@@ -25,6 +25,7 @@ export interface PluginOptions {
inputHtml?: string;
/** @deprecated use files instead */
inputPath?: string;
htmlFileName?: string;
}
export type MinifyFunction = (html: string) => string | Promise<string>;
@@ -68,6 +69,7 @@ export interface TransformArgs {
bundle: EntrypointBundle;
// see TemplateArgs
bundles: Record<string, EntrypointBundle>;
htmlFileName: string;
}
export type TransformFunction = (html: string, args: TransformArgs) => string | Promise<string>;

View File

@@ -7,6 +7,7 @@
/** @typedef {import('polyfills-loader').GeneratedFile} GeneratedFile */
/** @typedef {import('./src/types').PluginOptions} PluginOptions */
const path = require('path');
const { injectPolyfillsLoader } = require('polyfills-loader');
const { createError, shouldInjectLoader } = require('./src/utils');
const { createPolyfillsLoaderConfig } = require('./src/createPolyfillsLoaderConfig');
@@ -72,7 +73,12 @@ function rollupPluginPolyfillsLoader(pluginOptions = {}) {
let preloaded = [];
for (const entrypoint of entrypoints) {
preloaded.push(entrypoint.importPath);
preloaded.push(...entrypoint.chunk.imports);
// js files (incl. chunks) will always be in the root directory
const pathToRoot = path.posix.relative('./', path.posix.dirname(entrypoint.importPath));
for (const chunkPath of entrypoint.chunk.imports) {
preloaded.push(path.posix.join(pathToRoot, chunkPath));
}
}
preloaded = [...new Set(preloaded)];