diff --git a/packages/cli/src/lib/layout-utils.js b/packages/cli/src/lib/layout-utils.js index 6165350a8..25c77f5f7 100644 --- a/packages/cli/src/lib/layout-utils.js +++ b/packages/cli/src/lib/layout-utils.js @@ -1,8 +1,17 @@ import fs from 'fs/promises'; import htmlparser from 'node-html-parser'; import { checkResourceExists } from './resource-utils.js'; +import { Worker } from 'worker_threads'; + +async function getCustomPageLayoutsFromPlugins(compilation, layoutName) { + // TODO confirm context plugins work for SSR + // TODO support context plugins for more than just HTML files + const contextPlugins = compilation.config.plugins.filter((plugin) => { + return plugin.type === 'context'; + }).map((plugin) => { + return plugin.provider(compilation); + }); -async function getCustomPageLayoutsFromPlugins(contextPlugins, layoutName) { const customLayoutLocations = []; const layoutDir = contextPlugins .map(plugin => plugin.layouts) @@ -21,19 +30,21 @@ async function getCustomPageLayoutsFromPlugins(contextPlugins, layoutName) { return customLayoutLocations; } -async function getPageLayout(filePath, context, layout, contextPlugins = []) { +async function getPageLayout(filePath, compilation, layout) { + const { context } = compilation; const { layoutsDir, userLayoutsDir, pagesDir, projectDirectory } = context; - const customPluginDefaultPageLayouts = await getCustomPageLayoutsFromPlugins(contextPlugins, 'page'); - const customPluginPageLayouts = await getCustomPageLayoutsFromPlugins(contextPlugins, layout); + const customPluginDefaultPageLayouts = await getCustomPageLayoutsFromPlugins(compilation, 'page'); + const customPluginPageLayouts = await getCustomPageLayoutsFromPlugins(compilation, layout); const extension = filePath.split('.').pop(); const is404Page = filePath.startsWith('404') && extension === 'html'; - const hasCustomLayout = await checkResourceExists(new URL(`./${layout}.html`, userLayoutsDir)); + const hasCustomStaticLayout = await checkResourceExists(new URL(`./${layout}.html`, userLayoutsDir)); + const hasCustomDynamicLayout = await checkResourceExists(new URL(`./${layout}.js`, userLayoutsDir)); const hasPageLayout = await checkResourceExists(new URL('./page.html', userLayoutsDir)); const hasCustom404Page = await checkResourceExists(new URL('./404.html', pagesDir)); const isHtmlPage = extension === 'html' && await checkResourceExists(new URL(`./${filePath}`, projectDirectory)); let contents; - if (layout && (customPluginPageLayouts.length > 0 || hasCustomLayout)) { + if (layout && (customPluginPageLayouts.length > 0 || hasCustomStaticLayout)) { // use a custom layout, usually from markdown frontmatter contents = customPluginPageLayouts.length > 0 ? await fs.readFile(new URL(`./${layout}.html`, customPluginPageLayouts[0]), 'utf-8') @@ -47,6 +58,33 @@ async function getPageLayout(filePath, context, layout, contextPlugins = []) { contents = customPluginDefaultPageLayouts.length > 0 ? await fs.readFile(new URL('./page.html', customPluginDefaultPageLayouts[0]), 'utf-8') : await fs.readFile(new URL('./page.html', userLayoutsDir), 'utf-8'); + } else if (hasCustomDynamicLayout && !is404Page) { + const routeModuleLocationUrl = new URL(`./${layout}.js`, userLayoutsDir); + const routeWorkerUrl = compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider().executeModuleUrl; + + await new Promise(async (resolve, reject) => { + const worker = new Worker(new URL('./ssr-route-worker.js', import.meta.url)); + + worker.on('message', (result) => { + + if (result.body) { + contents = result.body; + } + resolve(); + }); + worker.on('error', reject); + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker stopped with exit code ${code}`)); + } + }); + + worker.postMessage({ + executeModuleUrl: routeWorkerUrl.href, + moduleUrl: routeModuleLocationUrl.href, + compilation: JSON.stringify(compilation) + }); + }); } else if (is404Page && !hasCustom404Page) { contents = await fs.readFile(new URL('./404.html', layoutsDir), 'utf-8'); } else { @@ -58,16 +96,52 @@ async function getPageLayout(filePath, context, layout, contextPlugins = []) { } /* eslint-disable-next-line complexity */ -async function getAppLayout(pageLayoutContents, context, customImports = [], contextPlugins, enableHud, frontmatterTitle) { - const { layoutsDir, userLayoutsDir } = context; - const userAppLayoutUrl = new URL('./app.html', userLayoutsDir); - const customAppLayoutsFromPlugins = await getCustomPageLayoutsFromPlugins(contextPlugins, 'app'); - const hasCustomUserAppLayout = await checkResourceExists(userAppLayoutUrl); +async function getAppLayout(pageLayoutContents, compilation, customImports = [], frontmatterTitle) { + const enableHud = compilation.config.devServer.hud; + const { layoutsDir, userLayoutsDir } = compilation.context; + const userStaticAppLayoutUrl = new URL('./app.html', userLayoutsDir); + // TODO support more than just .js files + const userDynamicAppLayoutUrl = new URL('./app.js', userLayoutsDir); + const userHasStaticAppLayout = await checkResourceExists(userStaticAppLayoutUrl); + const userHasDynamicAppLayout = await checkResourceExists(userDynamicAppLayoutUrl); + const customAppLayoutsFromPlugins = await getCustomPageLayoutsFromPlugins(compilation, 'app'); + let dynamicAppLayoutContents; + + if (userHasDynamicAppLayout) { + const routeWorkerUrl = compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider().executeModuleUrl; + + await new Promise(async (resolve, reject) => { + const worker = new Worker(new URL('./ssr-route-worker.js', import.meta.url)); + + worker.on('message', (result) => { + + if (result.body) { + dynamicAppLayoutContents = result.body; + } + resolve(); + }); + worker.on('error', reject); + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker stopped with exit code ${code}`)); + } + }); + + worker.postMessage({ + executeModuleUrl: routeWorkerUrl.href, + moduleUrl: userDynamicAppLayoutUrl.href, + compilation: JSON.stringify(compilation) + }); + }); + } + let appLayoutContents = customAppLayoutsFromPlugins.length > 0 ? await fs.readFile(new URL('./app.html', customAppLayoutsFromPlugins[0])) - : hasCustomUserAppLayout - ? await fs.readFile(userAppLayoutUrl, 'utf-8') - : await fs.readFile(new URL('./app.html', layoutsDir), 'utf-8'); + : userHasStaticAppLayout + ? await fs.readFile(userStaticAppLayoutUrl, 'utf-8') + : userHasDynamicAppLayout + ? dynamicAppLayoutContents + : await fs.readFile(new URL('./app.html', layoutsDir), 'utf-8'); let mergedLayoutContents = ''; const pageRoot = pageLayoutContents && htmlparser.parse(pageLayoutContents, { diff --git a/packages/cli/src/lib/ssr-route-worker.js b/packages/cli/src/lib/ssr-route-worker.js index 239eb49a3..4c3f25b46 100644 --- a/packages/cli/src/lib/ssr-route-worker.js +++ b/packages/cli/src/lib/ssr-route-worker.js @@ -1,7 +1,7 @@ // https://github.com/nodejs/modules/issues/307#issuecomment-858729422 import { parentPort } from 'worker_threads'; -async function executeModule({ executeModuleUrl, moduleUrl, compilation, page, prerender = false, htmlContents = null, scripts = '[]', request }) { +async function executeModule({ executeModuleUrl, moduleUrl, compilation = '{}', page = '{}', prerender = false, htmlContents = null, scripts = '[]', request }) { const { executeRouteModule } = await import(executeModuleUrl); const data = await executeRouteModule({ moduleUrl, compilation: JSON.parse(compilation), page: JSON.parse(page), prerender, htmlContents, scripts: JSON.parse(scripts), request }); diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 5813983d2..bc6ed62a1 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -198,13 +198,6 @@ async function bundleApiRoutes(compilation) { } async function bundleSsrPages(compilation, optimizePlugins) { - // https://rollupjs.org/guide/en/#differences-to-the-javascript-api - // TODO context plugins for SSR ? - // const contextPlugins = compilation.config.plugins.filter((plugin) => { - // return plugin.type === 'context'; - // }).map((plugin) => { - // return plugin.provider(compilation); - // }); const { context, config } = compilation; const ssrPages = compilation.graph.filter(page => page.isSSR && !page.prerender); const ssrPrerenderPagesRouteMapper = {}; @@ -227,8 +220,8 @@ async function bundleSsrPages(compilation, optimizePlugins) { const data = await executeRouteModule({ moduleUrl, compilation, page, prerender: false, htmlContents: null, scripts: [], request }); let staticHtml = ''; - staticHtml = data.layout ? data.layout : await getPageLayout(staticHtml, compilation.context, layout, []); - staticHtml = await getAppLayout(staticHtml, context, imports, [], false, title); + staticHtml = data.layout ? data.layout : await getPageLayout(staticHtml, compilation, layout); + staticHtml = await getAppLayout(staticHtml, compilation, imports, title); staticHtml = await getUserScripts(staticHtml, compilation); staticHtml = await (await interceptPage(new URL(`http://localhost:8080${route}`), new Request(new URL(`http://localhost:8080${route}`)), getPluginInstances(compilation), staticHtml)).text(); diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js index 6f6b497ff..f9452ee28 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-html.js +++ b/packages/cli/src/plugins/resource/plugin-standard-html.js @@ -137,20 +137,13 @@ class StandardHtmlResource extends ResourceInterface { }); } - // get context plugins - const contextPlugins = this.compilation.config.plugins.filter((plugin) => { - return plugin.type === 'context'; - }).map((plugin) => { - return plugin.provider(this.compilation); - }); - if (isSpaRoute) { body = await fs.readFile(new URL(`./${isSpaRoute.filename}`, userWorkspace), 'utf-8'); } else { - body = ssrLayout ? ssrLayout : await getPageLayout(filePath, context, layout, contextPlugins); + body = ssrLayout ? ssrLayout : await getPageLayout(filePath, this.compilation, layout); } - body = await getAppLayout(body, context, customImports, contextPlugins, config.devServer.hud, title); + body = await getAppLayout(body, this.compilation, customImports, title); body = await getUserScripts(body, this.compilation); if (processedMarkdown) { diff --git a/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/build.default.workspace-template-page-and-app-dynamic.spec.js b/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/build.default.workspace-template-page-and-app-dynamic.spec.js new file mode 100644 index 000000000..d791aea27 --- /dev/null +++ b/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/build.default.workspace-template-page-and-app-dynamic.spec.js @@ -0,0 +1,105 @@ +/* + * Use Case + * Run Greenwood build command with no config and dynamic (e.g. .js) custom page (and app) layouts. + * + * User Result + * Should generate a bare bones Greenwood build with custom page layout. + * + * User Command + * greenwood build + * + * User Config + * None (Greenwood Default) + * + * User Workspace + * src/ + * pages/ + * index.md + * layouts/ + * app.js + * page.js + */ +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { runSmokeTest } from '../../../../../test/smoke-test.js'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Default Greenwood Configuration and Workspace w/Custom Dynamic App and Page Layouts using JavaScript'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + before(function() { + runner.setup(outputPath, getSetupFiles(outputPath)); + runner.runCommand(cliPath, 'build'); + }); + + runSmokeTest(['public'], LABEL); + + describe('Custom App and Page Layout', function() { + let dom; + + before(async function() { + dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'index.html')); + }); + + it('should have the expected tag from the dynamic app layout', function() { + const title = dom.window.document.querySelectorAll('head title'); + + expect(title.length).to.equal(1); + expect(title[0].textContent).to.equal('App Layout'); + }); + + it('should have the expected <h1> tag from the dynamic app layout', function() { + const heading = dom.window.document.querySelectorAll('h1'); + + expect(heading.length).to.equal(1); + expect(heading[0].textContent).to.equal('App Layout'); + }); + + it('should have the expected <h2> tag from the dynamic page layout', function() { + const heading = dom.window.document.querySelectorAll('h2'); + + expect(heading.length).to.equal(1); + expect(heading[0].textContent).to.equal('Page Layout'); + }); + + it('should have the expected content from the index.md', function() { + const heading = dom.window.document.querySelectorAll('h3'); + const paragraph = dom.window.document.querySelectorAll('p'); + + expect(heading.length).to.equal(1); + expect(heading[0].textContent).to.equal('Home Page'); + + expect(paragraph.length).to.equal(1); + expect(paragraph[0].textContent).to.equal('Coffey was here'); + }); + + it('should have the expected <footer> tag from the dynamic app layout', function() { + const year = new Date().getFullYear(); + const footer = dom.window.document.querySelectorAll('footer'); + + expect(footer.length).to.equal(1); + expect(footer[0].textContent).to.equal(`${year}`); + }); + }); + }); + + after(function() { + runner.teardown(getOutputTeardownFiles(outputPath)); + }); +}); \ No newline at end of file diff --git a/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/src/layouts/app.js b/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/src/layouts/app.js new file mode 100644 index 000000000..6be8ae31b --- /dev/null +++ b/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/src/layouts/app.js @@ -0,0 +1,20 @@ +export default class AppLayout extends HTMLElement { + async connectedCallback() { + const year = new Date().getFullYear(); + + this.innerHTML = ` + <!DOCTYPE html> + <html> + <head> + <title>App Layout + + + +

App Layout

+ + + + + `; + } +} \ No newline at end of file diff --git a/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/src/layouts/page.js b/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/src/layouts/page.js new file mode 100644 index 000000000..bc19c5555 --- /dev/null +++ b/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/src/layouts/page.js @@ -0,0 +1,10 @@ +export default class PageLayout extends HTMLElement { + async connectedCallback() { + this.innerHTML = ` + +

Page Layout

+ + + `; + } +} \ No newline at end of file diff --git a/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/src/pages/index.md b/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/src/pages/index.md new file mode 100644 index 000000000..6f24cffb8 --- /dev/null +++ b/packages/cli/test/cases/build.default.workspace-template-page-and-app-dynamic/src/pages/index.md @@ -0,0 +1,3 @@ +### Home Page + +Coffey was here \ No newline at end of file