diff --git a/greenwood.config.js b/greenwood.config.js index d9eb4f88c..6744bc534 100644 --- a/greenwood.config.js +++ b/greenwood.config.js @@ -11,6 +11,7 @@ const FAVICON_HREF = '/assets/favicon.ico'; module.exports = { workspace: path.join(__dirname, 'www'), + mode: 'mpa', title: 'Greenwood', meta: [ { name: 'description', content: META_DESCRIPTION }, diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index b9ce136d9..262650b37 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -1,3 +1,4 @@ +/* eslint-disable max-depth, no-loop-func */ const Buffer = require('buffer').Buffer; const fs = require('fs'); const htmlparser = require('node-html-parser'); @@ -9,7 +10,6 @@ const postcss = require('postcss'); const postcssImport = require('postcss-import'); const replace = require('@rollup/plugin-replace'); const { terser } = require('rollup-plugin-terser'); - const tokenSuffix = 'scratch'; const tokenNodeModules = 'node_modules/'; @@ -98,7 +98,8 @@ function greenwoodWorkspaceResolver (compilation) { // https://github.com/rollup/rollup/issues/2873 function greenwoodHtmlPlugin(compilation) { const { projectDirectory, userWorkspace, outputDir, scratchDir } = compilation.context; - const isRemoteUrl = (url = '') => url.indexOf('http') === 0 || url.indexOf('//') === 0; + const { optimization } = compilation.config; + const isRemoteUrl = (url = undefined) => url && (url.indexOf('http') === 0 || url.indexOf('//') === 0); const customResources = compilation.config.plugins.filter((plugin) => { return plugin.type === 'resource'; }).map((plugin) => { @@ -159,25 +160,29 @@ function greenwoodHtmlPlugin(compilation) { // handle if (!isRemoteUrl(parsedAttributes.src) && parsedAttributes.type === 'module' && parsedAttributes.src && !mappedScripts.get(parsedAttributes.src)) { - const { src } = parsedAttributes; - - // TODO avoid using href and set it to the value of rollup fileName instead - // since user paths can still be the same file, - // e.g. ../theme.css and ./theme.css are still the same file - mappedScripts.set(src, true); + if (optimization === 'static') { + // console.debug('dont emit ', parsedAttributes.src); + } else { + const { src } = parsedAttributes; - const srcPath = src.replace('../', './'); - const basePath = srcPath.indexOf(tokenNodeModules) >= 0 - ? projectDirectory - : userWorkspace; - const source = fs.readFileSync(path.join(basePath, srcPath), 'utf-8'); - - this.emitFile({ - type: 'chunk', - id: srcPath.replace('/node_modules', path.join(projectDirectory, tokenNodeModules)), - name: srcPath.split('/')[srcPath.split('/').length - 1].replace('.js', ''), - source - }); + // TODO avoid using href and set it to the value of rollup fileName instead + // since user paths can still be the same file, + // e.g. ../theme.css and ./theme.css are still the same file + mappedScripts.set(src, true); + + const srcPath = src.replace('../', './'); + const basePath = srcPath.indexOf(tokenNodeModules) >= 0 + ? projectDirectory + : userWorkspace; + const source = fs.readFileSync(path.join(basePath, srcPath), 'utf-8'); + + this.emitFile({ + type: 'chunk', + id: srcPath.replace('/node_modules', path.join(projectDirectory, tokenNodeModules)), + name: srcPath.split('/')[srcPath.split('/').length - 1].replace('.js', ''), + source + }); + } } // handle @@ -312,10 +317,31 @@ function greenwoodHtmlPlugin(compilation) { for (const innerBundleId of Object.keys(bundles)) { const { src } = parsedAttributes; const facadeModuleId = bundles[innerBundleId].facadeModuleId; - const pathToMatch = src.replace('../', '').replace('./', ''); + let pathToMatch = src.replace('../', '').replace('./', ''); + + // special handling for node_modules paths + if (pathToMatch.indexOf(tokenNodeModules) >= 0) { + pathToMatch = pathToMatch.replace(`/${tokenNodeModules}`, ''); + + const pathToMatchPieces = pathToMatch.split('/'); + + pathToMatch = pathToMatch.replace(tokenNodeModules, ''); + pathToMatch = pathToMatch.replace(`${pathToMatchPieces[0]}/`, ''); + } if (facadeModuleId && facadeModuleId.indexOf(pathToMatch) > 0) { - newHtml = newHtml.replace(src, `/${innerBundleId}`); + const newSrc = `/${innerBundleId}`; + + newHtml = newHtml.replace(src, newSrc); + + if (optimization !== 'none' && optimization !== 'inline') { + newHtml = newHtml.replace('
', ` + + + `); + } + } else if (optimization === 'static' && newHtml.indexOf(pathToMatch) > 0) { + newHtml = newHtml.replace(scriptTag, ''); } } } @@ -331,7 +357,16 @@ function greenwoodHtmlPlugin(compilation) { if (bundleId2.indexOf('.css') > 0) { const bundle2 = bundles[bundleId2]; if (href.indexOf(bundle2.name) >= 0) { - newHtml = newHtml.replace(href, `/${bundle2.fileName}`); + const newHref = `/${bundle2.fileName}`; + + newHtml = newHtml.replace(href, newHref); + + if (optimization !== 'none' && optimization !== 'inline') { + newHtml = newHtml.replace('', ` + + + `); + } } } } @@ -363,10 +398,29 @@ function greenwoodHtmlPlugin(compilation) { style: true }); const headScripts = root.querySelectorAll('script'); + const headLinks = root.querySelectorAll('link'); headScripts.forEach((scriptTag) => { const parsedAttributes = parseTagForAttributes(scriptTag); - + const isScriptSrcTag = parsedAttributes.src && parsedAttributes.type === 'module'; + + if (optimization === 'inline' && isScriptSrcTag && !isRemoteUrl(parsedAttributes.src)) { + const src = parsedAttributes.src; + const basePath = src.indexOf(tokenNodeModules) >= 0 + ? process.cwd() + : outputDir; + const outputPath = path.join(basePath, src); + const js = fs.readFileSync(outputPath, 'utf-8'); + + // scratchFiles[src] = true; + + html = html.replace(``, ` + + `); + } + // handle if (parsedAttributes.type === 'module' && !parsedAttributes.src) { for (const innerBundleId of Object.keys(bundles)) { @@ -381,6 +435,29 @@ function greenwoodHtmlPlugin(compilation) { } }); + if (optimization === 'inline') { + headLinks + .forEach((linkTag) => { + const linkTagAttributes = parseTagForAttributes(linkTag); + const isLocalLinkTag = linkTagAttributes.rel === 'stylesheet' + && !isRemoteUrl(linkTagAttributes.href); + + if (isLocalLinkTag) { + const href = linkTagAttributes.href; + const outputPath = path.join(outputDir, href); + const css = fs.readFileSync(outputPath, 'utf-8'); + + // scratchFiles[href] = true; + + html = html.replace(``, ` + + `); + } + }); + } + await fs.promises.writeFile(htmlPath, html); } else { const sourcePath = `${outputDir}/${bundleId}`; @@ -400,13 +477,29 @@ function greenwoodHtmlPlugin(compilation) { module.exports = getRollupConfig = async (compilation) => { const { scratchDir, outputDir } = compilation.context; - + const defaultRollupPlugins = [ + // TODO replace should come in via plugin-node-modules + replace({ // https://github.com/rollup/rollup/issues/487#issuecomment-177596512 + 'process.env.NODE_ENV': JSON.stringify('production') + }), + nodeResolve(), // TODO move to plugin-node-modules + greenwoodWorkspaceResolver(compilation), + greenwoodHtmlPlugin(compilation), + multiInput(), + json() // TODO make it part plugin-standard-json + ]; // TODO greenwood standard plugins, then "Greenwood" plugins, then user plugins const customRollupPlugins = compilation.config.plugins.filter((plugin) => { return plugin.type === 'rollup'; }).map((plugin) => { return plugin.provider(compilation); }).flat(); + + if (compilation.config.optimization !== 'none') { + defaultRollupPlugins.push( + terser() // TODO extract to plugin-standard-javascript + ); + } return [{ // TODO Avoid .greenwood/ directory, do everything in public/? @@ -424,16 +517,7 @@ module.exports = getRollupConfig = async (compilation) => { } }, plugins: [ - // TODO replace should come in via plugins? - replace({ // https://github.com/rollup/rollup/issues/487#issuecomment-177596512 - 'process.env.NODE_ENV': JSON.stringify('production') - }), - nodeResolve(), // TODO move to plugin - greenwoodWorkspaceResolver(compilation), - greenwoodHtmlPlugin(compilation), - multiInput(), - json(), // TODO bundle as part of import support / transforms API? - terser(), // TODO extract to a plugin + ...defaultRollupPlugins, ...customRollupPlugins ] }]; diff --git a/packages/cli/src/lib/router.js b/packages/cli/src/lib/router.js new file mode 100644 index 000000000..12ac91e80 --- /dev/null +++ b/packages/cli/src/lib/router.js @@ -0,0 +1,43 @@ +/* eslint-disable no-underscore-dangle */ +document.addEventListener('click', function(e) { + e.preventDefault(); + + if (e.path[0].href) { + console.debug('linked clicked was...', e.path[0].href); + const target = e.path[0]; + const route = target.href.replace(window.location.origin, ''); + const routerOutlet = Array.from(document.getElementsByTagName('greenwood-route')).filter(outlet => { + return outlet.getAttribute('data-route') === route; + })[0]; + + console.debug('routerOutlet', routerOutlet); + + if (routerOutlet.getAttribute('data-template') === window.__greenwood.currentTemplate) { + window.__greenwood.currentTemplate = routerOutlet.getAttribute('data-template'); + routerOutlet.loadRoute(); + } else { + console.debug('new template detected, should do a hard reload'); + window.location.href = target; + } + } +}); + +class RouteComponent extends HTMLElement { + loadRoute() { + const route = this.getAttribute('data-route'); + const key = this.getAttribute('data-key'); + console.debug('load route ->', route); + console.debug('with bundle ->', key); + fetch(key) + .then(res => res.text()) + .then((response) => { + history.pushState(response, route, route); + document.getElementsByTagName('router-outlet')[0].innerHTML = response; + }); + } +} + +class RouterOutletComponent extends HTMLElement { } + +customElements.define('greenwood-route', RouteComponent); +customElements.define('router-outlet', RouterOutletComponent); \ No newline at end of file diff --git a/packages/cli/src/lifecycles/config.js b/packages/cli/src/lifecycles/config.js index 7e931844e..956dedb84 100644 --- a/packages/cli/src/lifecycles/config.js +++ b/packages/cli/src/lifecycles/config.js @@ -1,13 +1,16 @@ const fs = require('fs'); const path = require('path'); -// TODO const optimizations = ['strict', 'spa']; -let defaultConfig = { +const modes = ['ssg', 'mpa']; +const optimizations = ['default', 'none', 'static', 'inline']; + +const defaultConfig = { workspace: path.join(process.cwd(), 'src'), devServer: { port: 1984 }, - optimization: '', + mode: modes[0], + optimization: optimizations[0], title: 'My App', meta: [], plugins: [], @@ -23,7 +26,7 @@ module.exports = readAndMergeConfig = async() => { if (fs.existsSync(path.join(process.cwd(), 'greenwood.config.js'))) { const userCfgFile = require(path.join(process.cwd(), 'greenwood.config.js')); - const { workspace, devServer, title, markdown, meta, plugins } = userCfgFile; + const { workspace, devServer, title, markdown, meta, mode, optimization, plugins } = userCfgFile; // workspace validation if (workspace) { @@ -61,12 +64,17 @@ module.exports = readAndMergeConfig = async() => { customConfig.meta = meta; } - // TODO - // if (typeof optimization === 'string' && optimizations.indexOf(optimization.toLowerCase()) >= 0) { - // customConfig.optimization = optimization; - // } else if (optimization) { - // reject(`Error: provided optimization "${optimization}" is not supported. Please use one of: ${optimizations.join(', ')}.`); - // } + if (typeof mode === 'string' && modes.indexOf(mode.toLowerCase()) >= 0) { + customConfig.mode = mode; + } else if (mode) { + reject(`Error: provided mode "${mode}" is not supported. Please use one of: ${modes.join(', ')}.`); + } + + if (typeof optimization === 'string' && optimizations.indexOf(optimization.toLowerCase()) >= 0) { + customConfig.optimization = optimization; + } else if (optimization) { + reject(`Error: provided optimization "${optimization}" is not supported. Please use one of: ${optimizations.join(', ')}.`); + } if (plugins && plugins.length > 0) { const types = ['resource', 'rollup', 'server']; diff --git a/packages/cli/src/lifecycles/serialize.js b/packages/cli/src/lifecycles/serialize.js index 0012fc5c3..a11019da7 100644 --- a/packages/cli/src/lifecycles/serialize.js +++ b/packages/cli/src/lifecycles/serialize.js @@ -2,12 +2,14 @@ const BrowserRunner = require('../lib/browser'); const fs = require('fs'); const path = require('path'); const pluginResourceStandardHtml = require('../plugins/resource/plugin-standard-html'); +const pluginOptimizationMpa = require('../plugins/resource/plugin-optimization-mpa'); module.exports = serializeCompilation = async (compilation) => { const compilationCopy = Object.assign({}, compilation); const browserRunner = new BrowserRunner(); const optimizeResources = [ pluginResourceStandardHtml.provider(compilationCopy), + pluginOptimizationMpa().provider(compilationCopy), ...compilation.config.plugins.filter((plugin) => { const provider = plugin.provider(compilationCopy); diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index 24619e6d0..8a41808bb 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -3,6 +3,7 @@ const path = require('path'); const Koa = require('koa'); const pluginNodeModules = require('../plugins/resource/plugin-node-modules'); +const pluginResourceOptimizationMpa = require('../plugins/resource/plugin-optimization-mpa'); const pluginResourceStandardCss = require('../plugins/resource/plugin-standard-css'); const pluginResourceStandardFont = require('../plugins/resource/plugin-standard-font'); const pluginResourceStandardHtml = require('../plugins/resource/plugin-standard-html'); @@ -26,6 +27,7 @@ function getDevServer(compilation) { pluginResourceStandardImage.provider(compilationCopy), pluginResourceStandardJavaScript.provider(compilationCopy), pluginResourceStandardJson.provider(compilationCopy), + pluginResourceOptimizationMpa().provider(compilationCopy), // custom user resource plugins ...compilation.config.plugins.filter((plugin) => { diff --git a/packages/cli/src/plugins/resource/plugin-optimization-mpa.js b/packages/cli/src/plugins/resource/plugin-optimization-mpa.js new file mode 100644 index 000000000..8f1d72f53 --- /dev/null +++ b/packages/cli/src/plugins/resource/plugin-optimization-mpa.js @@ -0,0 +1,106 @@ +/* + * + * Manages web standard resource related operations for JavaScript. + * This is a Greenwood default plugin. + * + */ +const fs = require('fs'); +const path = require('path'); +const { ResourceInterface } = require('../../lib/resource-interface'); + +class OptimizationMPAResource extends ResourceInterface { + constructor(compilation, options) { + super(compilation, options); + this.extensions = ['.html']; + this.contentType = 'text/html'; + this.libPath = '@greenwood/router/router.js'; + } + + async shouldResolve(url) { + return Promise.resolve(url.indexOf(this.libPath) >= 0); + } + + async resolve() { + return new Promise(async (resolve, reject) => { + try { + const routerUrl = path.join(__dirname, '../../', 'lib/router.js'); + + resolve(routerUrl); + } catch (e) { + reject(e); + } + }); + } + + async shouldOptimize(url) { + return Promise.resolve(path.extname(url) === '.html' && this.compilation.config.mode === 'mpa'); + } + + async optimize(url, body) { + return new Promise(async (resolve, reject) => { + try { + let currentTemplate; + const { projectDirectory, scratchDir, outputDir } = this.compilation.context; + const bodyContents = body.match(/(.*)<\/body>/s)[0].replace('', '').replace('', ''); + const outputBundlePath = `${outputDir}/_routes${url.replace(projectDirectory, '')}` + .replace('.greenwood/', '') + .replace('//', '/'); + + const routeTags = this.compilation.graph.map((page) => { + const template = path.extname(page.filename) === '.html' + ? page.route + : page.template; + const key = page.route === '/' + ? '' + : page.route.slice(0, page.route.lastIndexOf('/')); + + if (url.replace(scratchDir, '') === `${page.route}index.html`) { + currentTemplate = template; + } + return ` +