diff --git a/packages/cli/package.json b/packages/cli/package.json index a581d6cd8..edc3cf2f5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,6 +25,8 @@ "access": "public" }, "dependencies": { + "@web/rollup-plugin-import-meta-assets": "^1.0.0", + "@rollup/plugin-commonjs": "^21.0.0", "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-replace": "^2.3.4", "@rollup/plugin-terser": "^0.1.0", @@ -45,7 +47,7 @@ "remark-rehype": "^7.0.0", "rollup": "^2.58.0", "unified": "^9.2.0", - "wc-compiler": "~0.7.0" + "wc-compiler": "~0.8.0" }, "devDependencies": { "@babel/runtime": "^7.10.4", diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index 591fec340..130eb2d4f 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -1,5 +1,27 @@ import fs from 'fs/promises'; import { checkResourceExists, normalizePathnameForWindows } from '../lib/resource-utils.js'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import { importMetaAssets } from '@web/rollup-plugin-import-meta-assets'; + +// specifically to handle escodegen using require for package.json +// https://github.com/estools/escodegen/issues/455 +function greenwoodJsonLoader() { + return { + name: 'greenwood-json-loader', + async load(id) { + const extension = id.split('.').pop(); + + if (extension === 'json') { + const url = new URL(`file://${id}`); + const json = JSON.parse(await fs.readFile(url, 'utf-8')); + const contents = `export default ${JSON.stringify(json)}`; + + return contents; + } + } + }; +} function greenwoodResourceLoader (compilation) { const resourcePlugins = compilation.config.plugins.filter((plugin) => { @@ -108,7 +130,7 @@ function greenwoodSyncPageResourceBundlesPlugin(compilation) { }; } -const getRollupConfig = async (compilation) => { +const getRollupConfigForScriptResources = async (compilation) => { const { outputDir } = compilation.context; const input = [...compilation.resources.values()] .filter(resource => resource.type === 'script') @@ -165,4 +187,52 @@ const getRollupConfig = async (compilation) => { }]; }; -export { getRollupConfig }; \ No newline at end of file +const getRollupConfigForApis = async (compilation) => { + const { outputDir, userWorkspace } = compilation.context; + const input = [...compilation.manifest.apis.values()] + .map(api => normalizePathnameForWindows(new URL(`.${api.path}`, userWorkspace))); + + // TODO should routes and APIs have chunks? + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + return [{ + input, + output: { + dir: `${normalizePathnameForWindows(outputDir)}/api`, + entryFileNames: '[name].js', + chunkFileNames: '[name].[hash].js' + }, + plugins: [ + greenwoodJsonLoader(), + nodeResolve(), + commonjs(), + importMetaAssets() + ] + }]; +}; + +const getRollupConfigForSsr = async (compilation, input) => { + const { outputDir } = compilation.context; + + // TODO should routes and APIs have chunks? + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + return [{ + input, + output: { + dir: normalizePathnameForWindows(outputDir), + entryFileNames: '_[name].js', + chunkFileNames: '[name].[hash].js' + }, + plugins: [ + greenwoodJsonLoader(), + nodeResolve(), + commonjs(), + importMetaAssets() + ] + }]; +}; + +export { + getRollupConfigForApis, + getRollupConfigForScriptResources, + getRollupConfigForSsr +}; \ No newline at end of file diff --git a/packages/cli/src/index.js b/packages/cli/src/index.js index a5d46571e..e09962e61 100755 --- a/packages/cli/src/index.js +++ b/packages/cli/src/index.js @@ -4,7 +4,6 @@ import { generateCompilation } from './lifecycles/compile.js'; import fs from 'fs/promises'; import program from 'commander'; -import { URL } from 'url'; const greenwoodPackageJson = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf-8')); let cmdOption = {}; @@ -56,11 +55,11 @@ if (program.parse.length === 0) { } const run = async() => { + process.env.__GWD_COMMAND__ = command; const compilation = await generateCompilation(); try { console.info(`Running Greenwood with the ${command} command.`); - process.env.__GWD_COMMAND__ = command; switch (command) { @@ -73,9 +72,6 @@ const run = async() => { break; case 'serve': - process.env.__GWD_COMMAND__ = 'build'; - - await (await import('./commands/build.js')).runProductionBuild(compilation); await (await import('./commands/serve.js')).runProdServer(compilation); break; diff --git a/packages/cli/src/lib/node-modules-utils.js b/packages/cli/src/lib/node-modules-utils.js index 2a45af8ad..688d00686 100644 --- a/packages/cli/src/lib/node-modules-utils.js +++ b/packages/cli/src/lib/node-modules-utils.js @@ -1,7 +1,7 @@ // TODO convert this to use / return URLs // https://github.com/ProjectEvergreen/greenwood/issues/953 import { createRequire } from 'module'; // https://stackoverflow.com/a/62499498/417806 -import { checkResourceExists } from '../lib/resource-utils.js'; +import { checkResourceExists } from './resource-utils.js'; import fs from 'fs/promises'; // defer to NodeJS to find where on disk a package is located using import.meta.resolve diff --git a/packages/cli/src/lib/resource-utils.js b/packages/cli/src/lib/resource-utils.js index d0b2d14e1..6d0e3c2c5 100644 --- a/packages/cli/src/lib/resource-utils.js +++ b/packages/cli/src/lib/resource-utils.js @@ -1,5 +1,5 @@ import fs from 'fs/promises'; -import { hashString } from '../lib/hashing-utils.js'; +import { hashString } from './hashing-utils.js'; async function modelResource(context, type, src = undefined, contents = undefined, optimizationAttr = undefined, rawAttributes = undefined) { const { projectDirectory, scratchDir, userWorkspace } = context; @@ -54,7 +54,7 @@ function mergeResponse(destination, source) { } // On Windows, a URL with a drive letter like C:/ thinks it is a protocol and so prepends a /, e.g. /C:/ -// This is fine with never fs methods that Greenwood uses, but tools like Rollupand PostCSS will need this handled manually +// This is fine with never fs methods that Greenwood uses, but tools like Rollup and PostCSS will need this handled manually // https://github.com/rollup/rollup/issues/3779 function normalizePathnameForWindows(url) { const windowsDriveRegex = /\/[a-zA-Z]{1}:\//; diff --git a/packages/cli/src/lib/ssr-route-worker.js b/packages/cli/src/lib/ssr-route-worker.js index e43c380d6..490624abe 100644 --- a/packages/cli/src/lib/ssr-route-worker.js +++ b/packages/cli/src/lib/ssr-route-worker.js @@ -21,7 +21,7 @@ async function executeRouteModule({ moduleUrl, compilation, route, label, id, pr const { getTemplate = null, getBody = null, getFrontmatter = null } = module; if (module.default) { - const { html } = await renderToString(new URL(moduleUrl)); + const { html } = await renderToString(new URL(moduleUrl), false); data.body = html; } else { diff --git a/packages/cli/src/lib/templating-utils.js b/packages/cli/src/lib/templating-utils.js new file mode 100644 index 000000000..39486a53e --- /dev/null +++ b/packages/cli/src/lib/templating-utils.js @@ -0,0 +1,201 @@ +import fs from 'fs/promises'; +import htmlparser from 'node-html-parser'; +import { checkResourceExists } from './resource-utils.js'; +import { getPackageJson } from './node-modules-utils.js'; + +async function getCustomPageTemplatesFromPlugins(contextPlugins, templateName) { + const customTemplateLocations = []; + const templateDir = contextPlugins + .map(plugin => plugin.templates) + .flat(); + + for (const templateDirUrl of templateDir) { + if (templateName) { + const templateUrl = new URL(`./${templateName}.html`, templateDirUrl); + + if (await checkResourceExists(templateUrl)) { + customTemplateLocations.push(templateUrl); + } + } + } + + return customTemplateLocations; +} + +async function getPageTemplate(filePath, context, template, contextPlugins = []) { + const { templatesDir, userTemplatesDir, pagesDir, projectDirectory } = context; + const customPluginDefaultPageTemplates = await getCustomPageTemplatesFromPlugins(contextPlugins, 'page'); + const customPluginPageTemplates = await getCustomPageTemplatesFromPlugins(contextPlugins, template); + const extension = filePath.split('.').pop(); + const is404Page = filePath.startsWith('404') && extension === 'html'; + const hasCustomTemplate = await checkResourceExists(new URL(`./${template}.html`, userTemplatesDir)); + const hasPageTemplate = await checkResourceExists(new URL('./page.html', userTemplatesDir)); + const hasCustom404Page = await checkResourceExists(new URL('./404.html', pagesDir)); + const isHtmlPage = extension === 'html' && await checkResourceExists(new URL(`./${filePath}`, projectDirectory)); + let contents; + + if (template && (customPluginPageTemplates.length > 0 || hasCustomTemplate)) { + // use a custom template, usually from markdown frontmatter + contents = customPluginPageTemplates.length > 0 + ? await fs.readFile(new URL(`./${template}.html`, customPluginPageTemplates[0]), 'utf-8') + : await fs.readFile(new URL(`./${template}.html`, userTemplatesDir), 'utf-8'); + } else if (isHtmlPage) { + // if the page is already HTML, use that as the template, NOT accounting for 404 pages + contents = await fs.readFile(new URL(`./${filePath}`, projectDirectory), 'utf-8'); + } else if (customPluginDefaultPageTemplates.length > 0 || (!is404Page && hasPageTemplate)) { + // else look for default page template from the user + // and 404 pages should be their own "top level" template + contents = customPluginDefaultPageTemplates.length > 0 + ? await fs.readFile(new URL('./page.html', customPluginDefaultPageTemplates[0]), 'utf-8') + : await fs.readFile(new URL('./page.html', userTemplatesDir), 'utf-8'); + } else if (is404Page && !hasCustom404Page) { + contents = await fs.readFile(new URL('./404.html', templatesDir), 'utf-8'); + } else { + // fallback to using Greenwood's stock page template + contents = await fs.readFile(new URL('./page.html', templatesDir), 'utf-8'); + } + + return contents; +} + +/* eslint-disable-next-line complexity */ +async function getAppTemplate(pageTemplateContents, context, customImports = [], contextPlugins, enableHud, frontmatterTitle) { + const { templatesDir, userTemplatesDir } = context; + const userAppTemplateUrl = new URL('./app.html', userTemplatesDir); + const customAppTemplatesFromPlugins = await getCustomPageTemplatesFromPlugins(contextPlugins, 'app'); + const hasCustomUserAppTemplate = await checkResourceExists(userAppTemplateUrl); + let appTemplateContents = customAppTemplatesFromPlugins.length > 0 + ? await fs.readFile(new URL('./app.html', customAppTemplatesFromPlugins[0])) + : hasCustomUserAppTemplate + ? await fs.readFile(userAppTemplateUrl, 'utf-8') + : await fs.readFile(new URL('./app.html', templatesDir), 'utf-8'); + let mergedTemplateContents = ''; + + const pageRoot = pageTemplateContents && htmlparser.parse(pageTemplateContents, { + script: true, + style: true, + noscript: true, + pre: true + }); + const appRoot = htmlparser.parse(appTemplateContents, { + script: true, + style: true + }); + + if ((pageTemplateContents && !pageRoot.valid) || !appRoot.valid) { + console.debug('ERROR: Invalid HTML detected'); + const invalidContents = !pageRoot.valid + ? pageTemplateContents + : appTemplateContents; + + if (enableHud) { + appTemplateContents = appTemplateContents.replace('', ` + +
+

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

+
+
+                ${invalidContents.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')}
+              
+
+
+ `); + } + + mergedTemplateContents = appTemplateContents.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 template -> app template + ? 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 = pageTemplateContents + ? appBody.replace(/<\/page-outlet>/, pageBody) + : appBody; + + mergedTemplateContents = ` + ${mergedHtml} + + ${title} + ${mergedMeta} + ${mergedLinks} + ${mergedStyles} + ${mergedScripts} + + + ${finalBody} + + + `; + } + + return mergedTemplateContents; +} + +async function getUserScripts (contents, context) { + // https://lit.dev/docs/tools/requirements/#polyfills + if (process.env.__GWD_COMMAND__ === 'build') { // eslint-disable-line no-underscore-dangle + const userPackageJson = await getPackageJson(context); + const dependencies = userPackageJson?.dependencies || {}; + const litPolyfill = dependencies && dependencies.lit + ? '\n' + : ''; + + contents = contents.replace('', ` + + ${litPolyfill} + `); + } + + 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 bc58cf0dd..4da88ed47 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -1,11 +1,30 @@ -/* eslint-disable max-depth */ +/* eslint-disable max-depth, max-len */ import fs from 'fs/promises'; -import { getRollupConfig } from '../config/rollup.config.js'; +import { getRollupConfigForApis, getRollupConfigForScriptResources, getRollupConfigForSsr } from '../config/rollup.config.js'; import { hashString } from '../lib/hashing-utils.js'; -import { checkResourceExists, mergeResponse } from '../lib/resource-utils.js'; +import { checkResourceExists, mergeResponse, normalizePathnameForWindows } from '../lib/resource-utils.js'; import path from 'path'; import { rollup } from 'rollup'; +async function emitResources(compilation) { + const { outputDir } = compilation.context; + const { resources } = compilation; + + // https://stackoverflow.com/a/56150320/417806 + // TODO put into a util + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + await fs.writeFile(new URL('./resources.json', outputDir), JSON.stringify(resources, (key, value) => { + if (value instanceof Map) { + return { + dataType: 'Map', + value: [...value] + }; + } else { + return value; + } + })); +} + async function cleanUpResources(compilation) { const { outputDir } = compilation.context; @@ -141,9 +160,130 @@ async function bundleStyleResources(compilation, resourcePlugins) { } } +async function bundleApiRoutes(compilation) { + // https://rollupjs.org/guide/en/#differences-to-the-javascript-api + const [rollupConfig] = await getRollupConfigForApis(compilation); + + if (rollupConfig.input.length !== 0) { + const bundle = await rollup(rollupConfig); + await bundle.write(rollupConfig.output); + } +} + +async function bundleSsrPages(compilation) { + // https://rollupjs.org/guide/en/#differences-to-the-javascript-api + const { outputDir, pagesDir } = compilation.context; + // TODO context plugins for SSR ? + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + // const contextPlugins = compilation.config.plugins.filter((plugin) => { + // return plugin.type === 'context'; + // }).map((plugin) => { + // return plugin.provider(compilation); + // }); + + const input = []; + // TODO ideally be able to serialize entire graph (or only an explicit subset?) + // right now page.imports is breaking JSON.stringify + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + const intermediateGraph = compilation.graph.map(page => { + const p = { ...page }; + delete p.imports; + + return p; + }); + + for (const page of compilation.graph) { + if (page.isSSR && !page.data.static) { + const { filename, path: pagePath } = page; + const scratchUrl = new URL(`./${filename}`, outputDir); + + // better way to write out inline code like this? + await fs.writeFile(scratchUrl, ` + import { Worker } from 'worker_threads'; + import { getAppTemplate, getPageTemplate, getUserScripts } from '@greenwood/cli/src/lib/templating-utils.js'; + + export async function handler(request, compilation) { + const routeModuleLocationUrl = new URL('./_${filename}', '${outputDir}'); + const routeWorkerUrl = '${compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider().workerUrl}'; + const htmlOptimizer = compilation.config.plugins.find(plugin => plugin.name === 'plugin-standard-html').provider(compilation); + let body = 'Hello from the ${page.id} page!'; + let html = ''; + let frontmatter; + let template; + let templateType = 'page'; + let title = ''; + let imports = []; + + await new Promise((resolve, reject) => { + const worker = new Worker(new URL(routeWorkerUrl)); + + worker.on('message', (result) => { + if (result.body) { + body = result.body; + } + + if (result.template) { + template = result.template; + } + + if (result.frontmatter) { + frontmatter = result.frontmatter; + + if (frontmatter.title) { + title = frontmatter.title; + } + + if (frontmatter.template) { + templateType = frontmatter.template; + } + + if (frontmatter.imports) { + imports = imports.concat(frontmatter.imports); + } + } + + resolve(); + }); + + worker.on('error', reject); + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(\`Worker stopped with exit code \${code}\`)); + } + }); + + worker.postMessage({ + moduleUrl: routeModuleLocationUrl.href, + compilation: \`${JSON.stringify({ graph: intermediateGraph })}\`, + route: '${pagePath}' + }); + }); + + html = template ? template : await getPageTemplate('', compilation.context, templateType, []); + html = await getAppTemplate(html, compilation.context, imports, [], false, title); + html = await getUserScripts(html, compilation.context); + html = html.replace(\/\(.*)<\\/content-outlet>\/s, body); + html = await (await htmlOptimizer.optimize(new URL(request.url), new Response(html))).text(); + + return new Response(html); + } + `); + + input.push(normalizePathnameForWindows(new URL(`./${filename}`, pagesDir))); + } + } + + const [rollupConfig] = await getRollupConfigForSsr(compilation, input); + + if (rollupConfig.input.length !== 0) { + const bundle = await rollup(rollupConfig); + await bundle.write(rollupConfig.output); + } +} + async function bundleScriptResources(compilation) { // https://rollupjs.org/guide/en/#differences-to-the-javascript-api - const [rollupConfig] = await getRollupConfig(compilation); + const [rollupConfig] = await getRollupConfigForScriptResources(compilation); if (rollupConfig.input.length !== 0) { const bundle = await rollup(rollupConfig); @@ -173,6 +313,8 @@ const bundleCompilation = async (compilation) => { console.info('bundling static assets...'); await Promise.all([ + await bundleApiRoutes(compilation), + await bundleSsrPages(compilation), await bundleScriptResources(compilation), await bundleStyleResources(compilation, optimizeResourcePlugins) ]); @@ -181,6 +323,7 @@ const bundleCompilation = async (compilation) => { await optimizeStaticPages(compilation, optimizeResourcePlugins); await cleanUpResources(compilation); + await emitResources(compilation); resolve(); } catch (err) { diff --git a/packages/cli/src/lifecycles/compile.js b/packages/cli/src/lifecycles/compile.js index a8d02f83a..60b1a07c6 100644 --- a/packages/cli/src/lifecycles/compile.js +++ b/packages/cli/src/lifecycles/compile.js @@ -1,6 +1,8 @@ -import { readAndMergeConfig as initConfig } from './config.js'; -import { initContext } from './context.js'; +import { checkResourceExists } from '../lib/resource-utils.js'; import { generateGraph } from './graph.js'; +import { initContext } from './context.js'; +import fs from 'fs/promises'; +import { readAndMergeConfig as initConfig } from './config.js'; const generateCompilation = () => { return new Promise(async (resolve, reject) => { @@ -10,7 +12,12 @@ const generateCompilation = () => { graph: [], context: {}, config: {}, - resources: new Map() + // TODO put resources into manifest + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + resources: new Map(), + manifest: { + apis: new Map() + } }; console.info('Initializing project config'); @@ -19,10 +26,88 @@ const generateCompilation = () => { // determine whether to use default template or user detected workspace console.info('Initializing project workspace contexts'); compilation.context = await initContext(compilation); - - // generate a graph of all pages / components to build - console.info('Generating graph of workspace files...'); - compilation = await generateGraph(compilation); + + const { scratchDir, outputDir } = compilation.context; + + if (!await checkResourceExists(scratchDir)) { + await fs.mkdir(scratchDir); + } + + if (process.env.__GWD_COMMAND__ === 'serve') { // eslint-disable-line no-underscore-dangle + console.info('Loading graph from build output...'); + + if (!await checkResourceExists(new URL('./graph.json', outputDir))) { + reject(new Error('No build output detected. Make sure you have run greenwood build')); + } + + compilation.graph = JSON.parse(await fs.readFile(new URL('./graph.json', outputDir), 'utf-8')); + + // hydrate URLs + compilation.graph.forEach((page, idx) => { + if (page.imports.length > 0) { + page.imports.forEach((imp, jdx) => { + compilation.graph[idx].imports[jdx].sourcePathURL = new URL(imp.sourcePathURL); + }); + } + }); + + if (await checkResourceExists(new URL('./manifest.json', outputDir))) { + console.info('Loading manifest from build output...'); + // TODO put reviver into a utility? + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + const manifest = JSON.parse(await fs.readFile(new URL('./manifest.json', outputDir)), function reviver(key, value) { + if (typeof value === 'object' && value !== null) { + if (value.dataType === 'Map') { + return new Map(value.value); + } + } + return value; + }); + + compilation.manifest = manifest; + } + + if (await checkResourceExists(new URL('./resources.json', outputDir))) { + console.info('Loading resources from build output...'); + // TODO put reviver into a utility? + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + const resources = JSON.parse(await fs.readFile(new URL('./resources.json', outputDir)), function reviver(key, value) { + if (typeof value === 'object' && value !== null) { + if (value.dataType === 'Map') { + // revive URLs + if (value.value.sourcePathURL) { + value.value.sourcePathURL = new URL(value.value.sourcePathURL); + } + + return new Map(value.value); + } + } + return value; + }); + + compilation.resources = resources; + } + } else { + // generate a graph of all pages / components to build + console.info('Generating graph of workspace files...'); + compilation = await generateGraph(compilation); + + // https://stackoverflow.com/a/56150320/417806 + // TODO put reviver into a util? + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + await fs.writeFile(new URL('./manifest.json', scratchDir), JSON.stringify(compilation.manifest, (key, value) => { + if (value instanceof Map) { + return { + dataType: 'Map', + value: [...value] + }; + } else { + return value; + } + })); + + await fs.writeFile(new URL('./graph.json', scratchDir), JSON.stringify(compilation.graph)); + } resolve(compilation); } catch (err) { diff --git a/packages/cli/src/lifecycles/context.js b/packages/cli/src/lifecycles/context.js index 1318c343c..05d52c511 100644 --- a/packages/cli/src/lifecycles/context.js +++ b/packages/cli/src/lifecycles/context.js @@ -11,10 +11,12 @@ const initContext = async({ config }) => { 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 userWorkspace = workspace; const apisDir = new URL('./api/', userWorkspace); const pagesDir = new URL(`./${pagesDirectory}/`, userWorkspace); const userTemplatesDir = new URL(`./${templatesDirectory}/`, userWorkspace); + const context = { dataDir, outputDir, @@ -23,7 +25,8 @@ const initContext = async({ config }) => { pagesDir, userTemplatesDir, scratchDir, - projectDirectory + projectDirectory, + templatesDir }; if (!await checkResourceExists(scratchDir)) { diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js index 0064ec3b1..d937e6bfb 100644 --- a/packages/cli/src/lifecycles/graph.js +++ b/packages/cli/src/lifecycles/graph.js @@ -10,7 +10,7 @@ const generateGraph = async (compilation) => { return new Promise(async (resolve, reject) => { try { const { context } = compilation; - const { pagesDir, projectDirectory, userWorkspace, scratchDir } = context; + const { apisDir, pagesDir, projectDirectory, userWorkspace } = context; let graph = [{ outputPath: 'index.html', filename: 'index.html', @@ -209,7 +209,47 @@ const generateGraph = async (compilation) => { 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(); + const relativeApiPath = filenameUrl.pathname.replace(userWorkspace.pathname, '/'); + const route = relativeApiPath.replace(`.${extension}`, ''); + + if (extension !== 'js') { + console.warn(`${filenameUrl} is not a JavaScript file, skipping...`); + } else { + /* + * 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 + */ + apis.set(route, { + filename: filename, + outputPath: `/api/${filename}`, + path: relativeApiPath, + route + }); + } + } + } + + return apis; + }; + console.debug('building from local sources...'); + // test for SPA if (await checkResourceExists(new URL('./index.html', userWorkspace))) { graph = [{ @@ -276,11 +316,11 @@ const generateGraph = async (compilation) => { compilation.graph = graph; - if (!await checkResourceExists(scratchDir)) { - await fs.mkdir(scratchDir); - } + if (await checkResourceExists(apisDir)) { + const apis = await walkDirectoryForApis(apisDir); - await fs.writeFile(new URL('./graph.json', scratchDir), JSON.stringify(compilation.graph)); + compilation.manifest = { apis }; + } resolve(compilation); } catch (err) { diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index 4ee643a9d..431bcc634 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -269,39 +269,33 @@ async function getStaticServer(compilation, composable) { } async function getHybridServer(compilation) { + const { graph, manifest, context } = compilation; + const { outputDir } = context; const app = await getStaticServer(compilation, true); - const resourcePlugins = compilation.config.plugins.filter((plugin) => { - return plugin.type === 'resource'; - }); app.use(async (ctx) => { try { const url = new URL(`http://localhost:8080${ctx.url}`); - const matchingRoute = compilation.graph.find((node) => node.route === url.pathname) || { data: {} }; + const matchingRoute = graph.find((node) => node.route === url.pathname) || { data: {} }; + const isApiRoute = manifest.apis.has(url.pathname); const request = new Request(url.href, { method: ctx.request.method, headers: ctx.request.header }); - const apiResource = resourcePlugins.find((plugin) => { - return plugin.isGreenwoodDefaultPlugin - && plugin.name === 'plugin-api-routes'; - }).provider(compilation); - const isApiRoute = await apiResource.shouldServe(url, request); if (matchingRoute.isSSR && !matchingRoute.data.static) { - const standardHtmlResource = resourcePlugins.find((plugin) => { - return plugin.isGreenwoodDefaultPlugin - && plugin.name.indexOf('plugin-standard-html') === 0; - }).provider(compilation); - let response = await standardHtmlResource.serve(url, request); - - response = await standardHtmlResource.optimize(url, response); + const { handler } = await import(`${outputDir}${matchingRoute.filename}`); + // TODO passing compilation this way too hacky? + // https://github.com/ProjectEvergreen/greenwood/issues/1008 + const response = await handler(request, compilation); ctx.body = Readable.from(response.body); ctx.set('Content-Type', 'text/html'); ctx.status = 200; } else if (isApiRoute) { - const response = await apiResource.serve(url, request); + const apiRoute = manifest.apis.get(url.pathname); + const { handler } = await import(`${outputDir}${apiRoute.path.replace('/', '')}`); + const response = await handler(request); ctx.status = 200; ctx.set('Content-Type', response.headers.get('Content-Type')); diff --git a/packages/cli/src/plugins/copy/plugin-copy-manifest-json.js b/packages/cli/src/plugins/copy/plugin-copy-manifest-json.js new file mode 100644 index 000000000..ea4031127 --- /dev/null +++ b/packages/cli/src/plugins/copy/plugin-copy-manifest-json.js @@ -0,0 +1,14 @@ +const greenwoodPluginCopyManifestJson = [{ + type: 'copy', + name: 'plugin-copy-manifest-json', + provider: (compilation) => { + const { scratchDir, outputDir } = compilation.context; + + return [{ + from: new URL('./manifest.json', scratchDir), + to: new URL('./manifest.json', outputDir) + }]; + } +}]; + +export { greenwoodPluginCopyManifestJson }; \ No newline at end of file diff --git a/packages/cli/src/plugins/copy/plugin-copy-user-templates.js b/packages/cli/src/plugins/copy/plugin-copy-user-templates.js new file mode 100644 index 000000000..12c1d15f4 --- /dev/null +++ b/packages/cli/src/plugins/copy/plugin-copy-user-templates.js @@ -0,0 +1,21 @@ +import { checkResourceExists } from '../../lib/resource-utils.js'; + +const greenwoodPluginCopyUserTemplates = [{ + type: 'copy', + name: 'plugin-user-templates', + provider: async (compilation) => { + const { outputDir, userTemplatesDir } = compilation.context; + const assets = []; + + if (await checkResourceExists(userTemplatesDir)) { + assets.push({ + from: userTemplatesDir, + to: new URL('./_templates/', outputDir) + }); + } + + return assets; + } +}]; + +export { greenwoodPluginCopyUserTemplates }; \ No newline at end of file diff --git a/packages/cli/src/plugins/resource/plugin-api-routes.js b/packages/cli/src/plugins/resource/plugin-api-routes.js index da39f3e30..d3da4e302 100644 --- a/packages/cli/src/plugins/resource/plugin-api-routes.js +++ b/packages/cli/src/plugins/resource/plugin-api-routes.js @@ -3,7 +3,6 @@ * Manages routing to API routes. * */ -import { checkResourceExists } from '../../lib/resource-utils.js'; import { ResourceInterface } from '../../lib/resource-interface.js'; class ApiRoutesResource extends ResourceInterface { @@ -13,20 +12,17 @@ class ApiRoutesResource extends ResourceInterface { async shouldServe(url) { const { protocol, pathname } = url; - const apiPathUrl = new URL(`.${pathname.replace('/api', '')}.js`, this.compilation.context.apisDir); - if (protocol.startsWith('http') && pathname.startsWith('/api') && await checkResourceExists(apiPathUrl)) { - return true; - } + return protocol.startsWith('http') && this.compilation.manifest.apis.has(pathname); } async serve(url, request) { - let href = new URL(`./${url.pathname.replace('/api/', '')}.js`, `file://${this.compilation.context.apisDir.pathname}`).href; - + const api = this.compilation.manifest.apis.get(url.pathname); + const apiUrl = new URL(`.${api.path}`, this.compilation.context.userWorkspace); // https://github.com/nodejs/modules/issues/307#issuecomment-1165387383 - if (process.env.__GWD_COMMAND__ === 'develop') { // eslint-disable-line no-underscore-dangle - href = `${href}?t=${Date.now()}`; - } + const href = process.env.__GWD_COMMAND__ === 'develop' // eslint-disable-line no-underscore-dangle + ? `${apiUrl.href}?t=${Date.now()}` + : apiUrl.href; const { handler } = await import(href); const req = new Request(new URL(`${request.url.origin}${url}`), { diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js index 1b9d5d036..bb592d3b2 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-html.js +++ b/packages/cli/src/plugins/resource/plugin-standard-html.js @@ -5,204 +5,18 @@ * This is a Greenwood default plugin. * */ -import { checkResourceExists } from '../../lib/resource-utils.js'; import frontmatter from 'front-matter'; import fs from 'fs/promises'; -import { getPackageJson } from '../../lib/node-modules-utils.js'; -import htmlparser from 'node-html-parser'; -import path from 'path'; import rehypeStringify from 'rehype-stringify'; import rehypeRaw from 'rehype-raw'; import remarkFrontmatter from 'remark-frontmatter'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import { ResourceInterface } from '../../lib/resource-interface.js'; +import { getUserScripts, getPageTemplate, getAppTemplate } from '../../lib/templating-utils.js'; import unified from 'unified'; import { Worker } from 'worker_threads'; -async function getCustomPageTemplatesFromPlugins(contextPlugins, templateName) { - const customTemplateLocations = []; - const templateDir = contextPlugins - .map(plugin => plugin.templates) - .flat(); - - for (const templateDirUrl of templateDir) { - if (templateName) { - const templateUrl = new URL(`./${templateName}.html`, templateDirUrl); - - if (await checkResourceExists(templateUrl)) { - customTemplateLocations.push(templateUrl); - } - } - } - - return customTemplateLocations; -} - -const getPageTemplate = async (filePath, { userTemplatesDir, pagesDir, projectDirectory }, template, contextPlugins = []) => { - const customPluginDefaultPageTemplates = await getCustomPageTemplatesFromPlugins(contextPlugins, 'page'); - const customPluginPageTemplates = await getCustomPageTemplatesFromPlugins(contextPlugins, template); - const extension = filePath.split('.').pop(); - const is404Page = filePath.startsWith('404') && extension === 'html'; - const hasCustomTemplate = await checkResourceExists(new URL(`./${template}.html`, userTemplatesDir)); - const hasPageTemplate = await checkResourceExists(new URL('./page.html', userTemplatesDir)); - const hasCustom404Page = await checkResourceExists(new URL('./404.html', pagesDir)); - const isHtmlPage = extension === 'html' && await checkResourceExists(new URL(`./${filePath}`, projectDirectory)); - let contents; - - if (template && (customPluginPageTemplates.length > 0 || hasCustomTemplate)) { - // use a custom template, usually from markdown frontmatter - contents = customPluginPageTemplates.length > 0 - ? await fs.readFile(new URL(`./${template}.html`, customPluginPageTemplates[0]), 'utf-8') - : await fs.readFile(new URL(`./${template}.html`, userTemplatesDir), 'utf-8'); - } else if (isHtmlPage) { - // if the page is already HTML, use that as the template, NOT accounting for 404 pages - contents = await fs.readFile(new URL(`./${filePath}`, projectDirectory), 'utf-8'); - } else if (customPluginDefaultPageTemplates.length > 0 || (!is404Page && hasPageTemplate)) { - // else look for default page template from the user - // and 404 pages should be their own "top level" template - contents = customPluginDefaultPageTemplates.length > 0 - ? await fs.readFile(new URL('./page.html', customPluginDefaultPageTemplates[0]), 'utf-8') - : await fs.readFile(new URL('./page.html', userTemplatesDir), 'utf-8'); - } else if (is404Page && !hasCustom404Page) { - contents = await fs.readFile(new URL('../../templates/404.html', import.meta.url), 'utf-8'); - } else { - // fallback to using Greenwood's stock page template - contents = await fs.readFile(new URL('../../templates/page.html', import.meta.url), 'utf-8'); - } - - return contents; -}; - -const getAppTemplate = async (pageTemplateContents, templatesDir, customImports = [], contextPlugins, enableHud, frontmatterTitle) => { - const userAppTemplateUrl = new URL('./app.html', templatesDir); - const customAppTemplatesFromPlugins = await getCustomPageTemplatesFromPlugins(contextPlugins, 'app'); - const hasCustomUserAppTemplate = await checkResourceExists(userAppTemplateUrl); - let appTemplateContents = customAppTemplatesFromPlugins.length > 0 - ? await fs.readFile(new URL('./app.html', customAppTemplatesFromPlugins[0])) - : hasCustomUserAppTemplate - ? await fs.readFile(userAppTemplateUrl, 'utf-8') - : await fs.readFile(new URL('../../templates/app.html', import.meta.url), 'utf-8'); - let mergedTemplateContents = ''; - - const pageRoot = htmlparser.parse(pageTemplateContents, { - script: true, - style: true, - noscript: true, - pre: true - }); - const appRoot = htmlparser.parse(appTemplateContents, { - script: true, - style: true - }); - - if (!pageRoot.valid || !appRoot.valid) { - console.debug('ERROR: Invalid HTML detected'); - const invalidContents = !pageRoot.valid - ? pageTemplateContents - : appTemplateContents; - - if (enableHud) { - appTemplateContents = appTemplateContents.replace('', ` - -
-

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

-
-
-                ${invalidContents.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')}
-              
-
-
- `); - } - - mergedTemplateContents = appTemplateContents.replace(/<\/page-outlet>/, ''); - } else { - const appTitle = appRoot ? appRoot.querySelector('head title') : null; - const appBody = appRoot.querySelector('body') ? appRoot.querySelector('body').innerHTML : ''; - const pageBody = pageRoot.querySelector('body') ? pageRoot.querySelector('body').innerHTML : ''; - const pageTitle = 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 template -> app template - ? frontmatterTitle - : pageTitle && pageTitle.rawText - ? pageTitle.rawText - : appTitle && appTitle.rawText - ? appTitle.rawText - : 'My App'; - - const mergedHtml = pageRoot.querySelector('html').rawAttrs !== '' - ? `` - : appRoot.querySelector('html').rawAttrs !== '' - ? `` - : ''; - - const mergedMeta = [ - ...appRoot.querySelectorAll('head meta'), - ...pageRoot.querySelectorAll('head meta') - ].join('\n'); - - const mergedLinks = [ - ...appRoot.querySelectorAll('head link'), - ...pageRoot.querySelectorAll('head link') - ].join('\n'); - - const mergedStyles = [ - ...appRoot.querySelectorAll('head style'), - ...pageRoot.querySelectorAll('head style'), - ...customImports.filter(resource => path.extname(resource) === '.css') - .map(resource => ``) - ].join('\n'); - - const mergedScripts = [ - ...appRoot.querySelectorAll('head script'), - ...pageRoot.querySelectorAll('head script'), - ...customImports.filter(resource => path.extname(resource) === '.js') - .map(resource => ``) - ].join('\n'); - - mergedTemplateContents = ` - ${mergedHtml} - - ${title} - ${mergedMeta} - ${mergedLinks} - ${mergedStyles} - ${mergedScripts} - - - ${appBody.replace(/<\/page-outlet>/, pageBody)} - - - `; - } - - return mergedTemplateContents; -}; - -const getUserScripts = async (contents, context) => { - // https://lit.dev/docs/tools/requirements/#polyfills - if (process.env.__GWD_COMMAND__ === 'build') { // eslint-disable-line no-underscore-dangle - const userPackageJson = await getPackageJson(context); - const dependencies = userPackageJson?.dependencies || {}; - const litPolyfill = dependencies && dependencies.lit - ? '\n' - : ''; - - contents = contents.replace('', ` - - ${litPolyfill} - `); - } - return contents; -}; - class StandardHtmlResource extends ResourceInterface { constructor(compilation, options) { super(compilation, options); @@ -220,8 +34,8 @@ class StandardHtmlResource extends ResourceInterface { } async serve(url) { - const { config } = this.compilation; - const { pagesDir, userTemplatesDir, userWorkspace } = this.compilation.context; + const { config, context } = this.compilation; + const { pagesDir, userWorkspace } = context; const { interpolateFrontmatter } = config; const { pathname } = url; const isSpaRoute = this.compilation.graph.find(node => node.isSPA); @@ -346,11 +160,11 @@ class StandardHtmlResource extends ResourceInterface { if (isSpaRoute) { body = await fs.readFile(new URL(`./${isSpaRoute.filename}`, userWorkspace), 'utf-8'); } else { - body = ssrTemplate ? ssrTemplate : await getPageTemplate(filePath, this.compilation.context, template, contextPlugins); + body = ssrTemplate ? ssrTemplate : await getPageTemplate(filePath, context, template, contextPlugins); } - body = await getAppTemplate(body, userTemplatesDir, customImports, contextPlugins, config.devServer.hud, title); - body = await getUserScripts(body, this.compilation.context); + body = await getAppTemplate(body, context, customImports, contextPlugins, config.devServer.hud, title); + body = await getUserScripts(body, context); if (processedMarkdown) { const wrappedCustomElementRegex = /

<[a-zA-Z]*-[a-zA-Z](.*)>(.*)<\/[a-zA-Z]*-[a-zA-Z](.*)><\/p>/g; diff --git a/packages/cli/test/cases/serve.config.static-router/serve.config.static-router.spec.js b/packages/cli/test/cases/serve.config.static-router/serve.config.static-router.spec.js index 6fab318ea..0aa04c416 100644 --- a/packages/cli/test/cases/serve.config.static-router/serve.config.static-router.spec.js +++ b/packages/cli/test/cases/serve.config.static-router/serve.config.static-router.spec.js @@ -22,8 +22,10 @@ * index.md */ import chai from 'chai'; +import fs from 'fs/promises'; import path from 'path'; import { getSetupFiles, getDependencyFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { normalizePathnameForWindows } from '../../../src/lib/resource-utils.js'; import request from 'request'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { Runner } from 'gallinago'; @@ -46,20 +48,79 @@ describe('Serve Greenwood With: ', function() { }); describe(LABEL, function() { + const workaroundFiles = [ + 'hashing-utils', + 'node-modules-utils', + 'resource-utils', + 'templating-utils' + ]; before(async function() { const greenwoodRouterLibs = await getDependencyFiles( `${process.cwd()}/packages/cli/src/lib/router.js`, `${outputPath}/node_modules/@greenwood/cli/src/lib` ); + /* + * there is an odd issue seemingly due to needed lib/router.js tha causes tests to think files are CommonJS + * ``` + * file:///Users/owenbuckley/Workspace/project-evergreen/repos/greenwood/packages/cli/test/cases/serve.config.static-router/public/artists.js:3 + * import { getAppTemplate, getPageTemplate, getUserScripts } from '@greenwood/cli/src/lib/templating-utils.js'; + * ^^^^^^^^^^^^^^ + * SyntaxError: Named export 'getAppTemplate' not found. The requested module '@greenwood/cli/src/lib/templating-utils.js' + * is a CommonJS module, which may not support all module.exports as named exports. + * CommonJS modules can always be imported via the default export, for example using: + * import pkg from '@greenwood/cli/src/lib/templating-utils.js'; + * const { getAppTemplate, getPageTemplate, getUserScripts } = pkg; + * ``` + * + * however no other tests have this issue. so as terrible hack we need to + * - copy all lib files + * - rename them to end in .mjs + * - update references to these files in other imports + * + * (unfortunately, trying to just add a package.json with type="module" did not seem to work :/) + */ + const greenwoodTemplatingLibs = await getDependencyFiles( + `${process.cwd()}/packages/cli/src/lib/*`, + `${outputPath}/node_modules/@greenwood/cli/src/lib` + ); + const greenwoodTemplates = await getDependencyFiles( + `${process.cwd()}/packages/cli/src/templates/*`, + `${outputPath}/node_modules/@greenwood/cli/src/templates` + ); await runner.setup(outputPath, [ ...getSetupFiles(outputPath), - ...greenwoodRouterLibs + ...greenwoodRouterLibs, + ...greenwoodTemplatingLibs, + ...greenwoodTemplates ]); + + for (const f of workaroundFiles) { + const pathname = normalizePathnameForWindows(new URL(`./node_modules/@greenwood/cli/src/lib/${f}.js`, import.meta.url)); + let contents = await fs.readFile(pathname, 'utf-8'); + + workaroundFiles.forEach((wf) => { + contents = contents.replace(`${wf}.js`, `${wf}.mjs`); + }); + + await fs.writeFile(pathname.replace('.js', '.mjs'), contents); + } + + await runner.runCommand(cliPath, 'build'); return new Promise(async (resolve) => { - setTimeout(() => { + setTimeout(async () => { + // template out artists.js to use .mjs too + const pathname = normalizePathnameForWindows(new URL('./public/artists.js', import.meta.url)); + let ssrPageContents = await fs.readFile(pathname, 'utf-8'); + + for (const f of workaroundFiles) { + ssrPageContents = ssrPageContents.replace(`${f}.js`, `${f}.mjs`); + } + + await fs.writeFile(pathname, ssrPageContents); + resolve(); }, 10000); diff --git a/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js b/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js index da11977ed..23cd8b303 100644 --- a/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js +++ b/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js @@ -26,7 +26,6 @@ import { fileURLToPath, URL } from 'url'; const expect = chai.expect; -// TODO why does this test keep stalling out and not closing the command? describe('Serve Greenwood With: ', function() { const LABEL = 'API Routes'; const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); @@ -45,6 +44,7 @@ describe('Serve Greenwood With: ', function() { before(async function() { await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); return new Promise(async (resolve) => { setTimeout(() => { diff --git a/packages/cli/test/cases/serve.default.error/serve.default.error.spec.js b/packages/cli/test/cases/serve.default.error/serve.default.error.spec.js new file mode 100644 index 000000000..a7d8a9cb1 --- /dev/null +++ b/packages/cli/test/cases/serve.default.error/serve.default.error.spec.js @@ -0,0 +1,52 @@ +/* + * Use Case + * Run Greenwood serve command without having already run greenwood build. + * + * User Result + * Should throw an error. + * + * User Command + * greenwood serve + * + * User Config + * N / A + * + * User Workspace + * Greenwood default + */ +import chai from 'chai'; +import path from 'path'; +import { getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; + +const expect = chai.expect; + +describe('Serve Greenwood With: ', function() { + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe('Running the serve command without running the build command first', function() { + it('should throw an error that no build output was detected', async function() { + try { + await runner.setup(outputPath); + await runner.runCommand(cliPath, 'serve'); + } catch (err) { + expect(err).to.contain('No build output detected. Make sure you have run greenwood build'); + } + }); + }); + + after(function() { + runner.teardown(getOutputTeardownFiles(outputPath)); + }); + +}); \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.ssr-prerender/serve.default.ssr-prerender.spec.js b/packages/cli/test/cases/serve.default.ssr-prerender/serve.default.ssr-prerender.spec.js index b589784cc..bc9e55c3b 100644 --- a/packages/cli/test/cases/serve.default.ssr-prerender/serve.default.ssr-prerender.spec.js +++ b/packages/cli/test/cases/serve.default.ssr-prerender/serve.default.ssr-prerender.spec.js @@ -52,6 +52,7 @@ describe('Serve Greenwood With: ', function() { before(async function() { await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); return new Promise(async (resolve) => { setTimeout(() => { diff --git a/packages/cli/test/cases/serve.default.ssr-static-export/serve.default.ssr-static-export.spec.js b/packages/cli/test/cases/serve.default.ssr-static-export/serve.default.ssr-static-export.spec.js index a05b8400f..72f1003dc 100644 --- a/packages/cli/test/cases/serve.default.ssr-static-export/serve.default.ssr-static-export.spec.js +++ b/packages/cli/test/cases/serve.default.ssr-static-export/serve.default.ssr-static-export.spec.js @@ -126,11 +126,11 @@ describe('Serve Greenwood With: ', function() { ...litReactiveElementDecorators, ...litReactiveElementPackageJson ]); - await runner.runCommand(cliPath, 'build'); }); before(async function() { await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); return new Promise(async (resolve) => { setTimeout(() => { diff --git a/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js b/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js index 2863f7935..feb66af61 100644 --- a/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js +++ b/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js @@ -32,7 +32,7 @@ import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils import request from 'request'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { Runner } from 'gallinago'; -import { fileURLToPath, URL } from 'url'; +import { fileURLToPath } from 'url'; const expect = chai.expect; @@ -55,6 +55,7 @@ describe('Serve Greenwood With: ', function() { before(async function() { await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); return new Promise(async (resolve) => { setTimeout(() => { diff --git a/packages/cli/test/cases/serve.default/serve.default.spec.js b/packages/cli/test/cases/serve.default/serve.default.spec.js index c973ea5ce..f2a15e555 100644 --- a/packages/cli/test/cases/serve.default/serve.default.spec.js +++ b/packages/cli/test/cases/serve.default/serve.default.spec.js @@ -60,6 +60,7 @@ describe('Serve Greenwood With: ', function() { before(async function() { await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); return new Promise(async (resolve) => { setTimeout(() => { diff --git a/packages/cli/test/cases/serve.spa/serve.spa.spec.js b/packages/cli/test/cases/serve.spa/serve.spa.spec.js index 64db569c0..c472bda2f 100644 --- a/packages/cli/test/cases/serve.spa/serve.spa.spec.js +++ b/packages/cli/test/cases/serve.spa/serve.spa.spec.js @@ -56,6 +56,7 @@ describe('Serve Greenwood With: ', function() { before(async function() { await runner.setup(outputPath); + await runner.runCommand(cliPath, 'build'); return new Promise(async (resolve) => { setTimeout(() => { diff --git a/packages/plugin-import-jsx/package.json b/packages/plugin-import-jsx/package.json index d77a81e4b..a6129c4ce 100644 --- a/packages/plugin-import-jsx/package.json +++ b/packages/plugin-import-jsx/package.json @@ -25,7 +25,7 @@ "@greenwood/cli": "^0.28.0-alpha.4" }, "dependencies": { - "wc-compiler": "~0.7.0" + "wc-compiler": "~0.8.0" }, "devDependencies": { "@greenwood/cli": "^0.28.0-alpha.4" diff --git a/packages/plugin-renderer-lit/test/cases/build.default/artists.json b/packages/plugin-renderer-lit/test/cases/serve.default/artists.json similarity index 100% rename from packages/plugin-renderer-lit/test/cases/build.default/artists.json rename to packages/plugin-renderer-lit/test/cases/serve.default/artists.json diff --git a/packages/plugin-renderer-lit/test/cases/build.default/greenwood.config.js b/packages/plugin-renderer-lit/test/cases/serve.default/greenwood.config.js similarity index 100% rename from packages/plugin-renderer-lit/test/cases/build.default/greenwood.config.js rename to packages/plugin-renderer-lit/test/cases/serve.default/greenwood.config.js diff --git a/packages/plugin-renderer-lit/test/cases/build.default/package.json b/packages/plugin-renderer-lit/test/cases/serve.default/package.json similarity index 100% rename from packages/plugin-renderer-lit/test/cases/build.default/package.json rename to packages/plugin-renderer-lit/test/cases/serve.default/package.json diff --git a/packages/plugin-renderer-lit/test/cases/build.default/build.default.spec.js b/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js similarity index 98% rename from packages/plugin-renderer-lit/test/cases/build.default/build.default.spec.js rename to packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js index a31503280..95b7576a2 100644 --- a/packages/plugin-renderer-lit/test/cases/build.default/build.default.spec.js +++ b/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js @@ -1,6 +1,6 @@ /* * Use Case - * Run Greenwood with an SSR route. + * Run Greenwood server with an SSR route built using Lit SSR. * * User Result * Should generate a Greenwood build for hosting a server rendered application. @@ -34,7 +34,7 @@ import { fileURLToPath, URL } from 'url'; const expect = chai.expect; -describe('Build Greenwood With: ', function() { +describe('Serve Greenwood With: ', function() { const LABEL = 'Custom Lit Renderer for SSR'; const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); const outputPath = fileURLToPath(new URL('.', import.meta.url)); @@ -127,6 +127,7 @@ describe('Build Greenwood With: ', function() { ...litReactiveElementDecorators, ...litReactiveElementPackageJson ]); + await runner.runCommand(cliPath, 'build'); return new Promise(async (resolve) => { setTimeout(() => { diff --git a/packages/plugin-renderer-lit/test/cases/build.default/src/components/footer.js b/packages/plugin-renderer-lit/test/cases/serve.default/src/components/footer.js similarity index 100% rename from packages/plugin-renderer-lit/test/cases/build.default/src/components/footer.js rename to packages/plugin-renderer-lit/test/cases/serve.default/src/components/footer.js diff --git a/packages/plugin-renderer-lit/test/cases/build.default/src/components/greeting.js b/packages/plugin-renderer-lit/test/cases/serve.default/src/components/greeting.js similarity index 100% rename from packages/plugin-renderer-lit/test/cases/build.default/src/components/greeting.js rename to packages/plugin-renderer-lit/test/cases/serve.default/src/components/greeting.js diff --git a/packages/plugin-renderer-lit/test/cases/build.default/src/pages/artists.js b/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/artists.js similarity index 92% rename from packages/plugin-renderer-lit/test/cases/build.default/src/pages/artists.js rename to packages/plugin-renderer-lit/test/cases/serve.default/src/pages/artists.js index 5e2e8ab04..2bdf8c57f 100644 --- a/packages/plugin-renderer-lit/test/cases/build.default/src/pages/artists.js +++ b/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/artists.js @@ -1,4 +1,4 @@ -import fs from 'fs/promises'; +import fs from 'fs'; import { html } from 'lit'; import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'; import '../components/greeting.js'; @@ -30,7 +30,7 @@ async function getTemplate(compilation, route) { } async function getBody() { - const artists = JSON.parse(await fs.readFile(new URL('../../artists.json', import.meta.url), 'utf-8')); + const artists = JSON.parse(await fs.promises.readFile(new URL('../../artists.json', import.meta.url), 'utf-8')); return html`

Lit SSR response

diff --git a/packages/plugin-renderer-lit/test/cases/build.default/src/pages/users.js b/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/users.js similarity index 100% rename from packages/plugin-renderer-lit/test/cases/build.default/src/pages/users.js rename to packages/plugin-renderer-lit/test/cases/serve.default/src/pages/users.js diff --git a/packages/plugin-renderer-lit/test/cases/build.default/src/templates/app.html b/packages/plugin-renderer-lit/test/cases/serve.default/src/templates/app.html similarity index 100% rename from packages/plugin-renderer-lit/test/cases/build.default/src/templates/app.html rename to packages/plugin-renderer-lit/test/cases/serve.default/src/templates/app.html diff --git a/www/pages/docs/index.md b/www/pages/docs/index.md index 24101b361..9967f730a 100644 --- a/www/pages/docs/index.md +++ b/www/pages/docs/index.md @@ -30,7 +30,9 @@ $ npx @greenwood/cli@latest With Greenwood installed, you can run its CLI to generate your site. The principal commands available are: - `greenwood develop`: Starts a local development server for your project. - `greenwood build`: Generates a production build of your project for just static assets. -- `greenwood serve`: Generates a production build of your project and runs it on a NodeJS based web server, for both static and server renderer pages. +- `greenwood serve`: Starts a server to host the output of the Greenwood build command with NodeJS. For convenience on your host, this can be achieved with `npx @greenwood/cli serve`. + - For SSG, you only need the _public/_ output directory. + - For SSR, you need the _public/_ output directory and the project's _greenwood.config.js_ file (if applicable). - `greenwood eject`: Ejects CLI configurations (Just Rollup right now) to your working directory for more advanced customization. [YMMV](https://www.howtogeek.com/693183/what-does-ymmv-mean-and-how-do-you-use-it/). You can define npm scripts in _package.json_ like so to automate your workflows. You also need to define a `type` field with the value of `module`: @@ -42,7 +44,6 @@ You can define npm scripts in _package.json_ like so to automate your workflows. "start": "greenwood develop", "serve": "greenwood serve" } - } ``` @@ -57,11 +58,13 @@ $ yarn start $ npm run build $ yarn build -# generate a static build and preview it locally +# serve a static build $ npm run serve $ yarn serve ``` +> _Note: You must have run `greenwood build` before running `greenwood serve`. Except for Context plugins, plugins are generally not available with `greenwood serve` as your application is built ahead of time._ + ### Sections To continue learning more about Greenwood, please feel free to browse the other sections of our documentation. diff --git a/yarn.lock b/yarn.lock index f23a3e356..2954f634f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2508,6 +2508,14 @@ estree-walker "^1.0.1" picomatch "^2.2.2" +"@rollup/pluginutils@^4.1.0": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" + integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ== + dependencies: + estree-walker "^2.0.1" + picomatch "^2.2.2" + "@rollup/stream@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@rollup/stream/-/stream-2.0.0.tgz#2ada818c2d042e37f63119d7bf8bbfc71792f641" @@ -2886,6 +2894,15 @@ resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== +"@web/rollup-plugin-import-meta-assets@^1.0.0": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@web/rollup-plugin-import-meta-assets/-/rollup-plugin-import-meta-assets-1.0.7.tgz#7048f717e2218a22af547371ffafdc4f534a2eea" + integrity sha512-ft44CqITUkNd8stwNb4ZOvrZ8DlPifM821jplksmxRGetg5Lx684oFrpfQJ7mfkU/Sa7B3dI1mHTX0DE52eBwg== + dependencies: + "@rollup/pluginutils" "^4.1.0" + estree-walker "^2.0.2" + magic-string "^0.25.7" + "@webcomponents/scoped-custom-element-registry@^0.0.3": version "0.0.3" resolved "https://registry.yarnpkg.com/@webcomponents/scoped-custom-element-registry/-/scoped-custom-element-registry-0.0.3.tgz#774591a886b0b0e4914717273ba53fd8d5657522" @@ -5230,7 +5247,7 @@ estree-walker@^1.0.1: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== -estree-walker@^2.0.1: +estree-walker@^2.0.1, estree-walker@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== @@ -10094,9 +10111,9 @@ rollup-plugin-analyzer@^4.0.0: integrity sha512-LL9GEt3bkXp6Wa19SNR5MWcvHNMvuTFYg+eYBZN2OIFhSWN+pEJUQXEKu5BsOeABob3x9PDaLKW7w5iOJnsESQ== rollup@^2.58.0: - version "2.58.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.58.0.tgz#a643983365e7bf7f5b7c62a8331b983b7c4c67fb" - integrity sha512-NOXpusKnaRpbS7ZVSzcEXqxcLDOagN6iFS8p45RkoiMqPHDLwJm758UF05KlMoCRbLBTZsPOIa887gZJ1AiXvw== + version "2.79.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" + integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== optionalDependencies: fsevents "~2.3.2" @@ -11679,10 +11696,10 @@ w3c-xmlserializer@^2.0.0: dependencies: xml-name-validator "^3.0.0" -wc-compiler@~0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/wc-compiler/-/wc-compiler-0.7.0.tgz#0ceac8af462502ff69a80eb3861f75eaa39c37f1" - integrity sha512-jWORep0UIvnZi8+4ek2ZfX1TJik3B5QW/38MZ6UZN/st3TORt13vk50Arr2Rc3zlpam5AGE3Oxi7no8WmEBBXQ== +wc-compiler@~0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/wc-compiler/-/wc-compiler-0.8.0.tgz#1baadbdc3aff91ff1eba601e56effcb3d6dc5ea8" + integrity sha512-vkq9wcpMOTsIDgPvQYso01fQZTD2iGzKTVhO6AbbAz5bE8WUOvNAN9sPy56An26sVmMl/KNL2St5rVmFQ/lw9w== dependencies: acorn "^8.7.0" acorn-jsx "^5.3.2"