diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d3e92b598..90ed23df3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -57,7 +57,7 @@ The [layout](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/ - _lib/_ - Custom utility and client facing files - _lifecycles/_ - Tasks that can be composed by commands to support the full needs of that command - _plugins/_ - Custom default plugins maintained by the CLI project -- _templates/_ - Default templates and / or pages provided by Greenwood. +- _layouts/_ - Default layouts and / or pages provided by Greenwood. #### Lifecycles diff --git a/packages/cli/package.json b/packages/cli/package.json index 2b70e5367..66b577095 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -52,7 +52,7 @@ "remark-rehype": "^7.0.0", "rollup": "^3.29.4", "unified": "^9.2.0", - "wc-compiler": "~0.13.0" + "wc-compiler": "~0.14.0" }, "devDependencies": { "@babel/runtime": "^7.10.4", diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index e2cc575fe..82937397c 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -1,70 +1,10 @@ import { bundleCompilation } from '../lifecycles/bundle.js'; -import { checkResourceExists, trackResourcesForRoute } from '../lib/resource-utils.js'; +import { checkResourceExists } from '../lib/resource-utils.js'; import { copyAssets } from '../lifecycles/copy.js'; import fs from 'fs/promises'; import { preRenderCompilationWorker, preRenderCompilationCustom, staticRenderCompilation } from '../lifecycles/prerender.js'; import { ServerInterface } from '../lib/server-interface.js'; -// TODO a lot of these are duplicated in the prerender lifecycle too -// would be good to refactor -async function servePage(url, request, plugins) { - let response = new Response(''); - - for (const plugin of plugins) { - if (plugin.shouldServe && await plugin.shouldServe(url, request)) { - response = await plugin.serve(url, request); - break; - } - } - - return response; -} - -async function interceptPage(url, request, plugins, body) { - let response = new Response(body, { - headers: new Headers({ 'Content-Type': 'text/html' }) - }); - - for (const plugin of plugins) { - if (plugin.shouldPreIntercept && await plugin.shouldPreIntercept(url, request, response)) { - response = await plugin.preIntercept(url, request, response); - } - - if (plugin.shouldIntercept && await plugin.shouldIntercept(url, request, response)) { - response = await plugin.intercept(url, request, response); - } - } - - return response; -} - -function getPluginInstances (compilation) { - return [...compilation.config.plugins] - .filter(plugin => plugin.type === 'resource' && plugin.name !== 'plugin-node-modules:resource') - .map((plugin) => { - return plugin.provider(compilation); - }); -} - -// TODO does this make more sense in bundle lifecycle? -// https://github.com/ProjectEvergreen/greenwood/issues/970 -// or could this be done sooner (like in appTemplate building in html resource plugin)? -// Or do we need to ensure userland code / plugins have gone first -async function trackResourcesForRoutes(compilation) { - const plugins = getPluginInstances(compilation); - - for (const page of compilation.graph) { - const { route } = page; - const url = new URL(`http://localhost:${compilation.config.port}${route}`); - const request = new Request(url); - - let body = await (await servePage(url, request, plugins)).text(); - body = await (await interceptPage(url, request, plugins, body)).text(); - - await trackResourcesForRoute(body, compilation, route); - } -} - const runProductionBuild = async (compilation) => { return new Promise(async (resolve, reject) => { @@ -106,13 +46,11 @@ const runProductionBuild = async (compilation) => { })); if (prerenderPlugin.executeModuleUrl) { - await trackResourcesForRoutes(compilation); await preRenderCompilationWorker(compilation, prerenderPlugin); } else { await preRenderCompilationCustom(compilation, prerenderPlugin); } } else { - await trackResourcesForRoutes(compilation); await staticRenderCompilation(compilation); } diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index 87a8fddcd..3bb9b8ce3 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -423,10 +423,10 @@ const getRollupConfigForScriptResources = async (compilation) => { }; const getRollupConfigForApis = async (compilation) => { - const { outputDir, userWorkspace } = compilation.context; + const { outputDir, pagesDir } = compilation.context; return [...compilation.manifest.apis.values()] - .map(api => normalizePathnameForWindows(new URL(`.${api.path}`, userWorkspace))) + .map(api => normalizePathnameForWindows(new URL(`.${api.path}`, pagesDir))) .map(filepath => ({ input: filepath, output: { @@ -445,7 +445,27 @@ const getRollupConfigForApis = async (compilation) => { }), commonjs(), greenwoodImportMetaUrl(compilation) - ] + ], + onwarn: (errorObj) => { + const { code, message } = errorObj; + + switch (code) { + + case 'CIRCULAR_DEPENDENCY': + // let this through for WCC + sucrase + // Circular dependency: ../../../../../node_modules/sucrase/dist/esm/parser/tokenizer/index.js -> + // ../../../../../node_modules/sucrase/dist/esm/parser/traverser/util.js -> ../../../../../node_modules/sucrase/dist/esm/parser/tokenizer/index.js + // Circular dependency: ../../../../../node_modules/sucrase/dist/esm/parser/tokenizer/index.js -> + // ../../../../../node_modules/sucrase/dist/esm/parser/tokenizer/readWord.js -> ../../../../../node_modules/sucrase/dist/esm/parser/tokenizer/index.js + // https://github.com/ProjectEvergreen/greenwood/pull/1212 + // https://github.com/lit/lit/issues/449#issuecomment-416688319 + break; + default: + // otherwise, log all warnings from rollup + console.debug(message); + + } + } })); }; @@ -483,11 +503,12 @@ const getRollupConfigForSsr = async (compilation, input) => { switch (code) { case 'CIRCULAR_DEPENDENCY': - // TODO let this through for lit by suppressing it + // let this through for lit // Error: the string "Circular dependency: ../../../../../node_modules/@lit-labs/ssr/lib/render-lit-html.js -> // ../../../../../node_modules/@lit-labs/ssr/lib/lit-element-renderer.js -> ../../../../../node_modules/@lit-labs/ssr/lib/render-lit-html.js\n" was thrown, throw an Error :) - // https://github.com/lit/lit/issues/449 // https://github.com/ProjectEvergreen/greenwood/issues/1118 + // https://github.com/lit/lit/issues/449#issuecomment-416688319 + // https://github.com/rollup/rollup/issues/1089#issuecomment-402109607 break; default: // otherwise, log all warnings from rollup diff --git a/packages/cli/src/templates/404.html b/packages/cli/src/layouts/404.html similarity index 100% rename from packages/cli/src/templates/404.html rename to packages/cli/src/layouts/404.html diff --git a/packages/cli/src/templates/app.html b/packages/cli/src/layouts/app.html similarity index 100% rename from packages/cli/src/templates/app.html rename to packages/cli/src/layouts/app.html diff --git a/packages/cli/src/templates/page.html b/packages/cli/src/layouts/page.html similarity index 100% rename from packages/cli/src/templates/page.html rename to packages/cli/src/layouts/page.html diff --git a/packages/cli/src/lib/execute-route-module.js b/packages/cli/src/lib/execute-route-module.js index 1d3746ace..dec53e5e2 100644 --- a/packages/cli/src/lib/execute-route-module.js +++ b/packages/cli/src/lib/execute-route-module.js @@ -2,7 +2,7 @@ import { renderToString, renderFromHTML } from 'wc-compiler'; async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender = false, htmlContents = null, scripts = [], request }) { const data = { - template: null, + layout: null, body: null, frontmatter: null, html: null @@ -15,7 +15,7 @@ async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender data.html = html; } else { const module = await import(moduleUrl).then(module => module); - const { prerender = false, getTemplate = null, getBody = null, getFrontmatter = null, isolation } = module; + const { prerender = false, getLayout = null, getBody = null, getFrontmatter = null, isolation } = module; if (module.default) { const { html } = await renderToString(new URL(moduleUrl), false, request); @@ -27,8 +27,8 @@ async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender } } - if (getTemplate) { - data.template = await getTemplate(compilation, page); + if (getLayout) { + data.layout = await getLayout(compilation, page); } if (getFrontmatter) { diff --git a/packages/cli/src/lib/layout-utils.js b/packages/cli/src/lib/layout-utils.js new file mode 100644 index 000000000..e39739676 --- /dev/null +++ b/packages/cli/src/lib/layout-utils.js @@ -0,0 +1,281 @@ +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); + }); + + const customLayoutLocations = []; + const layoutDir = contextPlugins + .map(plugin => plugin.layouts) + .flat(); + + for (const layoutDirUrl of layoutDir) { + if (layoutName) { + const layoutUrl = new URL(`./${layoutName}.html`, layoutDirUrl); + + if (await checkResourceExists(layoutUrl)) { + customLayoutLocations.push(layoutUrl); + } + } + } + + return customLayoutLocations; +} + +async function getPageLayout(filePath, compilation, layout) { + const { config, context } = compilation; + const { layoutsDir, userLayoutsDir, pagesDir, projectDirectory } = context; + const filePathUrl = new URL(`${filePath}`, projectDirectory); + const customPageFormatPlugins = config.plugins + .filter(plugin => plugin.type === 'resource' && !plugin.isGreenwoodDefaultPlugin) + .map(plugin => plugin.provider(compilation)); + const isCustomStaticPage = customPageFormatPlugins[0] + && customPageFormatPlugins[0].servePage === 'static' + && customPageFormatPlugins[0].shouldServe + && await customPageFormatPlugins[0].shouldServe(filePathUrl); + 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 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 || hasCustomStaticLayout)) { + // use a custom layout, usually from markdown frontmatter + contents = customPluginPageLayouts.length > 0 + ? await fs.readFile(new URL(`./${layout}.html`, customPluginPageLayouts[0]), 'utf-8') + : await fs.readFile(new URL(`./${layout}.html`, userLayoutsDir), 'utf-8'); + } else if (isHtmlPage) { + // if the page is already HTML, use that as the layout, NOT accounting for 404 pages + contents = await fs.readFile(filePathUrl, 'utf-8'); + } else if (isCustomStaticPage) { + // transform, then use that as the layout, NOT accounting for 404 pages + const transformed = await customPageFormatPlugins[0].serve(filePathUrl); + contents = await transformed.text(); + } else if (customPluginDefaultPageLayouts.length > 0 || (!is404Page && hasPageLayout)) { + // else look for default page layout from the user + // and 404 pages should be their own "top level" layout + 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 { + // fallback to using Greenwood's stock page layout + contents = await fs.readFile(new URL('./page.html', layoutsDir), 'utf-8'); + } + + return contents; +} + +/* eslint-disable-next-line complexity */ +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])) + : 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, { + script: true, + style: true, + noscript: true, + pre: true + }); + const appRoot = htmlparser.parse(appLayoutContents, { + script: true, + style: true + }); + + if ((pageLayoutContents && !pageRoot.valid) || !appRoot.valid) { + console.debug('ERROR: Invalid HTML detected'); + const invalidContents = !pageRoot.valid + ? pageLayoutContents + : appLayoutContents; + + if (enableHud) { + appLayoutContents = appLayoutContents.replace('', ` + +
+

Malformed HTML detected, please check your closing tags or an HTML formatter.

+
+
+                ${invalidContents.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')}
+              
+
+
+ `); + } + + mergedLayoutContents = appLayoutContents.replace(/<\/page-outlet>/, ''); + } else { + const appTitle = appRoot ? appRoot.querySelector('head title') : null; + const appBody = appRoot.querySelector('body') ? appRoot.querySelector('body').innerHTML : ''; + const pageBody = pageRoot && pageRoot.querySelector('body') ? pageRoot.querySelector('body').innerHTML : ''; + const pageTitle = pageRoot && pageRoot.querySelector('head title'); + const hasInterpolatedFrontmatter = pageTitle && pageTitle.rawText.indexOf('${globalThis.page.title}') >= 0 + || appTitle && appTitle.rawText.indexOf('${globalThis.page.title}') >= 0; + + const title = hasInterpolatedFrontmatter // favor frontmatter interpolation first + ? pageTitle && pageTitle.rawText + ? pageTitle.rawText + : appTitle.rawText + : frontmatterTitle // otherwise, work in order of specificity from page -> page layout -> app layout + ? frontmatterTitle + : pageTitle && pageTitle.rawText + ? pageTitle.rawText + : appTitle && appTitle.rawText + ? appTitle.rawText + : 'My App'; + + const mergedHtml = pageRoot && pageRoot.querySelector('html').rawAttrs !== '' + ? `` + : appRoot.querySelector('html').rawAttrs !== '' + ? `` + : ''; + + const mergedMeta = [ + ...appRoot.querySelectorAll('head meta'), + ...[...(pageRoot && pageRoot.querySelectorAll('head meta')) || []] + ].join('\n'); + + const mergedLinks = [ + ...appRoot.querySelectorAll('head link'), + ...[...(pageRoot && pageRoot.querySelectorAll('head link')) || []] + ].join('\n'); + + const mergedStyles = [ + ...appRoot.querySelectorAll('head style'), + ...[...(pageRoot && pageRoot.querySelectorAll('head style')) || []], + ...customImports.filter(resource => resource.split('.').pop() === 'css') + .map(resource => ``) + ].join('\n'); + + const mergedScripts = [ + ...appRoot.querySelectorAll('head script'), + ...[...(pageRoot && pageRoot.querySelectorAll('head script')) || []], + ...customImports.filter(resource => resource.split('.').pop() === 'js') + .map(resource => ``) + ].join('\n'); + + const finalBody = pageLayoutContents + ? appBody.replace(/<\/page-outlet>/, pageBody) + : appBody; + + mergedLayoutContents = ` + ${mergedHtml} + + ${title} + ${mergedMeta} + ${mergedLinks} + ${mergedStyles} + ${mergedScripts} + + + ${finalBody} + + + `; + } + + return mergedLayoutContents; +} + +async function getUserScripts (contents, compilation) { + const { config } = compilation; + + contents = contents.replace('', ` + + + `); + + return contents; +} + +export { + getAppLayout, + getPageLayout, + getUserScripts +}; \ No newline at end of file diff --git a/packages/cli/src/lib/resource-utils.js b/packages/cli/src/lib/resource-utils.js index a08cd9c57..6a78e4647 100644 --- a/packages/cli/src/lib/resource-utils.js +++ b/packages/cli/src/lib/resource-utils.js @@ -86,7 +86,7 @@ async function checkResourceExists(url) { // turn relative paths into relatively absolute based on a known root directory // * deep link route - /blog/releases/some-post -// * and a nested path in the template - ../../styles/theme.css +// * and a nested path in the layout - ../../styles/theme.css // so will get resolved as `${rootUrl}/styles/theme.css` async function resolveForRelativeUrl(url, rootUrl) { const search = url.search || ''; @@ -111,11 +111,6 @@ async function resolveForRelativeUrl(url, rootUrl) { return reducedUrl; } -// does this make more sense in bundle lifecycle? -// https://github.com/ProjectEvergreen/greenwood/issues/970 -// or could this be done sooner (like in appTemplate building in html resource plugin)? -// Or do we need to ensure userland code / plugins have gone first -// before we can curate the final list of `) - ].join('\n'); - - const finalBody = pageTemplateContents - ? appBody.replace(/<\/page-outlet>/, pageBody) - : appBody; - - mergedTemplateContents = ` - ${mergedHtml} - - ${title} - ${mergedMeta} - ${mergedLinks} - ${mergedStyles} - ${mergedScripts} - - - ${finalBody} - - - `; - } - - return mergedTemplateContents; -} - -async function getUserScripts (contents, compilation) { - const { config } = compilation; - - contents = contents.replace('', ` - - - `); - - return contents; -} - -export { - getAppTemplate, - getPageTemplate, - getUserScripts -}; \ No newline at end of file diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 352fe485a..33319cfa2 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -1,9 +1,9 @@ /* eslint-disable max-depth, max-len */ import fs from 'fs/promises'; import { getRollupConfigForApis, getRollupConfigForScriptResources, getRollupConfigForSsr } from '../config/rollup.config.js'; -import { getAppTemplate, getPageTemplate, getUserScripts } from '../lib/templating-utils.js'; +import { getAppLayout, getPageLayout, getUserScripts } from '../lib/layout-utils.js'; import { hashString } from '../lib/hashing-utils.js'; -import { checkResourceExists, mergeResponse, normalizePathnameForWindows } from '../lib/resource-utils.js'; +import { checkResourceExists, mergeResponse, normalizePathnameForWindows, trackResourcesForRoute } from '../lib/resource-utils.js'; import path from 'path'; import { rollup } from 'rollup'; @@ -217,84 +217,96 @@ async function bundleApiRoutes(compilation) { } } -async function bundleSsrPages(compilation) { - // 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 hasSSRPages = compilation.graph.filter(page => page.isSSR).length > 0; +async function bundleSsrPages(compilation, optimizePlugins) { + const { context, config } = compilation; + const ssrPages = compilation.graph.filter(page => page.isSSR && !page.prerender); + const ssrPrerenderPagesRouteMapper = {}; const input = []; - if (!compilation.config.prerender && hasSSRPages) { - const htmlOptimizer = compilation.config.plugins.find(plugin => plugin.name === 'plugin-standard-html').provider(compilation); - const { executeModuleUrl } = compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider(); + if (!config.prerender && ssrPages.length > 0) { + const { executeModuleUrl } = config.plugins.find(plugin => plugin.type === 'renderer').provider(); const { executeRouteModule } = await import(executeModuleUrl); - const { pagesDir, scratchDir } = compilation.context; - - for (const page of compilation.graph) { - if (page.isSSR && !page.prerender) { - const { filename, imports, route, template, title, relativeWorkspacePagePath } = page; - const entryFileUrl = new URL(`.${relativeWorkspacePagePath}`, scratchDir); - const moduleUrl = new URL(`.${relativeWorkspacePagePath}`, pagesDir); - const outputPathRootUrl = new URL(`file://${path.dirname(entryFileUrl.pathname)}`); - const request = new Request(moduleUrl); // TODO not really sure how to best no-op this? - // TODO getTemplate has to be static (for now?) - // https://github.com/ProjectEvergreen/greenwood/issues/955 - const data = await executeRouteModule({ moduleUrl, compilation, page, prerender: false, htmlContents: null, scripts: [], request }); - const pagesPathDiff = compilation.context.pagesDir.pathname.replace(compilation.context.projectDirectory.pathname, ''); - const relativeDepth = relativeWorkspacePagePath.replace(`/${filename}`, '') === '' - ? '../' - : '../'.repeat(relativeWorkspacePagePath.replace(`/${filename}`, '').split('/').length); - let staticHtml = ''; - - staticHtml = data.template ? data.template : await getPageTemplate(staticHtml, compilation.context, template, []); - staticHtml = await getAppTemplate(staticHtml, compilation.context, imports, [], false, 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(); - staticHtml = await (await htmlOptimizer.optimize(new URL(`http://localhost:8080${route}`), new Response(staticHtml))).text(); - staticHtml = staticHtml.replace(/[`\\$]/g, '\\$&'); // https://stackoverflow.com/a/75688937/417806 - - if (!await checkResourceExists(outputPathRootUrl)) { - await fs.mkdir(outputPathRootUrl, { - recursive: true - }); - } + const { pagesDir, scratchDir } = context; + + // one pass to generate initial static HTML and to track all combined static resources across layouts + // and before we optimize so that all bundled assets can tracked up front + // would be nice to see if this can be done in a single pass though... + for (const page of ssrPages) { + const { imports, route, layout, title, relativeWorkspacePagePath } = page; + const moduleUrl = new URL(`.${relativeWorkspacePagePath}`, pagesDir); + const request = new Request(moduleUrl); + // TODO getLayout has to be static (for now?) + // https://github.com/ProjectEvergreen/greenwood/issues/955 + const data = await executeRouteModule({ moduleUrl, compilation, page, prerender: false, htmlContents: null, scripts: [], request }); + let staticHtml = ''; + + 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(); + + await trackResourcesForRoute(staticHtml, compilation, route); + + ssrPrerenderPagesRouteMapper[route] = staticHtml; + } - // better way to write out this inline code? - // using a URL here produces a bundled chunk, but at leasts its bundled - await fs.writeFile(entryFileUrl, ` - import { executeRouteModule } from '${normalizePathnameForWindows(executeModuleUrl)}'; + // technically this happens in the start of bundleCompilation once + // so might be nice to detect those static assets to see if they have be "de-duped" from bundling here + await bundleScriptResources(compilation); + await bundleStyleResources(compilation, optimizePlugins); + + // second pass to link all bundled assets to their resources before optimizing and generating SSR bundles + for (const page of ssrPages) { + const { filename, route, relativeWorkspacePagePath } = page; + const entryFileUrl = new URL(`.${relativeWorkspacePagePath}`, scratchDir); + const outputPathRootUrl = new URL(`file://${path.dirname(entryFileUrl.pathname)}`); + const htmlOptimizer = config.plugins.find(plugin => plugin.name === 'plugin-standard-html').provider(compilation); + const pagesPathDiff = context.pagesDir.pathname.replace(context.projectDirectory.pathname, ''); + const relativeDepth = relativeWorkspacePagePath.replace(`/${filename}`, '') === '' + ? '../' + : '../'.repeat(relativeWorkspacePagePath.replace(`/${filename}`, '').split('/').length); + + let staticHtml = ssrPrerenderPagesRouteMapper[route]; + staticHtml = await (await htmlOptimizer.optimize(new URL(`http://localhost:8080${route}`), new Response(staticHtml))).text(); + staticHtml = staticHtml.replace(/[`\\$]/g, '\\$&'); // https://stackoverflow.com/a/75688937/417806 - const moduleUrl = new URL('${relativeDepth}${pagesPathDiff}${relativeWorkspacePagePath.replace('/', '')}', import.meta.url); + if (!await checkResourceExists(outputPathRootUrl)) { + await fs.mkdir(outputPathRootUrl, { + recursive: true + }); + } - export async function handler(request) { - const compilation = JSON.parse('${JSON.stringify(compilation)}'); - const page = JSON.parse('${JSON.stringify(page)}'); - const data = await executeRouteModule({ moduleUrl, compilation, page, request }); - let staticHtml = \`${staticHtml}\`; + // better way to write out this inline code? + await fs.writeFile(entryFileUrl, ` + import { executeRouteModule } from '${normalizePathnameForWindows(executeModuleUrl)}'; - if (data.body) { - staticHtml = staticHtml.replace(\/\(.*)<\\/content-outlet>\/s, data.body); - } + const moduleUrl = new URL('${relativeDepth}${pagesPathDiff}${relativeWorkspacePagePath.replace('/', '')}', import.meta.url); + + export async function handler(request) { + const compilation = JSON.parse('${JSON.stringify(compilation)}'); + const page = JSON.parse('${JSON.stringify(page)}'); + const data = await executeRouteModule({ moduleUrl, compilation, page, request }); + let staticHtml = \`${staticHtml}\`; - return new Response(staticHtml, { - headers: { - 'Content-Type': 'text/html' - } - }); + if (data.body) { + staticHtml = staticHtml.replace(\/\(.*)<\\/content-outlet>\/s, data.body); } - `); - input.push(normalizePathnameForWindows(entryFileUrl)); - } + return new Response(staticHtml, { + headers: { + 'Content-Type': 'text/html' + } + }); + } + `); + + input.push(normalizePathnameForWindows(entryFileUrl)); } const ssrConfigs = await getRollupConfigForSsr(compilation, input); if (ssrConfigs.length > 0 && ssrConfigs[0].input !== '') { + console.info('bundling dynamic pages...'); for (const configIndex in ssrConfigs) { const rollupConfig = ssrConfigs[configIndex]; const bundle = await rollup(rollupConfig); @@ -337,7 +349,7 @@ const bundleCompilation = async (compilation) => { ]); // bundleSsrPages depends on bundleScriptResources having run first - await bundleSsrPages(compilation); + await bundleSsrPages(compilation, optimizeResourcePlugins); console.info('optimizing static pages....'); await optimizeStaticPages(compilation, optimizeResourcePlugins); diff --git a/packages/cli/src/lifecycles/compile.js b/packages/cli/src/lifecycles/compile.js index 8b09af2a7..fb48336fe 100644 --- a/packages/cli/src/lifecycles/compile.js +++ b/packages/cli/src/lifecycles/compile.js @@ -22,7 +22,7 @@ const generateCompilation = () => { console.info('Initializing project config'); compilation.config = await initConfig(); - // determine whether to use default template or user detected workspace + // determine whether to use default layout or user detected workspace console.info('Initializing project workspace contexts'); compilation.context = await initContext(compilation); diff --git a/packages/cli/src/lifecycles/config.js b/packages/cli/src/lifecycles/config.js index e15fcc998..7ee7fc2f7 100644 --- a/packages/cli/src/lifecycles/config.js +++ b/packages/cli/src/lifecycles/config.js @@ -52,7 +52,7 @@ const defaultConfig = { prerender: false, isolation: false, pagesDirectory: 'pages', - templatesDirectory: 'templates' + layoutsDirectory: 'layouts' }; const readAndMergeConfig = async() => { @@ -77,7 +77,7 @@ const readAndMergeConfig = async() => { if (hasConfigFile) { const userCfgFile = (await import(configUrl)).default; - const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, templatesDirectory, interpolateFrontmatter, isolation } = userCfgFile; + const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, layoutsDirectory, interpolateFrontmatter, isolation } = userCfgFile; // workspace validation if (workspace) { @@ -205,10 +205,10 @@ const readAndMergeConfig = async() => { reject(`Error: provided pagesDirectory "${pagesDirectory}" is not supported. Please make sure to pass something like 'docs/'`); } - if (templatesDirectory && typeof templatesDirectory === 'string') { - customConfig.templatesDirectory = templatesDirectory; - } else if (templatesDirectory) { - reject(`Error: provided templatesDirectory "${templatesDirectory}" is not supported. Please make sure to pass something like 'layouts/'`); + if (layoutsDirectory && typeof layoutsDirectory === 'string') { + customConfig.layoutsDirectory = layoutsDirectory; + } else if (layoutsDirectory) { + reject(`Error: provided layoutsDirectory "${layoutsDirectory}" is not supported. Please make sure to pass something like 'layouts/'`); } if (prerender !== undefined) { diff --git a/packages/cli/src/lifecycles/context.js b/packages/cli/src/lifecycles/context.js index d49ae226b..e4f45b564 100644 --- a/packages/cli/src/lifecycles/context.js +++ b/packages/cli/src/lifecycles/context.js @@ -5,17 +5,17 @@ const initContext = async({ config }) => { return new Promise(async (resolve, reject) => { try { - const { workspace, pagesDirectory, templatesDirectory } = config; + const { workspace, pagesDirectory, layoutsDirectory } = config; const projectDirectory = new URL(`file://${process.cwd()}/`); const scratchDir = new URL('./.greenwood/', projectDirectory); const outputDir = new URL('./public/', projectDirectory); const dataDir = new URL('../data/', import.meta.url); - const templatesDir = new URL('../templates/', import.meta.url); + const layoutsDir = new URL('../layouts/', import.meta.url); const userWorkspace = workspace; - const apisDir = new URL('./api/', userWorkspace); const pagesDir = new URL(`./${pagesDirectory}/`, userWorkspace); - const userTemplatesDir = new URL(`./${templatesDirectory}/`, userWorkspace); + const apisDir = new URL('./api/', pagesDir); + const userLayoutsDir = new URL(`./${layoutsDirectory}/`, userWorkspace); const context = { dataDir, @@ -23,10 +23,10 @@ const initContext = async({ config }) => { userWorkspace, apisDir, pagesDir, - userTemplatesDir, + userLayoutsDir, scratchDir, projectDirectory, - templatesDir + layoutsDir }; if (!await checkResourceExists(scratchDir)) { diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js index 57b53b7bc..ad7415d30 100644 --- a/packages/cli/src/lifecycles/graph.js +++ b/packages/cli/src/lifecycles/graph.js @@ -11,7 +11,12 @@ const generateGraph = async (compilation) => { try { const { context, config } = compilation; const { basePath } = config; - const { apisDir, pagesDir, projectDirectory, userWorkspace } = context; + const { pagesDir, projectDirectory, userWorkspace } = context; + const customPageFormatPlugins = config.plugins + .filter(plugin => plugin.type === 'resource' && !plugin.isGreenwoodDefaultPlugin) + .map(plugin => plugin.provider(compilation)); + + let apis = new Map(); let graph = [{ outputPath: '/index.html', filename: 'index.html', @@ -26,7 +31,7 @@ const generateGraph = async (compilation) => { isolation: false }]; - const walkDirectoryForPages = async function(directory, pages = []) { + const walkDirectoryForPages = async function(directory, pages = [], apiRoutes = new Map()) { const files = await fs.readdir(directory); for (const filename of files) { @@ -35,254 +40,254 @@ const generateGraph = async (compilation) => { const isDirectory = await checkResourceExists(filenameUrlAsDir) && (await fs.stat(filenameUrlAsDir)).isDirectory(); if (isDirectory) { - pages = await walkDirectoryForPages(filenameUrlAsDir, pages); + const nextPages = await walkDirectoryForPages(filenameUrlAsDir, pages, apiRoutes); + + pages = nextPages.pages; + apiRoutes = nextPages.apiRoutes; } else { + const req = new Request(filenameUrl, { headers: { 'Accept': 'text/html' } }); const extension = `.${filenameUrl.pathname.split('.').pop()}`; - const isStatic = extension === '.md' || extension === '.html'; - const isDynamic = extension === '.js'; + const isCustom = customPageFormatPlugins[0] && customPageFormatPlugins[0].shouldServe && await customPageFormatPlugins[0].shouldServe(filenameUrl, req) + ? customPageFormatPlugins[0].servePage + : null; const relativePagePath = filenameUrl.pathname.replace(pagesDir.pathname, '/'); const relativeWorkspacePath = directory.pathname.replace(projectDirectory.pathname, ''); - let route = relativePagePath.replace(extension, ''); - let id = filename.split('/')[filename.split('/').length - 1].replace(extension, ''); - let template = 'page'; - let title = null; - let imports = []; - let customData = {}; - let filePath; - let prerender = true; - let isolation = false; - let hydration = false; - - /* - * check if additional nested directories exist to correctly determine route (minus filename) - * examples: - * - pages/index.{html,md,js} -> / - * - pages/about.{html,md,js} -> /about/ - * - pages/blog/index.{html,md,js} -> /blog/ - * - pages/blog/some-post.{html,md,js} -> /blog/some-post/ - */ - if (relativePagePath.lastIndexOf('/') > 0) { - // https://github.com/ProjectEvergreen/greenwood/issues/455 - route = id === 'index' || route.replace('/index', '') === `/${id}` - ? route.replace('index', '') - : `${route}/`; - } else { - route = route === '/index' - ? '/' - : `${route}/`; - } + const isStatic = isCustom === 'static' || extension === '.md' || extension === '.html'; + const isDynamic = isCustom === 'dynamic' || extension === '.js'; + const isApiRoute = relativePagePath.startsWith('/api'); + const isPage = isStatic || isDynamic; + + if (isApiRoute) { + const req = new Request(filenameUrl); + const extension = filenameUrl.pathname.split('.').pop(); + const isCustom = customPageFormatPlugins[0] && customPageFormatPlugins[0].shouldServe && await customPageFormatPlugins[0].shouldServe(filenameUrl, req); + + if (extension !== 'js' && !isCustom) { + console.warn(`${filenameUrl} is not a supported API file extension, skipping...`); + return; + } - if (isStatic) { - const fileContents = await fs.readFile(filenameUrl, 'utf8'); - const { attributes } = fm(fileContents); - - template = attributes.template || 'page'; - title = attributes.title || title; - id = attributes.label || id; - imports = attributes.imports || []; - filePath = `${relativeWorkspacePath}${filename}`; - - // prune "reserved" attributes that are supported by Greenwood - // https://www.greenwoodjs.io/docs/front-matter - customData = attributes; - - delete customData.label; - delete customData.imports; - delete customData.title; - delete customData.template; - - /* Menu Query - * Custom front matter - Variable Definitions - * -------------------------------------------------- - * menu: the name of the menu in which this item can be listed and queried - * index: the index of this list item within a menu - * linkheadings: flag to tell us where to add page's table of contents as menu items - * tableOfContents: json object containing page's table of contents(list of headings) + const relativeApiPath = filenameUrl.pathname.replace(pagesDir.pathname, '/'); + const route = `${basePath}${relativeApiPath.replace(`.${extension}`, '')}`; + // TODO should this be run in isolation like SSR pages? + // https://github.com/ProjectEvergreen/greenwood/issues/991 + const { isolation } = await import(filenameUrl).then(module => module); + + /* + * API Properties (per route) + *---------------------- + * filename: base filename of the page + * outputPath: the filename to write to when generating a build + * path: path to the file relative to the workspace + * route: URL route for a given page on outputFilePath + * isolation: if this should be run in isolated mode + */ + apiRoutes.set(route, { + filename: filename, + outputPath: `/api/${filename.replace(`.${extension}`, '.js')}`, + path: relativeApiPath, + route, + isolation + }); + } else if (isPage) { + let route = relativePagePath.replace(extension, ''); + let id = filename.split('/')[filename.split('/').length - 1].replace(extension, ''); + let layout = extension === '.html' ? null : 'page'; + let title = null; + let imports = []; + let customData = {}; + let filePath; + let prerender = true; + let isolation = false; + let hydration = false; + + /* + * check if additional nested directories exist to correctly determine route (minus filename) + * examples: + * - pages/index.{html,md,js} -> / + * - pages/about.{html,md,js} -> /about/ + * - pages/blog/index.{html,md,js} -> /blog/ + * - pages/blog/some-post.{html,md,js} -> /blog/some-post/ */ - // set specific menu to place this page - customData.menu = customData.menu || ''; + if (relativePagePath.lastIndexOf('/') > 0) { + // https://github.com/ProjectEvergreen/greenwood/issues/455 + route = id === 'index' || route.replace('/index', '') === `/${id}` + ? route.replace('index', '') + : `${route}/`; + } else { + route = route === '/index' + ? '/' + : `${route}/`; + } - // set specific index list priority of this item within a menu - customData.index = customData.index || ''; + if (isStatic) { + const fileContents = await fs.readFile(filenameUrl, 'utf8'); + const { attributes } = fm(fileContents); - // set flag whether to gather a list of headings on a page as menu items - customData.linkheadings = customData.linkheadings || 0; - customData.tableOfContents = []; + layout = attributes.layout || layout; + title = attributes.title || title; + id = attributes.label || id; + imports = attributes.imports || []; + filePath = `${relativeWorkspacePath}${filename}`; - if (customData.linkheadings > 0) { - // parse markdown for table of contents and output to json - customData.tableOfContents = toc(fileContents).json; - customData.tableOfContents.shift(); + // prune "reserved" attributes that are supported by Greenwood + // https://www.greenwoodjs.io/docs/front-matter + customData = attributes; - // parse table of contents for only the pages user wants linked - if (customData.tableOfContents.length > 0 && customData.linkheadings > 0) { - customData.tableOfContents = customData.tableOfContents - .filter((item) => item.lvl === customData.linkheadings); - } - } - /* ---------End Menu Query-------------------- */ - } else if (isDynamic) { - const routeWorkerUrl = compilation.config.plugins.filter(plugin => plugin.type === 'renderer')[0].provider(compilation).executeModuleUrl; - let ssrFrontmatter; - - filePath = route; - - await new Promise(async (resolve, reject) => { - const worker = new Worker(new URL('../lib/ssr-route-worker.js', import.meta.url)); - // TODO "faux" new Request here, a better way? - const request = await requestAsObject(new Request(filenameUrl)); - - worker.on('message', async (result) => { - prerender = result.prerender ?? false; - isolation = result.isolation ?? isolation; - hydration = result.hydration ?? hydration; - - if (result.frontmatter) { - result.frontmatter.imports = result.frontmatter.imports || []; - ssrFrontmatter = result.frontmatter; - } + delete customData.label; + delete customData.imports; + delete customData.title; + delete customData.layout; - resolve(); - }); - worker.on('error', reject); - worker.on('exit', (code) => { - if (code !== 0) { - reject(new Error(`Worker stopped with exit code ${code}`)); + /* Menu Query + * Custom front matter - Variable Definitions + * -------------------------------------------------- + * menu: the name of the menu in which this item can be listed and queried + * index: the index of this list item within a menu + * linkheadings: flag to tell us where to add page's table of contents as menu items + * tableOfContents: json object containing page's table of contents(list of headings) + */ + // set specific menu to place this page + customData.menu = customData.menu || ''; + + // set specific index list priority of this item within a menu + customData.index = customData.index || ''; + + // set flag whether to gather a list of headings on a page as menu items + customData.linkheadings = customData.linkheadings || 0; + customData.tableOfContents = []; + + if (customData.linkheadings > 0) { + // parse markdown for table of contents and output to json + customData.tableOfContents = toc(fileContents).json; + customData.tableOfContents.shift(); + + // parse table of contents for only the pages user wants linked + if (customData.tableOfContents.length > 0 && customData.linkheadings > 0) { + customData.tableOfContents = customData.tableOfContents + .filter((item) => item.lvl === customData.linkheadings); } + } + /* ---------End Menu Query-------------------- */ + } else if (isDynamic) { + const routeWorkerUrl = compilation.config.plugins.filter(plugin => plugin.type === 'renderer')[0].provider(compilation).executeModuleUrl; + let ssrFrontmatter; + + filePath = route; + + await new Promise(async (resolve, reject) => { + const worker = new Worker(new URL('../lib/ssr-route-worker.js', import.meta.url)); + const request = await requestAsObject(new Request(filenameUrl)); + + worker.on('message', async (result) => { + prerender = result.prerender ?? false; + isolation = result.isolation ?? isolation; + hydration = result.hydration ?? hydration; + + if (result.frontmatter) { + result.frontmatter.imports = result.frontmatter.imports || []; + ssrFrontmatter = result.frontmatter; + } + + 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: filenameUrl.href, + compilation: JSON.stringify(compilation), + // TODO need to get as many of these params as possible + // or ignore completely? + page: JSON.stringify({ + servePage: isCustom, + route, + id, + label: id.split('-') + .map((idPart) => { + return `${idPart.charAt(0).toUpperCase()}${idPart.substring(1)}`; + }).join(' ') + }), + request + }); }); - worker.postMessage({ - executeModuleUrl: routeWorkerUrl.href, - moduleUrl: filenameUrl.href, - compilation: JSON.stringify(compilation), - // TODO need to get as many of these params as possible - // or ignore completely? - page: JSON.stringify({ - route, - id, - label: id.split('-') - .map((idPart) => { - return `${idPart.charAt(0).toUpperCase()}${idPart.substring(1)}`; - }).join(' ') - }), - request - }); - }); - - if (ssrFrontmatter) { - template = ssrFrontmatter.template || template; - title = ssrFrontmatter.title || title; - imports = ssrFrontmatter.imports || imports; - customData = ssrFrontmatter.data || customData; - - /* Menu Query - * Custom front matter - Variable Definitions - * -------------------------------------------------- - * menu: the name of the menu in which this item can be listed and queried - * index: the index of this list item within a menu - * linkheadings: flag to tell us where to add page's table of contents as menu items - * tableOfContents: json object containing page's table of contents(list of headings) - */ - customData.menu = ssrFrontmatter.menu || ''; - customData.index = ssrFrontmatter.index || ''; + if (ssrFrontmatter) { + layout = ssrFrontmatter.layout || layout; + title = ssrFrontmatter.title || title; + imports = ssrFrontmatter.imports || imports; + customData = ssrFrontmatter.data || customData; + + /* Menu Query + * Custom front matter - Variable Definitions + * -------------------------------------------------- + * menu: the name of the menu in which this item can be listed and queried + * index: the index of this list item within a menu + * linkheadings: flag to tell us where to add page's table of contents as menu items + * tableOfContents: json object containing page's table of contents(list of headings) + */ + customData.menu = ssrFrontmatter.menu || ''; + customData.index = ssrFrontmatter.index || ''; + } } - } else { - console.debug(`Unhandled extension (.${extension}) for route => ${route}`); - } - /* - * Graph Properties (per page) - *---------------------- - * data: custom page frontmatter - * filename: base filename of the page - * id: filename without the extension - * relativeWorkspacePagePath: the file path relative to the user's workspace directory - * label: "pretty" text representation of the filename - * imports: per page JS or CSS file imports to be included in HTML output from frontmatter - * resources: sum of all resources for the entire page - * outputPath: the filename to write to when generating static HTML - * path: path to the file relative to the workspace - * route: URL route for a given page on outputFilePath - * template: page template to use as a base for a generated component - * title: a default value that can be used for - * isSSR: if this is a server side route - * prerender: if this should be statically exported - * isolation: if this should be run in isolated mode - * hydration: if this page needs hydration support - */ - pages.push({ - data: customData || {}, - filename, - id, - relativeWorkspacePagePath: relativePagePath, - label: id.split('-') - .map((idPart) => { - return `${idPart.charAt(0).toUpperCase()}${idPart.substring(1)}`; - }).join(' '), - imports, - resources: [], - outputPath: route === '/404/' - ? '/404.html' - : `${route}index.html`, - path: filePath, - route: `${basePath}${route}`, - template, - title, - isSSR: !isStatic, - prerender, - isolation, - hydration - }); - } - } - - return pages; - }; - - const walkDirectoryForApis = async function(directory, apis = new Map()) { - const files = await fs.readdir(directory); - - for (const filename of files) { - const filenameUrl = new URL(`./${filename}`, directory); - const filenameUrlAsDir = new URL(`./${filename}/`, directory); - const isDirectory = await checkResourceExists(filenameUrlAsDir) && (await fs.stat(filenameUrlAsDir)).isDirectory(); - - if (isDirectory) { - apis = await walkDirectoryForApis(filenameUrlAsDir, apis); - } else { - const extension = filenameUrl.pathname.split('.').pop(); - - if (extension !== 'js') { - console.warn(`${filenameUrl} is not a JavaScript file, skipping...`); - return; + /* + * Graph Properties (per page) + *---------------------- + * data: custom page frontmatter + * filename: base filename of the page + * id: filename without the extension + * relativeWorkspacePagePath: the file path relative to the user's workspace directory + * label: "pretty" text representation of the filename + * imports: per page JS or CSS file imports to be included in HTML output from frontmatter + * resources: sum of all resources for the entire page + * outputPath: the filename to write to when generating static HTML + * path: path to the file relative to the workspace + * route: URL route for a given page on outputFilePath + * layout: page layout to use as a base for a generated component + * title: a default value that can be used for + * isSSR: if this is a server side route + * prerender: if this should be statically exported + * isolation: if this should be run in isolated mode + * hydration: if this page needs hydration support + * servePage: signal that this is a custom page file type (static | dynamic) + */ + pages.push({ + data: customData || {}, + filename, + id, + relativeWorkspacePagePath: relativePagePath, + label: id.split('-') + .map((idPart) => { + return `${idPart.charAt(0).toUpperCase()}${idPart.substring(1)}`; + }).join(' '), + imports, + resources: [], + outputPath: route === '/404/' + ? '/404.html' + : `${route}index.html`, + path: filePath, + route: `${basePath}${route}`, + layout, + title, + isSSR: !isStatic, + prerender, + isolation, + hydration, + servePage: isCustom + }); + } else { + console.debug(`Unhandled extension (${extension}) for route => ${route}`); } - - const relativeApiPath = filenameUrl.pathname.replace(userWorkspace.pathname, '/'); - const route = `${basePath}${relativeApiPath.replace(`.${extension}`, '')}`; - // TODO should this be run in isolation like SSR pages? - // https://github.com/ProjectEvergreen/greenwood/issues/991 - const { isolation } = await import(filenameUrl).then(module => module); - - /* - * API Properties (per route) - *---------------------- - * filename: base filename of the page - * outputPath: the filename to write to when generating a build - * path: path to the file relative to the workspace - * route: URL route for a given page on outputFilePath - * isolation: if this should be run in isolated mode - */ - apis.set(route, { - filename: filename, - outputPath: `/api/${filename}`, - path: relativeApiPath, - route, - isolation - }); } } - return apis; + return { pages, apiRoutes }; }; console.debug('building from local sources...'); @@ -296,8 +301,10 @@ const generateGraph = async (compilation) => { }]; } else { const oldGraph = graph[0]; + const pages = await checkResourceExists(pagesDir) ? await walkDirectoryForPages(pagesDir) : { pages: graph, apiRoutes: apis }; - graph = await checkResourceExists(pagesDir) ? await walkDirectoryForPages(pagesDir) : graph; + graph = pages.pages; + apis = pages.apiRoutes; const has404Page = graph.find(page => page.route.endsWith('/404/')); @@ -353,12 +360,7 @@ const generateGraph = async (compilation) => { } compilation.graph = graph; - - if (await checkResourceExists(apisDir)) { - const apis = await walkDirectoryForApis(apisDir); - - compilation.manifest = { apis }; - } + compilation.manifest = { apis }; resolve(compilation); } catch (err) { diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index 804cfd4a1..04007e00e 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -3,8 +3,6 @@ import { checkResourceExists, trackResourcesForRoute } from '../lib/resource-uti import os from 'os'; import { WorkerPool } from '../lib/threadpool.js'; -// TODO a lot of these are duplicated in the build lifecycle too -// would be good to refactor async function createOutputDirectory(route, outputDir) { if (!route.endsWith('/404/') && !await checkResourceExists(outputDir)) { await fs.mkdir(outputDir, { @@ -62,18 +60,32 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { const pool = new WorkerPool(os.cpus().length, new URL('../lib/ssr-route-worker.js', import.meta.url)); for (const page of pages) { - const { route, outputPath, resources } = page; + const { route, outputPath } = page; const outputPathUrl = new URL(`.${outputPath}`, scratchDir); const url = new URL(`http://localhost:${compilation.config.port}${route}`); const request = new Request(url); + let ssrContents; + // do we negate the worker pool by also running this, outside the pool? let body = await (await servePage(url, request, plugins)).text(); body = await (await interceptPage(url, request, plugins, body)).text(); - await createOutputDirectory(route, new URL(outputPathUrl.href.replace('index.html', ''))); + // hack to avoid over-rendering SSR content + // https://github.com/ProjectEvergreen/greenwood/issues/1044 + // https://github.com/ProjectEvergreen/greenwood/issues/988#issuecomment-1288168858 + if (page.isSSR) { + const ssrContentsMatch = /(.*.)/s; + + ssrContents = body.match(ssrContentsMatch)[0]; + body = body.replace(ssrContents, ''); + + ssrContents = ssrContents + .replace('', '') + .replace('', ''); + } + const resources = await trackResourcesForRoute(body, compilation, route); const scripts = resources - .map(resource => compilation.resources.get(resource)) .filter(resource => resource.type === 'script') .map(resource => resource.sourcePathURL.href); @@ -95,6 +107,11 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { }); }); + if (page.isSSR) { + body = body.replace('', ssrContents); + } + + await createOutputDirectory(route, new URL(outputPathUrl.href.replace('index.html', ''))); await fs.writeFile(outputPathUrl, body); console.info('generated page...', route); @@ -117,7 +134,7 @@ async function preRenderCompilationCustom(compilation, customPrerender) { body = body.replace(/ `) diff --git a/packages/cli/test/cases/build.config.error-templates-directory/build.config.error-templates-directory.spec.js b/packages/cli/test/cases/build.config.error-layouts-directory/build.config.error-layouts-directory.spec.js similarity index 63% rename from packages/cli/test/cases/build.config.error-templates-directory/build.config.error-templates-directory.spec.js rename to packages/cli/test/cases/build.config.error-layouts-directory/build.config.error-layouts-directory.spec.js index 8b481fd00..e48c2130a 100644 --- a/packages/cli/test/cases/build.config.error-templates-directory/build.config.error-templates-directory.spec.js +++ b/packages/cli/test/cases/build.config.error-layouts-directory/build.config.error-layouts-directory.spec.js @@ -1,6 +1,6 @@ /* * Use Case - * Run Greenwood build command with a bad value for templatesDirectory in a custom config. + * Run Greenwood build command with a bad value for layoutsDirectory in a custom config. * * User Result * Should throw an error. @@ -10,7 +10,7 @@ * * User Config * { - * templatesDirectory: {} + * layoutsDirectory: {} * } * * User Workspace @@ -35,13 +35,13 @@ describe('Build Greenwood With: ', function() { runner = new Runner(); }); - describe('Custom Configuration with a bad value for templatesDirectory', function() { - it('should throw an error that templatesDirectory must be a string', function() { + describe('Custom Configuration with a bad value for layoutsDirectory', function() { + it('should throw an error that layoutsDirectory must be a string', async function() { try { runner.setup(outputPath); runner.runCommand(cliPath, 'build'); } catch (err) { - expect(err).to.contain('Error: provided templatesDirectory "[object Object]" is not supported. Please make sure to pass something like \'layouts/\''); + expect(err).to.contain('Error: provided layoutsDirectory "[object Object]" is not supported. Please make sure to pass something like \'layouts/\''); } }); }); diff --git a/packages/cli/test/cases/build.config.error-layouts-directory/greenwood.config.js b/packages/cli/test/cases/build.config.error-layouts-directory/greenwood.config.js new file mode 100644 index 000000000..376ab3249 --- /dev/null +++ b/packages/cli/test/cases/build.config.error-layouts-directory/greenwood.config.js @@ -0,0 +1,3 @@ +export default { + layoutsDirectory: {} +}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.error-templates-directory/greenwood.config.js b/packages/cli/test/cases/build.config.error-templates-directory/greenwood.config.js deleted file mode 100644 index b8e6daaba..000000000 --- a/packages/cli/test/cases/build.config.error-templates-directory/greenwood.config.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - templatesDirectory: {} -}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.interpolate-frontmatter/build.config.interpolate-frontmatter.spec.js b/packages/cli/test/cases/build.config.interpolate-frontmatter/build.config.interpolate-frontmatter.spec.js index 62b30ae9c..b39193067 100644 --- a/packages/cli/test/cases/build.config.interpolate-frontmatter/build.config.interpolate-frontmatter.spec.js +++ b/packages/cli/test/cases/build.config.interpolate-frontmatter/build.config.interpolate-frontmatter.spec.js @@ -19,7 +19,7 @@ * pages/ * blog/ * first-post.md - * templates/ + * layouts/ * blog.html */ import { JSDOM } from 'jsdom'; diff --git a/packages/cli/test/cases/build.config.interpolate-frontmatter/src/templates/blog.html b/packages/cli/test/cases/build.config.interpolate-frontmatter/src/layouts/blog.html similarity index 100% rename from packages/cli/test/cases/build.config.interpolate-frontmatter/src/templates/blog.html rename to packages/cli/test/cases/build.config.interpolate-frontmatter/src/layouts/blog.html diff --git a/packages/cli/test/cases/build.config.interpolate-frontmatter/src/pages/blog/first-post.md b/packages/cli/test/cases/build.config.interpolate-frontmatter/src/pages/blog/first-post.md index deae1bb80..fa488585c 100644 --- a/packages/cli/test/cases/build.config.interpolate-frontmatter/src/pages/blog/first-post.md +++ b/packages/cli/test/cases/build.config.interpolate-frontmatter/src/pages/blog/first-post.md @@ -1,6 +1,6 @@ --- title: Ny First Post -template: blog +layout: blog published: 11/11/2022 author: Owen Buckley --- diff --git a/packages/cli/test/cases/build.config.templates-directory/build.config.templates-directory.spec.js b/packages/cli/test/cases/build.config.layouts-directory/build.config.layouts-directory.spec.js similarity index 89% rename from packages/cli/test/cases/build.config.templates-directory/build.config.templates-directory.spec.js rename to packages/cli/test/cases/build.config.layouts-directory/build.config.layouts-directory.spec.js index baac1c79b..f206cf923 100644 --- a/packages/cli/test/cases/build.config.templates-directory/build.config.templates-directory.spec.js +++ b/packages/cli/test/cases/build.config.layouts-directory/build.config.layouts-directory.spec.js @@ -1,6 +1,6 @@ /* * Use Case - * Run Greenwood with a custom name for templates directory. + * Run Greenwood with a custom name for layouts directory. * * User Result * Should generate a bare bones Greenwood build. (same as build.default.spec.js) with custom title in header @@ -10,7 +10,7 @@ * * User Config * { - * templatesDirectory: 'layouts' + * layoutsDirectory: 'layouts' * } * * User Workspace @@ -68,7 +68,7 @@ describe('Build Greenwood With: ', function() { it('should have the correct page heading', function() { const heading = dom.window.document.querySelectorAll('head title')[0].textContent; - expect(heading).to.be.equal('Custom Layout Page Template'); + expect(heading).to.be.equal('Custom Layout Page Layout'); }); it('should have the correct page heading', function() { @@ -80,7 +80,7 @@ describe('Build Greenwood With: ', function() { it('should have the correct page heading', function() { const paragraph = dom.window.document.querySelectorAll('body p')[0].textContent; - expect(paragraph).to.be.equal('A page using a page template from a custom layout directory.'); + expect(paragraph).to.be.equal('A page using a page layout from a custom layout directory.'); }); }); }); diff --git a/packages/cli/test/cases/build.config.layouts-directory/greenwood.config.js b/packages/cli/test/cases/build.config.layouts-directory/greenwood.config.js new file mode 100644 index 000000000..04634f17c --- /dev/null +++ b/packages/cli/test/cases/build.config.layouts-directory/greenwood.config.js @@ -0,0 +1,3 @@ +export default { + layoutsDirectory: 'my-layouts' +}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.templates-directory/src/layouts/page.html b/packages/cli/test/cases/build.config.layouts-directory/src/my-layouts/page.html similarity index 66% rename from packages/cli/test/cases/build.config.templates-directory/src/layouts/page.html rename to packages/cli/test/cases/build.config.layouts-directory/src/my-layouts/page.html index b7fe92d12..0fc6a16f9 100644 --- a/packages/cli/test/cases/build.config.templates-directory/src/layouts/page.html +++ b/packages/cli/test/cases/build.config.layouts-directory/src/my-layouts/page.html @@ -1,6 +1,6 @@ - Custom Layout Page Template + Custom Layout Page Layout diff --git a/packages/cli/test/cases/build.config.layouts-directory/src/pages/index.md b/packages/cli/test/cases/build.config.layouts-directory/src/pages/index.md new file mode 100644 index 000000000..3332d3216 --- /dev/null +++ b/packages/cli/test/cases/build.config.layouts-directory/src/pages/index.md @@ -0,0 +1,3 @@ +# Home Page + +A page using a page layout from a custom layout directory. \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.optimization-inline/build.config-optimization-inline.spec.js b/packages/cli/test/cases/build.config.optimization-inline/build.config-optimization-inline.spec.js index 149ae06dd..f025f762b 100644 --- a/packages/cli/test/cases/build.config.optimization-inline/build.config-optimization-inline.spec.js +++ b/packages/cli/test/cases/build.config.optimization-inline/build.config-optimization-inline.spec.js @@ -18,6 +18,8 @@ * components/ * foobar.js * header.js + * layouts/ + * app.html * pages/ * index.html * styles/ diff --git a/packages/cli/test/cases/build.config.optimization-inline/src/templates/app.html b/packages/cli/test/cases/build.config.optimization-inline/src/layouts/app.html similarity index 100% rename from packages/cli/test/cases/build.config.optimization-inline/src/templates/app.html rename to packages/cli/test/cases/build.config.optimization-inline/src/layouts/app.html diff --git a/packages/cli/test/cases/build.config.prerender/build.config.prerender.spec.js b/packages/cli/test/cases/build.config.prerender/build.config.prerender.spec.js index 6a86102e6..881d19886 100644 --- a/packages/cli/test/cases/build.config.prerender/build.config.prerender.spec.js +++ b/packages/cli/test/cases/build.config.prerender/build.config.prerender.spec.js @@ -98,12 +98,6 @@ describe('Build Greenwood With: ', function() { }); }); - it('should have the expected heading text within the index page in the public directory', function() { - const heading = dom.window.document.querySelector('body h1').textContent; - - expect(heading).to.equal('Welcome to Greenwood!'); - }); - it('should have prerendered content from component', function() { const appHeader = dom.window.document.querySelectorAll('body app-header'); const header = dom.window.document.querySelectorAll('body header'); diff --git a/packages/cli/test/cases/build.config.static-router/build.config.static-router.spec.js b/packages/cli/test/cases/build.config.static-router/build.config.static-router.spec.js index 56a992c28..f90f340a3 100644 --- a/packages/cli/test/cases/build.config.static-router/build.config.static-router.spec.js +++ b/packages/cli/test/cases/build.config.static-router/build.config.static-router.spec.js @@ -95,7 +95,7 @@ describe('Build Greenwood With: ', function() { expect(inlineRouterTags.length).to.be.equal(1); expect(inlineRouterTags[0].textContent).to.contain('window.__greenwood = window.__greenwood || {};'); - expect(inlineRouterTags[0].textContent).to.contain('window.__greenwood.currentTemplate = "page"'); + expect(inlineRouterTags[0].textContent).to.contain('window.__greenwood.currentLayout = "page"'); }); it('should have one tag in the for the content', function() { @@ -117,7 +117,7 @@ describe('Build Greenwood With: ', function() { const dataset = aboutRouteTag[0].dataset; expect(aboutRouteTag.length).to.be.equal(1); - expect(dataset.template).to.be.equal('test'); + expect(dataset.layout).to.be.equal('test'); expect(dataset.key).to.be.equal('/_routes/about/index.html'); }); @@ -128,7 +128,7 @@ describe('Build Greenwood With: ', function() { const dataset = aboutRouteTag[0].dataset; expect(aboutRouteTag.length).to.be.equal(1); - expect(dataset.template).to.be.equal('page'); + expect(dataset.layout).to.be.equal('page'); expect(dataset.key).to.be.equal('/_routes/index.html'); }); diff --git a/packages/cli/test/cases/build.config.static-router/src/pages/about.md b/packages/cli/test/cases/build.config.static-router/src/pages/about.md index b90299ace..5618aaa66 100644 --- a/packages/cli/test/cases/build.config.static-router/src/pages/about.md +++ b/packages/cli/test/cases/build.config.static-router/src/pages/about.md @@ -1,5 +1,5 @@ --- -template: test +layout: test --- ### Greenwood diff --git a/packages/cli/test/cases/build.config.static-router/src/pages/regex-test.html b/packages/cli/test/cases/build.config.static-router/src/pages/regex-test.html index e25174ef9..70b2aba4e 100644 --- a/packages/cli/test/cases/build.config.static-router/src/pages/regex-test.html +++ b/packages/cli/test/cases/build.config.static-router/src/pages/regex-test.html @@ -176,7 +176,7 @@

The static site generator for your. . .
-
+

Greenwood is a modern and performant static site generator for Web Component based development.

diff --git a/packages/cli/test/cases/build.config.templates-directory/greenwood.config.js b/packages/cli/test/cases/build.config.templates-directory/greenwood.config.js deleted file mode 100644 index 4ea248e54..000000000 --- a/packages/cli/test/cases/build.config.templates-directory/greenwood.config.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - templatesDirectory: 'layouts' -}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.templates-directory/src/pages/index.md b/packages/cli/test/cases/build.config.templates-directory/src/pages/index.md deleted file mode 100644 index a44ed2d17..000000000 --- a/packages/cli/test/cases/build.config.templates-directory/src/pages/index.md +++ /dev/null @@ -1,3 +0,0 @@ -# Home Page - -A page using a page template from a custom layout directory. \ No newline at end of file diff --git a/packages/cli/test/cases/build.default.meta/build.default.meta.spec.js b/packages/cli/test/cases/build.default.meta/build.default.meta.spec.js index 23efa5a6d..5dae7737f 100644 --- a/packages/cli/test/cases/build.default.meta/build.default.meta.spec.js +++ b/packages/cli/test/cases/build.default.meta/build.default.meta.spec.js @@ -1,6 +1,6 @@ /* * Use Case - * Run Greenwood and tests for correct `` tag merging for pages and templates. + * Run Greenwood and tests for correct `` tag merging for pages and layouts. * * User Result * Should generate a bare bones Greenwood build with one nested About page with expected meta values. @@ -19,7 +19,7 @@ * index.md * hello.md * index.md - * template/ + * layout/ * app.html * page.html */ diff --git a/packages/cli/test/cases/build.default.meta/src/templates/page.html b/packages/cli/test/cases/build.default.meta/src/layouts/page.html similarity index 100% rename from packages/cli/test/cases/build.default.meta/src/templates/page.html rename to packages/cli/test/cases/build.default.meta/src/layouts/page.html diff --git a/packages/cli/test/cases/build.default.ssr-prerender/build.default.ssr-prerender.spec.js b/packages/cli/test/cases/build.default.ssr-prerender/build.default.ssr-prerender.spec.js index f4eb9413e..001fd39a8 100644 --- a/packages/cli/test/cases/build.default.ssr-prerender/build.default.ssr-prerender.spec.js +++ b/packages/cli/test/cases/build.default.ssr-prerender/build.default.ssr-prerender.spec.js @@ -19,7 +19,7 @@ * footer.js * pages/ * index.js - * templates/ + * layouts/ * app.html */ import chai from 'chai'; @@ -68,17 +68,35 @@ describe('Build Greenwood With: ', function() { expect(headings[0].textContent).to.equal('This is the home page.'); }); - it('should have one top level element with a