diff --git a/.eslintignore b/.eslintignore index 7862cff23..8e786af06 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,5 @@ **/node_modules/** !.eslintrc.cjs !.mocharc.js -packages/plugin-babel/test/cases/**/*main.js \ No newline at end of file +packages/plugin-babel/test/cases/**/*main.js +TODO.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1303492c0..a56ab6ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ coverage/ node_modules/ packages/**/test/**/yarn.lock packages/**/test/**/package-lock.json -public/ \ No newline at end of file +packages/**/test/**/netlify +packages/**/test/**/.netlify +packages/**/test/**/.vercel +public/ +adapter-outlet/ \ No newline at end of file diff --git a/.ls-lint.yml b/.ls-lint.yml index 6db0a5ef2..64ab822b4 100644 --- a/.ls-lint.yml +++ b/.ls-lint.yml @@ -14,4 +14,5 @@ ls: ignore: - .git - node_modules - - packages/init/node_modules \ No newline at end of file + - packages/init/node_modules + - packages/plugin-typescript/node_modules \ No newline at end of file diff --git a/lerna.json b/lerna.json index 5f90bc0a4..b1cd7d2e6 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.28.5", + "version": "0.29.0-alpha.6", "packages": [ "packages/*", "www" diff --git a/package.json b/package.json index cdcefd983..20bcc8e25 100644 --- a/package.json +++ b/package.json @@ -34,16 +34,16 @@ }, "devDependencies": { "@ls-lint/ls-lint": "^1.10.0", - "@typescript-eslint/eslint-plugin": "^4.28.2", - "@typescript-eslint/parser": "^4.28.2", + "@typescript-eslint/eslint-plugin": "^6.7.5", + "@typescript-eslint/parser": "^6.7.5", "babel-eslint": "^10.1.0", "c8": "^7.10.0", "chai": "^4.2.0", "cross-env": "^7.0.3", - "eslint": "^6.8.0", + "eslint": "^8.51.0", "eslint-plugin-markdown": "^3.0.0", "eslint-plugin-no-only-tests": "^2.6.0", - "gallinago": "^0.6.0", + "gallinago": "^0.7.0", "glob-promise": "^3.4.0", "jsdom": "^16.5.0", "lerna": "^3.16.4", diff --git a/packages/cli/package.json b/packages/cli/package.json index 23883d6e6..5c36fa21a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@greenwood/cli", - "version": "0.28.5", + "version": "0.29.0-alpha.6", "description": "Greenwood CLI.", "type": "module", "repository": "https://github.com/ProjectEvergreen/greenwood", @@ -34,7 +34,6 @@ "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-replace": "^2.3.4", "@rollup/plugin-terser": "^0.1.0", - "@web/rollup-plugin-import-meta-assets": "^1.0.0", "acorn": "^8.0.1", "acorn-walk": "^8.0.0", "commander": "^2.20.0", @@ -42,6 +41,7 @@ "es-module-shims": "^1.2.0", "front-matter": "^4.0.2", "koa": "^2.13.0", + "koa-body": "^6.0.1", "livereload": "^0.9.1", "markdown-toc": "^1.2.0", "node-html-parser": "^1.2.21", @@ -52,7 +52,7 @@ "remark-rehype": "^7.0.0", "rollup": "^2.58.0", "unified": "^9.2.0", - "wc-compiler": "~0.8.0" + "wc-compiler": "~0.9.0" }, "devDependencies": { "@babel/runtime": "^7.10.4", diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index 1d4502195..fba4ef73f 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -71,6 +71,9 @@ const runProductionBuild = async (compilation) => { const prerenderPlugin = compilation.config.plugins.find(plugin => plugin.type === 'renderer') ? compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider(compilation) : {}; + const adapterPlugin = compilation.config.plugins.find(plugin => plugin.type === 'adapter') + ? compilation.config.plugins.find(plugin => plugin.type === 'adapter').provider(compilation) + : null; if (!await checkResourceExists(outputDir)) { await fs.mkdir(outputDir, { @@ -98,7 +101,7 @@ const runProductionBuild = async (compilation) => { return Promise.resolve(server); })); - if (prerenderPlugin.workerUrl) { + if (prerenderPlugin.executeModuleUrl) { await trackResourcesForRoutes(compilation); await preRenderCompilationWorker(compilation, prerenderPlugin); } else { @@ -114,6 +117,10 @@ const runProductionBuild = async (compilation) => { await bundleCompilation(compilation); await copyAssets(compilation); + if (adapterPlugin) { + await adapterPlugin(); + } + resolve(); } catch (err) { reject(err); diff --git a/packages/cli/src/commands/develop.js b/packages/cli/src/commands/develop.js index a5ea59a5c..3c28226c0 100644 --- a/packages/cli/src/commands/develop.js +++ b/packages/cli/src/commands/develop.js @@ -6,11 +6,13 @@ const runDevServer = async (compilation) => { return new Promise(async (resolve, reject) => { try { - const { port } = compilation.config.devServer; + const { basePath, devServer } = compilation.config; + const { port } = devServer; + const postfixSlash = basePath === '' ? '' : '/'; (await getDevServer(compilation)).listen(port, () => { - console.info(`Started local development server at localhost:${port}`); + console.info(`Started local development server at http://localhost:${port}${basePath}${postfixSlash}`); const servers = [...compilation.config.plugins.filter((plugin) => { return plugin.type === 'server'; diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js index f9c1dbfb1..d40983ad1 100644 --- a/packages/cli/src/commands/serve.js +++ b/packages/cli/src/commands/serve.js @@ -6,13 +6,14 @@ const runProdServer = async (compilation) => { return new Promise(async (resolve, reject) => { try { - const port = compilation.config.port; + const { basePath, port } = compilation.config; + const postfixSlash = basePath === '' ? '' : '/'; const hasApisDir = await checkResourceExists(compilation.context.apisDir); - const hasDynamicRoutes = compilation.graph.find(page => page.isSSR && !page.data.static); + const hasDynamicRoutes = compilation.graph.find(page => page.isSSR && !page.prerender); const server = (hasDynamicRoutes && !compilation.config.prerender) || hasApisDir ? getHybridServer : getStaticServer; (await server(compilation)).listen(port, () => { - console.info(`Started server at localhost:${port}`); + console.info(`Started server at http://localhost:${port}${basePath}${postfixSlash}`); }); } catch (err) { reject(err); diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index f3d0ebd4f..8aa6f56c2 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -1,20 +1,27 @@ -import fs from 'fs/promises'; +import fs from 'fs'; +import path from 'path'; 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'; +import * as walk from 'acorn-walk'; -// specifically to handle escodegen using require for package.json +// https://github.com/rollup/rollup/issues/2121 +function cleanRollupId(id) { + return id.replace('\x00', ''); +} + +// specifically to handle escodegen and other node modules +// using require for package.json or other json files // https://github.com/estools/escodegen/issues/455 function greenwoodJsonLoader() { return { name: 'greenwood-json-loader', async load(id) { - const extension = id.split('.').pop(); + const idUrl = new URL(`file://${cleanRollupId(id)}`); + const extension = idUrl.pathname.split('.').pop(); if (extension === 'json') { - const url = new URL(`file://${id}`); - const json = JSON.parse(await fs.readFile(url, 'utf-8')); + const json = JSON.parse(await fs.promises.readFile(idUrl, 'utf-8')); const contents = `export default ${JSON.stringify(json)}`; return contents; @@ -33,11 +40,11 @@ function greenwoodResourceLoader (compilation) { return { name: 'greenwood-resource-loader', async resolveId(id) { - const normalizedId = id.replace(/\?type=(.*)/, ''); + const normalizedId = cleanRollupId(id); // idUrl.pathname; const { projectDirectory, userWorkspace } = compilation.context; - if (id.startsWith('.') && !id.startsWith(projectDirectory.pathname)) { - const prefix = id.startsWith('..') ? './' : ''; + if (normalizedId.startsWith('.') && !normalizedId.startsWith(projectDirectory.pathname)) { + const prefix = normalizedId.startsWith('..') ? './' : ''; const userWorkspaceUrl = new URL(`${prefix}${normalizedId.replace(/\.\.\//g, '')}`, userWorkspace); if (await checkResourceExists(userWorkspaceUrl)) { @@ -46,11 +53,13 @@ function greenwoodResourceLoader (compilation) { } }, async load(id) { - const pathname = id.indexOf('?') >= 0 ? id.slice(0, id.indexOf('?')) : id; + const idUrl = new URL(`file://${cleanRollupId(id)}`); + const { pathname } = idUrl; const extension = pathname.split('.').pop(); - if (extension !== '' && extension !== 'js') { - const url = new URL(`file://${pathname}?type=${extension}`); + // filter first for any bare specifiers + if (await checkResourceExists(idUrl) && extension !== '' && extension !== 'js') { + const url = new URL(`${idUrl.href}?type=${extension}`); const request = new Request(url.href); let response = new Response(''); @@ -116,12 +125,12 @@ function greenwoodSyncPageResourceBundlesPlugin(compilation) { compilation.resources.set(resource.sourcePathURL.pathname, { ...compilation.resources.get(resource.sourcePathURL.pathname), optimizedFileName: fileName, - optimizedFileContents: await fs.readFile(outputPath, 'utf-8'), + optimizedFileContents: await fs.promises.readFile(outputPath, 'utf-8'), contents }); if (noop) { - await fs.writeFile(outputPath, contents); + await fs.promises.writeFile(outputPath, contents); } } } @@ -130,6 +139,162 @@ function greenwoodSyncPageResourceBundlesPlugin(compilation) { }; } +function getMetaImportPath(node) { + return node.arguments[0].value.split('/').join(path.sep); +} + +function isNewUrlImportMetaUrl(node) { + return ( + node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'URL' && + node.arguments.length === 2 && + node.arguments[0].type === 'Literal' && + typeof getMetaImportPath(node) === 'string' && + node.arguments[1].type === 'MemberExpression' && + node.arguments[1].object.type === 'MetaProperty' && + node.arguments[1].property.type === 'Identifier' && + node.arguments[1].property.name === 'url' + ); +} + +// adapted from, and with credit to @web/rollup-plugin-import-meta-assets +// https://modern-web.dev/docs/building/rollup-plugin-import-meta-assets/ +function greenwoodImportMetaUrl(compilation) { + + return { + name: 'greenwood-import-meta-url', + + async transform(code, id) { + const resourcePlugins = compilation.config.plugins.filter((plugin) => { + return plugin.type === 'resource'; + }).map((plugin) => { + return plugin.provider(compilation); + }); + const customResourcePlugins = compilation.config.plugins.filter((plugin) => { + return plugin.type === 'resource' && !plugin.isGreenwoodDefaultPlugin; + }).map((plugin) => { + return plugin.provider(compilation); + }); + const idUrl = new URL(`file://${cleanRollupId(id)}`); + const { pathname } = idUrl; + const extension = pathname.split('.').pop(); + const urlWithType = new URL(`${idUrl.href}?type=${extension}`); + const request = new Request(urlWithType.href); + let canTransform = false; + let response = new Response(code); + + // handle any custom imports or pre-processing needed before passing to Rollup this.parse + if (await checkResourceExists(idUrl) && extension !== '' && extension !== 'json') { + for (const plugin of resourcePlugins) { + if (plugin.shouldServe && await plugin.shouldServe(urlWithType, request)) { + response = await plugin.serve(urlWithType, request); + canTransform = true; + } + } + + for (const plugin of resourcePlugins) { + if (plugin.shouldIntercept && await plugin.shouldIntercept(urlWithType, request, response.clone())) { + response = await plugin.intercept(urlWithType, request, response.clone()); + canTransform = true; + } + } + } + + if (!canTransform) { + return null; + } + + const ast = this.parse(await response.text()); + const assetUrls = []; + let modifiedCode = false; + + // aggregate all references of new URL + import.meta.url + walk.simple(ast, { + NewExpression(node) { + if (isNewUrlImportMetaUrl(node)) { + const absoluteScriptDir = path.dirname(id); + const relativeAssetPath = getMetaImportPath(node); + const absoluteAssetPath = path.resolve(absoluteScriptDir, relativeAssetPath); + const assetName = path.basename(absoluteAssetPath); + const assetExtension = assetName.split('.').pop(); + + assetUrls.push({ + url: new URL(`file://${absoluteAssetPath}?type=${assetExtension}`), + relativeAssetPath + }); + } + } + }); + + for (const assetUrl of assetUrls) { + const { url } = assetUrl; + const { pathname } = url; + const { relativeAssetPath } = assetUrl; + const assetName = path.basename(pathname); + const assetExtension = assetName.split('.').pop(); + const assetContents = await fs.promises.readFile(url, 'utf-8'); + const name = assetName.replace(`.${assetExtension}`, ''); + let bundleExtensions = ['js']; + + for (const plugin of customResourcePlugins) { + if (plugin.shouldServe && await plugin.shouldServe(url)) { + const response = await plugin.serve(url); + + if (response?.headers?.get('content-type') || ''.indexOf('text/javascript') >= 0) { + bundleExtensions = [...bundleExtensions, ...plugin.extensions]; + } + } + } + + const type = bundleExtensions.indexOf(assetExtension) >= 0 + ? 'chunk' + : 'asset'; + const emitConfig = type === 'chunk' + ? { type, id: normalizePathnameForWindows(url), name } + : { type, name: assetName, source: assetContents }; + const ref = this.emitFile(emitConfig); + // handle Windows style paths + const normalizedRelativeAssetPath = relativeAssetPath.replace(/\\/g, '/'); + const importRef = `import.meta.ROLLUP_FILE_URL_${ref}`; + + modifiedCode = code + .replace(`'${normalizedRelativeAssetPath}'`, importRef) + .replace(`"${normalizedRelativeAssetPath}"`, importRef); + } + + return { + code: modifiedCode ? modifiedCode : code, + map: null + }; + } + }; +} + +// TODO could we use this instead? +// https://github.com/rollup/rollup/blob/v2.79.1/docs/05-plugin-development.md#resolveimportmeta +// https://github.com/ProjectEvergreen/greenwood/issues/1087 +function greenwoodPatchSsrPagesEntryPointRuntimeImport() { + return { + name: 'greenwood-patch-ssr-pages-entry-point-runtime-import', + generateBundle(options, bundle) { + Object.keys(bundle).forEach((key) => { + if (key.startsWith('__')) { + // ___GWD_ENTRY_FILE_URL=${filename}___ + const needle = bundle[key].code.match(/___GWD_ENTRY_FILE_URL=(.*.)___/); + if (needle) { + const entryPathMatch = needle[1]; + + bundle[key].code = bundle[key].code.replace(/'___GWD_ENTRY_FILE_URL=(.*.)___'/, `new URL('./_${entryPathMatch}', import.meta.url)`); + } else { + console.warn(`Could not find entry path match for bundle => ${key}`); + } + } + }); + } + }; +} + const getRollupConfigForScriptResources = async (compilation) => { const { outputDir } = compilation.context; const input = [...compilation.resources.values()] @@ -153,6 +318,7 @@ const getRollupConfigForScriptResources = async (compilation) => { plugins: [ greenwoodResourceLoader(compilation), greenwoodSyncPageResourceBundlesPlugin(compilation), + greenwoodImportMetaUrl(compilation), ...customRollupPlugins ], context: 'window', @@ -192,8 +358,13 @@ const getRollupConfigForApis = async (compilation) => { const input = [...compilation.manifest.apis.values()] .map(api => normalizePathnameForWindows(new URL(`.${api.path}`, userWorkspace))); + // why is this needed? + await fs.promises.mkdir(new URL('./api/assets/', outputDir), { + recursive: true + }); + // TODO should routes and APIs have chunks? - // https://github.com/ProjectEvergreen/greenwood/issues/1008 + // https://github.com/ProjectEvergreen/greenwood/issues/1118 return [{ input, output: { @@ -203,9 +374,10 @@ const getRollupConfigForApis = async (compilation) => { }, plugins: [ greenwoodJsonLoader(), + greenwoodResourceLoader(compilation), nodeResolve(), commonjs(), - importMetaAssets() + greenwoodImportMetaUrl(compilation) ] }]; }; @@ -214,7 +386,7 @@ const getRollupConfigForSsr = async (compilation, input) => { const { outputDir } = compilation.context; // TODO should routes and APIs have chunks? - // https://github.com/ProjectEvergreen/greenwood/issues/1008 + // https://github.com/ProjectEvergreen/greenwood/issues/1118 return [{ input, output: { @@ -224,10 +396,35 @@ const getRollupConfigForSsr = async (compilation, input) => { }, plugins: [ greenwoodJsonLoader(), - nodeResolve(), + greenwoodResourceLoader(compilation), + // TODO let this through for lit to enable nodeResolve({ preferBuiltins: true }) + // https://github.com/lit/lit/issues/449 + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + nodeResolve({ + preferBuiltins: true + }), commonjs(), - importMetaAssets() - ] + greenwoodImportMetaUrl(compilation), + greenwoodPatchSsrPagesEntryPointRuntimeImport() // TODO a little hacky but works for now + ], + onwarn: (errorObj) => { + const { code, message } = errorObj; + + switch (code) { + + case 'CIRCULAR_DEPENDENCY': + // TODO let this through for lit by suppressing it + // 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 + break; + default: + // otherwise, log all warnings from rollup + console.debug(message); + + } + } }]; }; diff --git a/packages/cli/src/lib/api-route-worker.js b/packages/cli/src/lib/api-route-worker.js index 5a87470ad..4af5fabc5 100644 --- a/packages/cli/src/lib/api-route-worker.js +++ b/packages/cli/src/lib/api-route-worker.js @@ -1,5 +1,6 @@ // https://github.com/nodejs/modules/issues/307#issuecomment-858729422 import { parentPort } from 'worker_threads'; +import { transformKoaRequestIntoStandardRequest } from './resource-utils.js'; // based on https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript async function responseAsObject (response) { @@ -21,19 +22,31 @@ async function responseAsObject (response) { return filtered; } - // TODO handle full response - // https://github.com/ProjectEvergreen/greenwood/issues/1048 return { ...stringifiableObject(response), headers: Object.fromEntries(response.headers), - // signal: stringifiableObject(request.signal), body: await response.text() }; } async function executeRouteModule({ href, request }) { - const { handler } = await import(href); - const response = await handler(request); + const { body, headers = {}, method, url } = request; + const contentType = headers['content-type'] || ''; + const { handler } = await import(new URL(href)); + const format = contentType.startsWith('application/json') + ? JSON.parse(body) + : body; + + // handling of serialized FormData across Worker threads + if (contentType.startsWith('x-greenwood/www-form-urlencoded')) { + headers['content-type'] = 'application/x-www-form-urlencoded'; + } + + const response = await handler(transformKoaRequestIntoStandardRequest(new URL(url), { + method, + header: headers, + body: format + })); parentPort.postMessage(await responseAsObject(response)); } diff --git a/packages/cli/src/lib/execute-route-module.js b/packages/cli/src/lib/execute-route-module.js new file mode 100644 index 000000000..483696fdc --- /dev/null +++ b/packages/cli/src/lib/execute-route-module.js @@ -0,0 +1,44 @@ +import { renderToString, renderFromHTML } from 'wc-compiler'; + +async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender = false, htmlContents = null, scripts = [], request }) { + const data = { + template: null, + body: null, + frontmatter: null, + html: null + }; + + if (prerender) { + const scriptURLs = scripts.map(scriptFile => new URL(scriptFile)); + const { html } = await renderFromHTML(htmlContents, scriptURLs); + + data.html = html; + } else { + const module = await import(moduleUrl).then(module => module); + const { prerender = false, getTemplate = null, getBody = null, getFrontmatter = null } = module; + + if (module.default) { + const { html } = await renderToString(new URL(moduleUrl), false, request); + + data.body = html; + } else { + if (getBody) { + data.body = await getBody(compilation, page, request); + } + } + + if (getTemplate) { + data.template = await getTemplate(compilation, page); + } + + if (getFrontmatter) { + data.frontmatter = await getFrontmatter(compilation, page); + } + + data.prerender = prerender; + } + + return data; +} + +export { executeRouteModule }; \ 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 6f84e415e..a3d71e579 100644 --- a/packages/cli/src/lib/resource-utils.js +++ b/packages/cli/src/lib/resource-utils.js @@ -36,6 +36,8 @@ async function modelResource(context, type, src = undefined, contents = undefine function mergeResponse(destination, source) { const headers = destination.headers || new Headers(); + const status = source.status || destination.status; + const statusText = source.statusText || destination.statusText; source.headers.forEach((value, key) => { // TODO better way to handle Response automatically setting content-type @@ -47,10 +49,10 @@ function mergeResponse(destination, source) { } }); - // TODO handle merging in state (aborted, type, status, etc) - // https://github.com/ProjectEvergreen/greenwood/issues/1048 return new Response(source.body, { - headers + headers, + status, + statusText }); } @@ -169,11 +171,101 @@ function isLocalLink(url = '') { return url !== '' && (url.indexOf('http') !== 0 && url.indexOf('//') !== 0); } +// TODO handle full request +// https://github.com/ProjectEvergreen/greenwood/discussions/1146 +function transformKoaRequestIntoStandardRequest(url, request) { + const { body, method, header } = request; + const headers = new Headers(header); + const contentType = headers.get('content-type') || ''; + let format; + + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = new FormData(); + + for (const key of Object.keys(body)) { + formData.append(key, body[key]); + } + + // when using FormData, let Request set the correct headers + // or else it will come out as multipart/form-data + // https://stackoverflow.com/a/43521052/417806 + headers.delete('content-type'); + + format = formData; + } else if (contentType.includes('application/json')) { + format = JSON.stringify(body); + } else { + format = body; + } + + // https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#parameters + return new Request(url, { + body: ['GET', 'HEAD'].includes(method.toUpperCase()) + ? null + : format, + method, + headers + }); +} + +// https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript +async function requestAsObject (_request) { + if (!_request instanceof Request) { + throw Object.assign( + new Error(), + { name: 'TypeError', message: 'Argument must be a Request object' } + ); + } + + const request = _request.clone(); + const contentType = request.headers.get('content-type') || ''; + let headers = Object.fromEntries(request.headers); + let format; + + function stringifiableObject (obj) { + const filtered = {}; + for (const key in obj) { + if (['boolean', 'number', 'string'].includes(typeof obj[key]) || obj[key] === null) { + filtered[key] = obj[key]; + } + } + return filtered; + } + + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await request.formData(); + const params = {}; + + for (const entry of formData.entries()) { + params[entry[0]] = entry[1]; + } + + // when using FormData, let Request set the correct headers + // or else it will come out as multipart/form-data + // for serialization between route workers, leave a special marker for Greenwood + // https://stackoverflow.com/a/43521052/417806 + headers['content-type'] = 'x-greenwood/www-form-urlencoded'; + format = JSON.stringify(params); + } else if (contentType.includes('application/json')) { + format = JSON.stringify(await request.json()); + } else { + format = await request.text(); + } + + return { + ...stringifiableObject(request), + body: format, + headers + }; +} + export { checkResourceExists, mergeResponse, modelResource, normalizePathnameForWindows, + requestAsObject, resolveForRelativeUrl, - trackResourcesForRoute + trackResourcesForRoute, + transformKoaRequestIntoStandardRequest }; \ No newline at end of file diff --git a/packages/cli/src/lib/ssr-route-worker.js b/packages/cli/src/lib/ssr-route-worker.js index 490624abe..239eb49a3 100644 --- a/packages/cli/src/lib/ssr-route-worker.js +++ b/packages/cli/src/lib/ssr-route-worker.js @@ -1,47 +1,13 @@ // https://github.com/nodejs/modules/issues/307#issuecomment-858729422 import { parentPort } from 'worker_threads'; -import { renderToString, renderFromHTML } from 'wc-compiler'; -async function executeRouteModule({ moduleUrl, compilation, route, label, id, prerender, htmlContents, scripts }) { - const parsedCompilation = JSON.parse(compilation); - const data = { - template: null, - body: null, - frontmatter: null, - html: null - }; - - if (prerender) { - const scriptURLs = JSON.parse(scripts).map(scriptFile => new URL(scriptFile)); - const { html } = await renderFromHTML(htmlContents, scriptURLs); - - data.html = html; - } else { - const module = await import(moduleUrl).then(module => module); - const { getTemplate = null, getBody = null, getFrontmatter = null } = module; - - if (module.default) { - const { html } = await renderToString(new URL(moduleUrl), false); - - data.body = html; - } else { - if (getBody) { - data.body = await getBody(parsedCompilation, route); - } - } - - if (getTemplate) { - data.template = await getTemplate(parsedCompilation, route); - } - - if (getFrontmatter) { - data.frontmatter = await getFrontmatter(parsedCompilation, route, label, id); - } - } +async function executeModule({ executeModuleUrl, moduleUrl, compilation, page, prerender = false, htmlContents = null, scripts = '[]', request }) { + const { executeRouteModule } = await import(executeModuleUrl); + const data = await executeRouteModule({ moduleUrl, compilation: JSON.parse(compilation), page: JSON.parse(page), prerender, htmlContents, scripts: JSON.parse(scripts), request }); parentPort.postMessage(data); } parentPort.on('message', async (task) => { - await executeRouteModule(task); + await executeModule(task); }); \ No newline at end of file diff --git a/packages/cli/src/lib/templating-utils.js b/packages/cli/src/lib/templating-utils.js index 39486a53e..9503e6aac 100644 --- a/packages/cli/src/lib/templating-utils.js +++ b/packages/cli/src/lib/templating-utils.js @@ -176,7 +176,18 @@ async function getAppTemplate(pageTemplateContents, context, customImports = [], return mergedTemplateContents; } -async function getUserScripts (contents, context) { +async function getUserScripts (contents, compilation) { + const { context, config } = compilation; + + contents = contents.replace('', ` + + + `); + + // TODO get rid of lit polyfills in core + // https://github.com/ProjectEvergreen/greenwood/issues/728 // https://lit.dev/docs/tools/requirements/#polyfills if (process.env.__GWD_COMMAND__ === 'build') { // eslint-disable-line no-underscore-dangle const userPackageJson = await getPackageJson(context); diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 974619223..e5a28fdd5 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -1,6 +1,7 @@ /* 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 { hashString } from '../lib/hashing-utils.js'; import { checkResourceExists, mergeResponse, normalizePathnameForWindows } from '../lib/resource-utils.js'; import path from 'path'; @@ -11,8 +12,6 @@ async function emitResources(compilation) { const { resources, graph } = 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 { @@ -45,10 +44,10 @@ async function optimizeStaticPages(compilation, plugins) { const { scratchDir, outputDir } = compilation.context; return Promise.all(compilation.graph - .filter(page => !page.isSSR || (page.isSSR && page.data.static) || (page.isSSR && compilation.config.prerender)) + .filter(page => !page.isSSR || (page.isSSR && page.prerender) || (page.isSSR && compilation.config.prerender)) .map(async (page) => { const { route, outputPath } = page; - const outputDirUrl = new URL(`.${route}`, outputDir); + const outputDirUrl = new URL(`.${outputPath.replace('index.html', '').replace('404.html', '')}`, outputDir); const url = new URL(`http://localhost:${compilation.config.port}${route}`); const contents = await fs.readFile(new URL(`./${outputPath}`, scratchDir), 'utf-8'); const headers = new Headers({ 'Content-Type': 'text/html' }); @@ -71,7 +70,7 @@ async function optimizeStaticPages(compilation, plugins) { // clean up optimization markers const body = (await response.text()).replace(/data-gwd-opt=".*[a-z]"/g, ''); - await fs.writeFile(new URL(`./${outputPath}`, outputDir), body); + await fs.writeFile(new URL(`.${outputPath}`, outputDir), body); }) ); } @@ -174,108 +173,69 @@ async function bundleApiRoutes(compilation) { 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 hasSSRPages = compilation.graph.filter(page => page.isSSR).length > 0; const input = []; - if (!compilation.config.prerender) { - 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 = ''; - 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(compilation)}\`, - route: '${pagePath}' - }); - }); + 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(); + const { executeRouteModule } = await import(executeModuleUrl); + const { pagesDir, scratchDir } = compilation.context; - 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(); + for (const page of compilation.graph) { + if (page.isSSR && !page.prerender) { + const { filename, imports, route, template, title } = page; + const entryFileUrl = new URL(`./_${filename}`, scratchDir); + const moduleUrl = new URL(`./${filename}`, pagesDir); + 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 }); + 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 htmlOptimizer.optimize(new URL(`http://localhost:8080${route}`), new Response(staticHtml))).text(); + staticHtml = staticHtml.replace(/[`\\$]/g, '\\$&'); // https://stackoverflow.com/a/75688937/417806 + + // better way to write out this inline code? + await fs.writeFile(entryFileUrl, ` + import { executeRouteModule } from '${normalizePathnameForWindows(executeModuleUrl)}'; + + export async function handler(request) { + const compilation = JSON.parse('${JSON.stringify(compilation)}'); + const page = JSON.parse('${JSON.stringify(page)}'); + const moduleUrl = '___GWD_ENTRY_FILE_URL=${filename}___'; + const data = await executeRouteModule({ moduleUrl, compilation, page, request }); + let staticHtml = \`${staticHtml}\`; + + if (data.body) { + staticHtml = staticHtml.replace(\/\(.*)<\\/content-outlet>\/s, data.body); + } - return new Response(html); + return new Response(staticHtml, { + headers: { + 'Content-Type': 'text/html' + } + }); } `); - input.push(normalizePathnameForWindows(new URL(`./${filename}`, pagesDir))); + input.push(normalizePathnameForWindows(moduleUrl)); + input.push(normalizePathnameForWindows(entryFileUrl)); } } const [rollupConfig] = await getRollupConfigForSsr(compilation, input); if (rollupConfig.input.length > 0) { - const { userTemplatesDir, outputDir } = compilation.context; - - if (await checkResourceExists(userTemplatesDir)) { - await fs.cp(userTemplatesDir, new URL('./_templates/', outputDir), { recursive: true }); - } - const bundle = await rollup(rollupConfig); await bundle.write(rollupConfig.output); } @@ -309,13 +269,14 @@ const bundleCompilation = async (compilation) => { await Promise.all([ await bundleApiRoutes(compilation), - await bundleSsrPages(compilation), await bundleScriptResources(compilation), await bundleStyleResources(compilation, optimizeResourcePlugins) ]); - console.info('optimizing static pages....'); + // bundleSsrPages depends on bundleScriptResources having run first + await bundleSsrPages(compilation); + console.info('optimizing static pages....'); await optimizeStaticPages(compilation, optimizeResourcePlugins); await cleanUpResources(compilation); await emitResources(compilation); diff --git a/packages/cli/src/lifecycles/compile.js b/packages/cli/src/lifecycles/compile.js index 5e0d2c977..8b09af2a7 100644 --- a/packages/cli/src/lifecycles/compile.js +++ b/packages/cli/src/lifecycles/compile.js @@ -13,7 +13,6 @@ const generateCompilation = () => { context: {}, config: {}, // TODO put resources into manifest - // https://github.com/ProjectEvergreen/greenwood/issues/1008 resources: new Map(), manifest: { apis: new Map() @@ -45,7 +44,6 @@ const generateCompilation = () => { 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') { @@ -61,7 +59,6 @@ const generateCompilation = () => { 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') { @@ -85,7 +82,6 @@ const generateCompilation = () => { // 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 { diff --git a/packages/cli/src/lifecycles/config.js b/packages/cli/src/lifecycles/config.js index f3afd0ba5..e9b9dd400 100644 --- a/packages/cli/src/lifecycles/config.js +++ b/packages/cli/src/lifecycles/config.js @@ -35,7 +35,7 @@ const greenwoodPlugins = (await Promise.all([ }); const optimizations = ['default', 'none', 'static', 'inline']; -const pluginTypes = ['copy', 'context', 'resource', 'rollup', 'server', 'source', 'renderer']; +const pluginTypes = ['copy', 'context', 'resource', 'rollup', 'server', 'source', 'renderer', 'adapter']; const defaultConfig = { workspace: new URL('./src/', cwd), devServer: { @@ -44,6 +44,7 @@ const defaultConfig = { extensions: [] }, port: 8080, + basePath: '', optimization: optimizations[0], interpolateFrontmatter: false, plugins: greenwoodPlugins, @@ -75,7 +76,7 @@ const readAndMergeConfig = async() => { if (hasConfigFile) { const userCfgFile = (await import(configUrl)).default; - const { workspace, devServer, markdown, optimization, plugins, port, prerender, staticRouter, pagesDirectory, templatesDirectory, interpolateFrontmatter } = userCfgFile; + const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, templatesDirectory, interpolateFrontmatter } = userCfgFile; // workspace validation if (workspace) { @@ -188,6 +189,15 @@ const readAndMergeConfig = async() => { } } + if (basePath) { + // eslint-disable-next-line max-depth + if (typeof basePath !== 'string') { + reject(`Error: greenwood.config.js basePath must be a string. Passed value was: ${basePath}`); + } else { + customConfig.basePath = basePath; + } + } + if (pagesDirectory && typeof pagesDirectory === 'string') { customConfig.pagesDirectory = pagesDirectory; } else if (pagesDirectory) { diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js index 8c66ab3ae..ae82285e6 100644 --- a/packages/cli/src/lifecycles/graph.js +++ b/packages/cli/src/lifecycles/graph.js @@ -1,7 +1,7 @@ /* eslint-disable complexity, max-depth */ import fs from 'fs/promises'; import fm from 'front-matter'; -import { checkResourceExists } from '../lib/resource-utils.js'; +import { checkResourceExists, requestAsObject } from '../lib/resource-utils.js'; import toc from 'markdown-toc'; import { Worker } from 'worker_threads'; @@ -9,18 +9,20 @@ const generateGraph = async (compilation) => { return new Promise(async (resolve, reject) => { try { - const { context } = compilation; + const { context, config } = compilation; + const { basePath } = config; const { apisDir, pagesDir, projectDirectory, userWorkspace } = context; let graph = [{ - outputPath: 'index.html', + outputPath: '/index.html', filename: 'index.html', path: '/', - route: '/', + route: `${basePath}/`, id: 'index', label: 'Index', data: {}, imports: [], - resources: [] + resources: [], + prerender: true }]; const walkDirectoryForPages = async function(directory, pages = []) { @@ -46,6 +48,7 @@ const generateGraph = async (compilation) => { let imports = []; let customData = {}; let filePath; + let prerender = true; /* * check if additional nested directories exist to correctly determine route (minus filename) @@ -116,19 +119,24 @@ const generateGraph = async (compilation) => { } /* ---------End Menu Query-------------------- */ } else if (isDynamic) { - const routeWorkerUrl = compilation.config.plugins.filter(plugin => plugin.type === 'renderer')[0].provider(compilation).workerUrl; + const routeWorkerUrl = compilation.config.plugins.filter(plugin => plugin.type === 'renderer')[0].provider(compilation).executeModuleUrl; let ssrFrontmatter; filePath = route; - await new Promise((resolve, reject) => { - const worker = new Worker(routeWorkerUrl); + 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; + if (result.frontmatter) { result.frontmatter.imports = result.frontmatter.imports || []; ssrFrontmatter = result.frontmatter; } + resolve(); }); worker.on('error', reject); @@ -139,9 +147,20 @@ const generateGraph = async (compilation) => { }); worker.postMessage({ + executeModuleUrl: routeWorkerUrl.href, moduleUrl: filenameUrl.href, compilation: JSON.stringify(compilation), - route + // 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 }); }); @@ -180,6 +199,8 @@ const generateGraph = async (compilation) => { * 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 + * prerednder: if this should be statically exported */ pages.push({ data: customData || {}, @@ -192,13 +213,14 @@ const generateGraph = async (compilation) => { imports, resources: [], outputPath: route === '/404/' - ? '404.html' + ? '/404.html' : `${route}index.html`, path: filePath, - route, + route: `${basePath}${route}`, template, title, - isSSR: !isStatic + isSSR: !isStatic, + prerender }); } } @@ -219,7 +241,7 @@ const generateGraph = async (compilation) => { } else { const extension = filenameUrl.pathname.split('.').pop(); const relativeApiPath = filenameUrl.pathname.replace(userWorkspace.pathname, '/'); - const route = relativeApiPath.replace(`.${extension}`, ''); + const route = `${basePath}${relativeApiPath.replace(`.${extension}`, '')}`; if (extension !== 'js') { console.warn(`${filenameUrl} is not a JavaScript file, skipping...`); @@ -259,7 +281,7 @@ const generateGraph = async (compilation) => { graph = await checkResourceExists(pagesDir) ? await walkDirectoryForPages(pagesDir) : graph; - const has404Page = graph.filter(page => page.route === '/404/').length === 1; + const has404Page = graph.find(page => page.route.endsWith('/404/')); // if the _only_ page is a 404 page, still provide a default index.html if (has404Page && graph.length === 1) { @@ -272,9 +294,9 @@ const generateGraph = async (compilation) => { ...graph, { ...oldGraph, - outputPath: '404.html', + outputPath: '/404.html', filename: '404.html', - route: '/404/', + route: `${basePath}/404/`, path: '404.html', id: '404', label: 'Not Found' diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index 48f8e8fdd..6eb1a3438 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -6,7 +6,7 @@ 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 !== '/404/' && !await checkResourceExists(outputDir)) { + if (!route.endsWith('/404/') && !await checkResourceExists(outputDir)) { await fs.mkdir(outputDir, { recursive: true }); @@ -49,25 +49,24 @@ function getPluginInstances (compilation) { } async function preRenderCompilationWorker(compilation, workerPrerender) { - const pages = compilation.graph.filter(page => !page.isSSR || (page.isSSR && page.data.static) || (page.isSSR && compilation.config.prerender)); + const pages = compilation.graph.filter(page => !page.isSSR || (page.isSSR && page.prerender) || (page.isSSR && compilation.config.prerender)); const { scratchDir } = compilation.context; const plugins = getPluginInstances(compilation); console.info('pages to generate', `\n ${pages.map(page => page.route).join('\n ')}`); - const pool = new WorkerPool(os.cpus().length, workerPrerender.workerUrl); + 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 outputDirUrl = new URL(`./${route}/`, scratchDir); - const outputPathUrl = new URL(`./${outputPath}`, scratchDir); + const outputPathUrl = new URL(`.${outputPath}`, scratchDir); 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 createOutputDirectory(route, outputDirUrl); + await createOutputDirectory(route, new URL(outputPathUrl.href.replace('index.html', ''))); const scripts = resources .map(resource => compilation.resources.get(resource)) @@ -76,9 +75,10 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { body = await new Promise((resolve, reject) => { pool.runTask({ + executeModuleUrl: workerPrerender.executeModuleUrl.href, modulePath: null, compilation: JSON.stringify(compilation), - route, + page: JSON.stringify(page), prerender: true, htmlContents: body, scripts: JSON.stringify(scripts) @@ -105,8 +105,7 @@ async function preRenderCompilationCustom(compilation, customPrerender) { await renderer(compilation, async (page, body) => { const { route, outputPath } = page; - const outputDirUrl = new URL(`./${route}`, scratchDir); - const outputPathUrl = new URL(`./${outputPath}`, scratchDir); + const outputPathUrl = new URL(`.${outputPath}`, scratchDir); // clean up special Greenwood dev only assets that would come through if prerendering with a headless browser body = body.replace(/`, ` `); } else if (optimizationAttr === 'static' || optimization === 'static') { @@ -249,7 +252,7 @@ class StandardHtmlResource extends ResourceInterface { } } else if (type === 'link') { if (!optimizationAttr && (optimization !== 'none' && optimization !== 'inline')) { - const optimizedFilePath = `/${optimizedFileName}`; + const optimizedFilePath = `${basePath}/${optimizedFileName}`; body = body.replace(src, optimizedFilePath); body = body.replace('', ` @@ -277,9 +280,9 @@ class StandardHtmlResource extends ResourceInterface { if (optimizationAttr === 'static' || optimization === 'static') { body = body.replace(``, ''); } else if (optimizationAttr === 'none') { - body = body.replace(contents, contents.replace(/\.\//g, '/').replace(/\$/g, '$$$')); + body = body.replace(contents, contents.replace(/\.\//g, `${basePath}/`).replace(/\$/g, '$$$')); } else { - body = body.replace(contents, optimizedFileContents.replace(/\.\//g, '/').replace(/\$/g, '$$$')); + body = body.replace(contents, optimizedFileContents.replace(/\.\//g, `${basePath}/`).replace(/\$/g, '$$$')); } } else if (type === 'style') { body = body.replace(contents, optimizedFileContents); diff --git a/packages/cli/src/plugins/resource/plugin-standard-json.js b/packages/cli/src/plugins/resource/plugin-standard-json.js index ee29ae8be..726c9b5c1 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-json.js +++ b/packages/cli/src/plugins/resource/plugin-standard-json.js @@ -17,8 +17,9 @@ class StandardJsonResource extends ResourceInterface { async shouldServe(url) { const { protocol, pathname } = url; + const { basePath } = this.compilation.config; const isJson = pathname.split('.').pop() === this.extensions[0]; - const isGraphJson = pathname === '/graph.json'; + const isGraphJson = pathname === `${basePath}/graph.json`; const isWorkspaceFile = protocol === 'file:' && await checkResourceExists(url); return isJson && (isWorkspaceFile || isGraphJson); @@ -27,7 +28,8 @@ class StandardJsonResource extends ResourceInterface { async serve(url) { const { pathname } = url; const { scratchDir } = this.compilation.context; - const finalUrl = pathname.startsWith('/graph.json') + const { basePath } = this.compilation.config; + const finalUrl = pathname === `${basePath}/graph.json` ? new URL('./graph.json', scratchDir) : url; const contents = await fs.readFile(finalUrl, 'utf-8'); diff --git a/packages/cli/src/plugins/resource/plugin-static-router.js b/packages/cli/src/plugins/resource/plugin-static-router.js index 48af82277..90b144d12 100644 --- a/packages/cli/src/plugins/resource/plugin-static-router.js +++ b/packages/cli/src/plugins/resource/plugin-static-router.js @@ -56,30 +56,31 @@ class StaticRouterResource extends ResourceInterface { async optimize(url, response) { let body = await response.text(); + const { basePath } = this.compilation.config; const { pathname } = url; const isStaticRoute = this.compilation.graph.find(page => page.route === pathname && !page.isSSR); const { outputDir } = this.compilation.context; const partial = body.match(/(.*)<\/body>/s)[0].replace('', '').replace('', ''); - const outputPartialDirUrl = new URL(`./_routes${url.pathname}`, outputDir); + const outputPartialDirUrl = new URL(`./_routes${url.pathname.replace(basePath, '')}`, outputDir); const outputPartialDirPathUrl = new URL(`file://${outputPartialDirUrl.pathname.split('/').slice(0, -1).join('/').concat('/')}`); let currentTemplate; const routeTags = this.compilation.graph .filter(page => !page.isSSR) - .filter(page => page.route !== '/404/') + .filter(page => !page.route.endsWith('/404/')) .map((page) => { const template = page.filename && page.filename.split('.').pop() === this.extensions[0] ? page.route : page.template; const key = page.route === '/' ? '' - : page.route.slice(0, page.route.lastIndexOf('/')); + : page.route.slice(0, page.route.lastIndexOf('/')).replace(basePath, ''); if (pathname === page.route) { currentTemplate = template; } return ` - + `; }); @@ -93,13 +94,14 @@ class StaticRouterResource extends ResourceInterface { await fs.writeFile(new URL('./index.html', outputPartialDirUrl), partial); } - body = body.replace('', ` - - - `.replace(/\n/g, '').replace(/ /g, '')) + body = body + .replace('', ` + + + `) .replace(/(.*)<\/body>/s, ` \n diff --git a/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js b/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js index 4c6a66793..6a3aa9372 100644 --- a/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js +++ b/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js @@ -72,7 +72,7 @@ describe('Build Greenwood With: ', function() { }); it('should have the expected tag in the with mixed attribute ordering', function() { it('should have two + + + + +

Hello World

+ +

Greenwood Logo

+ Greenwood Logo +
+ + + \ No newline at end of file diff --git a/packages/cli/test/cases/develop.config.base-path/src/pages/users.js b/packages/cli/test/cases/develop.config.base-path/src/pages/users.js new file mode 100644 index 000000000..7497339d8 --- /dev/null +++ b/packages/cli/test/cases/develop.config.base-path/src/pages/users.js @@ -0,0 +1,18 @@ +export default class UsersPage extends HTMLElement { + async connectedCallback() { + const users = await fetch('https://www.analogstudios.net/api/artists').then(resp => resp.json()); + const html = users.map(user => { + return ` +
+

${user.name}

+ ${user.name} +
+ `; + }).join(''); + + this.innerHTML = ` +

List of Users: ${users.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/cli/test/cases/develop.config.base-path/src/styles/main.css b/packages/cli/test/cases/develop.config.base-path/src/styles/main.css new file mode 100644 index 000000000..9a7b45f93 --- /dev/null +++ b/packages/cli/test/cases/develop.config.base-path/src/styles/main.css @@ -0,0 +1,3 @@ +* { + color: blue; +} \ No newline at end of file diff --git a/packages/cli/test/cases/develop.default.hud-disabled/develop.default.hud-disabled.spec.js b/packages/cli/test/cases/develop.default.hud-disabled/develop.default.hud-disabled.spec.js index 649e63535..41f5801b0 100644 --- a/packages/cli/test/cases/develop.default.hud-disabled/develop.default.hud-disabled.spec.js +++ b/packages/cli/test/cases/develop.default.hud-disabled/develop.default.hud-disabled.spec.js @@ -20,7 +20,6 @@ import fs from 'fs'; import { JSDOM } from 'jsdom'; import path from 'path'; import { getSetupFiles } from '../../../../../test/utils.js'; -import request from 'request'; import { Runner } from 'gallinago'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { fileURLToPath, URL } from 'url'; @@ -61,46 +60,31 @@ describe('Develop Greenwood With: ', function() { describe('Develop command specific HUD HTML behaviors when disabled', function() { let response = {}; let sourceHtml = ''; - let dom; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}`, - headers: { - accept: 'text/html' - } - }, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - - dom = new JSDOM(body); - sourceHtml = fs.readFileSync(fileURLToPath(new URL('./src/pages/index.html', import.meta.url)), 'utf-8'); - - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}`); + body = await response.clone().text(); + sourceHtml = fs.readFileSync(fileURLToPath(new URL('./src/pages/index.html', import.meta.url)), 'utf-8'); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should contain the appropriate HUD output in the response', function(done) { - const body = dom.window.document.querySelectorAll('body')[0]; + const dom = new JSDOM(body); + const bodyTag = dom.window.document.querySelectorAll('body')[0]; - expect(body.textContent).not.to.contain('Malformed HTML detected, please check your closing tags or an HTML formatter'); - expect(body.textContent.replace(/\\n/g, '').trim()).not.to.contain(sourceHtml.replace(/\\n/g, '').trim()); + expect(bodyTag.textContent).not.to.contain('Malformed HTML detected, please check your closing tags or an HTML formatter'); + expect(bodyTag.textContent.replace(/\\n/g, '').trim()).not.to.contain(sourceHtml.replace(/\\n/g, '').trim()); done(); }); diff --git a/packages/cli/test/cases/develop.default.hud/develop.default.hud.spec.js b/packages/cli/test/cases/develop.default.hud/develop.default.hud.spec.js index c611967d5..4d0d00b04 100644 --- a/packages/cli/test/cases/develop.default.hud/develop.default.hud.spec.js +++ b/packages/cli/test/cases/develop.default.hud/develop.default.hud.spec.js @@ -20,7 +20,6 @@ import fs from 'fs'; import { JSDOM } from 'jsdom'; import path from 'path'; import { getSetupFiles } from '../../../../../test/utils.js'; -import request from 'request'; import { Runner } from 'gallinago'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { fileURLToPath, URL } from 'url'; @@ -61,46 +60,31 @@ describe('Develop Greenwood With: ', function() { describe('Develop command specific HUD HTML behaviors', function() { let response = {}; let sourceHtml = ''; - let dom; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}`, - headers: { - accept: 'text/html' - } - }, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - - dom = new JSDOM(body); - sourceHtml = fs.readFileSync(fileURLToPath(new URL('./src/pages/index.html', import.meta.url)), 'utf-8'); - - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}`); + body = await response.clone().text(); + sourceHtml = fs.readFileSync(fileURLToPath(new URL('./src/pages/index.html', import.meta.url)), 'utf-8'); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should contain the appropriate HUD output in the response', function(done) { - const body = dom.window.document.querySelectorAll('body')[0]; + const dom = new JSDOM(body); + const bodyTag = dom.window.document.querySelectorAll('body')[0]; - expect(body.textContent).to.contain('Malformed HTML detected, please check your closing tags or an HTML formatter'); - expect(body.textContent.replace(/\\n/g, '').trim()).to.contain(sourceHtml.replace(/\\n/g, '').trim()); + expect(bodyTag.textContent).to.contain('Malformed HTML detected, please check your closing tags or an HTML formatter'); + expect(bodyTag.textContent.replace(/\\n/g, '').trim()).to.contain(sourceHtml.replace(/\\n/g, '').trim()); done(); }); diff --git a/packages/cli/test/cases/develop.default/develop.default.spec.js b/packages/cli/test/cases/develop.default/develop.default.spec.js index 0136a1ce1..b6fd01a72 100644 --- a/packages/cli/test/cases/develop.default/develop.default.spec.js +++ b/packages/cli/test/cases/develop.default/develop.default.spec.js @@ -18,8 +18,12 @@ * User Workspace * src/ * api/ + * fragment.js * greeting.js + * missing.js * nothing.js + * submit-form-data.js + * submit-json.js * assets/ * data.json * favicon.ico @@ -31,6 +35,7 @@ * splash-clip.mp4 * webcomponents.svg * components/ + * card.js * header.js * pages/ * index.html @@ -43,7 +48,6 @@ import fs from 'fs'; import { JSDOM } from 'jsdom'; import path from 'path'; import { getDependencyFiles, getSetupFiles } from '../../../../../test/utils.js'; -import request from 'request'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { Runner } from 'gallinago'; import { fileURLToPath, URL } from 'url'; @@ -459,34 +463,19 @@ describe('Develop Greenwood With: ', function() { let expectedImportMap; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}`, - headers: { - accept: 'text/html' - } - }, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - - dom = new JSDOM(body); - expectedImportMap = JSON.parse(fs.readFileSync(new URL('./import-map.snapshot.json', import.meta.url), 'utf-8')); - - resolve(); - }); - }); + response = await fetch(`http://127.0.0.1:${port}`); + const data = await response.text(); + dom = new JSDOM(data); + expectedImportMap = JSON.parse(fs.readFileSync(new URL('./import-map.snapshot.json', import.meta.url), 'utf-8')); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); @@ -547,6 +536,17 @@ describe('Develop Greenwood With: ', function() { done(); }); + + it('should add a + + + + +

Hello World

+ +

Greenwood Logo

+ Greenwood Logo +
+ + + \ No newline at end of file diff --git a/packages/cli/test/cases/serve.config.base-path/src/pages/users.js b/packages/cli/test/cases/serve.config.base-path/src/pages/users.js new file mode 100644 index 000000000..7497339d8 --- /dev/null +++ b/packages/cli/test/cases/serve.config.base-path/src/pages/users.js @@ -0,0 +1,18 @@ +export default class UsersPage extends HTMLElement { + async connectedCallback() { + const users = await fetch('https://www.analogstudios.net/api/artists').then(resp => resp.json()); + const html = users.map(user => { + return ` +
+

${user.name}

+ ${user.name} +
+ `; + }).join(''); + + this.innerHTML = ` +

List of Users: ${users.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/cli/test/cases/serve.config.base-path/src/styles/main.css b/packages/cli/test/cases/serve.config.base-path/src/styles/main.css new file mode 100644 index 000000000..9a7b45f93 --- /dev/null +++ b/packages/cli/test/cases/serve.config.base-path/src/styles/main.css @@ -0,0 +1,3 @@ +* { + color: blue; +} \ No newline at end of file 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 369ad63f6..d3be8b253 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,11 +22,8 @@ * 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'; import { fileURLToPath, URL } from 'url'; @@ -48,79 +45,22 @@ 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, - ...greenwoodTemplatingLibs, - ...greenwoodTemplates + ...greenwoodRouterLibs ]); - 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(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); @@ -132,35 +72,26 @@ describe('Serve Greenwood With: ', function() { describe('Serve command with SSR route with staticRouter config set', function() { let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/artists/`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}/artists/`); + body = await response.clone().text(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return the correct response body', function(done) { - expect(response.body).to.contain('

Analog

'); - expect(response.body).to.contain('Analog'); + expect(body).to.contain('

Analog

'); + expect(body).to.contain('Analog'); done(); }); }); 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 c7e88120b..4e9ce751b 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 @@ -14,12 +14,18 @@ * User Workspace * src/ * api/ + * fragment.js * greeting.js + * missing.js + * nothing.js + * submit-form-data.js + * submit-json.js + * components/ + * card.js */ import chai from 'chai'; import path from 'path'; import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; -import request from 'request'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { Runner } from 'gallinago'; import { fileURLToPath, URL } from 'url'; @@ -37,7 +43,7 @@ describe('Serve Greenwood With: ', function() { this.context = { hostname }; - runner = new Runner(true); + runner = new Runner(); }); describe(LABEL, function() { @@ -60,35 +66,31 @@ describe('Serve Greenwood With: ', function() { describe('Serve command with API specific behaviors for a JSON API', function() { const name = 'Greenwood'; let response = {}; + let data; before(async function() { - // TODO not sure why native `fetch` doesn't seem to work here, just hangs the test runner - return new Promise((resolve, reject) => { - request.get(`${hostname}/api/greeting?name=${name}`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = JSON.parse(body); - - resolve(); - }); - }); + response = await fetch(`${hostname}/api/greeting?name=${name}`); + data = await response.clone().json(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); + done(); + }); + + it('should return a default status message', function(done) { + // OK appears to be a Koa default when statusText is an empty string + expect(response.statusText).to.equal('OK'); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('application/json'); + expect(response.headers.get('content-type')).to.equal('application/json'); done(); }); it('should return the correct response body', function(done) { - expect(response.body.message).to.equal(`Hello ${name}!!!`); + expect(data.message).to.equal(`Hello ${name}!!!`); done(); }); }); @@ -96,35 +98,30 @@ describe('Serve Greenwood With: ', function() { describe('Serve command with API specific behaviors for an HTML ("fragment") API', function() { const name = 'Greenwood'; let response = {}; + let body; before(async function() { - // TODO not sure why native `fetch` doesn't seem to work here, just hangs the test runner - return new Promise((resolve, reject) => { - request.get(`${hostname}/api/fragment?name=${name}`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}/api/fragment?name=${name}`); + body = await response.clone().text(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); + done(); + }); + + it('should return a custom status message', function(done) { + expect(response.statusText).to.equal('SUCCESS!!!'); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.equal('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return the correct response body', function(done) { - expect(response.body).to.contain(`

Hello ${name}!!!

`); + expect(body).to.contain(`

Hello ${name}!!!

`); done(); }); }); @@ -133,20 +130,11 @@ describe('Serve Greenwood With: ', function() { let response = {}; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/api/nothing`, (err, res) => { - if (err) { - reject(); - } - - response = res; - resolve(); - }); - }); + response = await fetch(`${hostname}/api/nothing`); }); - it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + it('should return a custom status code', function(done) { + expect(response.status).to.equal(204); done(); }); }); @@ -155,22 +143,99 @@ describe('Serve Greenwood With: ', function() { let response = {}; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/api/foo`, (err, res, body) => { - if (err) { - reject(); - } + response = await fetch(`${hostname}/api/foo`); + }); + + it('should return a 404 status', function(done) { + expect(response.status).to.equal(404); + done(); + }); + }); - response = res; - response.body = body; + describe('Serve command with API specific behaviors with a custom response', function() { + let response = {}; + let body; - resolve(); - }); - }); + before(async function() { + response = await fetch(`${hostname}/api/missing`); + body = await response.clone().text(); }); it('should return a 404 status', function(done) { - expect(response.statusCode).to.equal(404); + expect(response.status).to.equal(404); + done(); + }); + + it('should return a body of not found', function(done) { + expect(body).to.equal('Not Found'); + done(); + }); + }); + + describe('Serve command with POST API specific behaviors for JSON', function() { + const param = 'Greenwood'; + let response = {}; + let data; + + before(async function() { + response = await fetch(`${hostname}/api/submit-json`, { + method: 'POST', + body: JSON.stringify({ name: param }) + }); + data = await response.clone().json(); + }); + + it('should return a 200 status', function(done) { + expect(response.status).to.equal(200); + done(); + }); + + it('should return the expected content type header', function(done) { + expect(response.headers.get('content-type')).to.equal('application/json'); + done(); + }); + + it('should return the secret header in the response', function(done) { + expect(response.headers.get('x-secret')).to.equal('1234'); + done(); + }); + + it('should return the expected response message', function(done) { + const { message } = data; + + expect(message).to.equal(`Thank you ${param} for your submission!`); + done(); + }); + }); + + describe('Serve command with POST API specific behaviors for FormData', function() { + const param = 'Greenwood'; + let response = {}; + let body; + + before(async function() { + response = await fetch(`${hostname}/api/submit-form-data`, { + method: 'POST', + body: new URLSearchParams({ name: param }).toString(), + headers: new Headers({ + 'content-type': 'application/x-www-form-urlencoded' + }) + }); + body = await response.clone().text(); + }); + + it('should return a 200 status', function(done) { + expect(response.status).to.equal(200); + done(); + }); + + it('should return the expected content type header', function(done) { + expect(response.headers.get('content-type')).to.equal('text/html'); + done(); + }); + + it('should return the expected response message', function(done) { + expect(body).to.equal(`Thank you ${param} for your submission!`); done(); }); }); diff --git a/packages/cli/test/cases/serve.default.api/src/api/fragment.js b/packages/cli/test/cases/serve.default.api/src/api/fragment.js index 5dc52e456..ab5a5722c 100644 --- a/packages/cli/test/cases/serve.default.api/src/api/fragment.js +++ b/packages/cli/test/cases/serve.default.api/src/api/fragment.js @@ -1,7 +1,6 @@ import { renderFromHTML } from 'wc-compiler'; export async function handler(request) { - const headers = new Headers(); const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); const name = params.has('name') ? params.get('name') : 'World'; const { html } = await renderFromHTML(` @@ -10,9 +9,10 @@ export async function handler(request) { new URL('../components/card.js', import.meta.url) ]); - headers.append('Content-Type', 'text/html'); - return new Response(html, { - headers + headers: new Headers({ + 'Content-Type': 'text/html' + }), + statusText: 'SUCCESS!!!' }); } \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.api/src/api/missing.js b/packages/cli/test/cases/serve.default.api/src/api/missing.js new file mode 100644 index 000000000..fe2c58c32 --- /dev/null +++ b/packages/cli/test/cases/serve.default.api/src/api/missing.js @@ -0,0 +1,3 @@ +export async function handler() { + return new Response('Not Found', { status: 404 }); +} \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.api/src/api/nothing.js b/packages/cli/test/cases/serve.default.api/src/api/nothing.js index 4596641aa..d7a1ac2a0 100644 --- a/packages/cli/test/cases/serve.default.api/src/api/nothing.js +++ b/packages/cli/test/cases/serve.default.api/src/api/nothing.js @@ -1,3 +1,5 @@ export async function handler() { - return new Response(undefined); + return new Response(null, { + status: 204 + }); } \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.api/src/api/submit-form-data.js b/packages/cli/test/cases/serve.default.api/src/api/submit-form-data.js new file mode 100644 index 000000000..5a4716f26 --- /dev/null +++ b/packages/cli/test/cases/serve.default.api/src/api/submit-form-data.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const formData = await request.formData(); + const name = formData.get('name'); + const body = `Thank you ${name} for your submission!`; + + return new Response(body, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.api/src/api/submit-json.js b/packages/cli/test/cases/serve.default.api/src/api/submit-json.js new file mode 100644 index 000000000..b43a3e367 --- /dev/null +++ b/packages/cli/test/cases/serve.default.api/src/api/submit-json.js @@ -0,0 +1,12 @@ +export async function handler(request) { + const formData = await request.json(); + const { name } = formData; + const body = { message: `Thank you ${name} for your submission!` }; + + return new Response(JSON.stringify(body), { + headers: new Headers({ + 'Content-Type': 'application/json', + 'x-secret': 1234 + }) + }); +} \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.ssr-prerender-api-hybrid/serve.default.ssr-prerender-api-hybrid.spec.js b/packages/cli/test/cases/serve.default.ssr-prerender-api-hybrid/serve.default.ssr-prerender-api-hybrid.spec.js index aa18871d6..ecdd62f82 100644 --- a/packages/cli/test/cases/serve.default.ssr-prerender-api-hybrid/serve.default.ssr-prerender-api-hybrid.spec.js +++ b/packages/cli/test/cases/serve.default.ssr-prerender-api-hybrid/serve.default.ssr-prerender-api-hybrid.spec.js @@ -29,7 +29,6 @@ import glob from 'glob-promise'; import { JSDOM } from 'jsdom'; import path from 'path'; import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; -import request from 'request'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { Runner } from 'gallinago'; import { fileURLToPath, URL } from 'url'; @@ -72,36 +71,27 @@ describe('Serve Greenwood With: ', function() { describe('Serve command that prerenders SSR pages', function() { let dom; let response; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - dom = new JSDOM(body); - - resolve(); - }); - }); + response = await fetch(`${hostname}/`); + body = await response.clone().text(); + dom = new JSDOM(body); }); describe('Serve command with HTML response for the home page', function() { it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return a response body', function(done) { - expect(response.body).to.not.be.undefined; + expect(body).to.not.be.undefined; done(); }); @@ -123,13 +113,13 @@ describe('Serve Greenwood With: ', function() { // TODO no page.js output describe('Serve command for static HTML response with bundled home page + 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 521af14c0..ca93bc372 100644 --- a/packages/cli/test/cases/serve.default/serve.default.spec.js +++ b/packages/cli/test/cases/serve.default/serve.default.spec.js @@ -35,7 +35,6 @@ import chai from 'chai'; import path from 'path'; import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; -import request from 'request'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { Runner } from 'gallinago'; import { fileURLToPath, URL } from 'url'; @@ -46,7 +45,7 @@ describe('Serve Greenwood With: ', function() { const LABEL = 'Default Greenwood Configuration and Workspace'; const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); const outputPath = fileURLToPath(new URL('.', import.meta.url)); - const hostname = 'http://127.0.0.1:8181'; + const hostname = 'http://localhost:8181'; let runner; before(function() { @@ -76,69 +75,48 @@ describe('Serve Greenwood With: ', function() { // proxies to https://jsonplaceholder.typicode.com/posts via greenwood.config.js describe('Serve command with dev proxy', function() { let response = {}; + let data; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/posts?id=7`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = JSON.parse(body); - - resolve(); - }); - }); + response = await fetch(`${hostname}/posts?id=7`); + data = await response.json(); }); - it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); - done(); + it('should return a 200 status', function() { + expect(response.status).to.equal(200); }); - it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('application/json'); - done(); + it('should return the correct content type', function() { + expect(response.headers.get('content-type')).to.equal('application/json; charset=utf-8'); }); - it('should return the correct response body', function(done) { - expect(response.body).to.have.lengthOf(1); - done(); + it('should return the correct response body', function() { + expect(data).to.have.lengthOf(1); }); }); // https://github.com/ProjectEvergreen/greenwood/issues/1059 describe('Serve command with dev proxy with an /api prefix', function() { let response = {}; + let data; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/api/posts?id=7`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = JSON.parse(body); - - resolve(); - }); - }); + response = await fetch(`${hostname}/api/posts?id=7`); + data = await response.clone().json(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('application/json'); + expect(response.headers.get('content-type')).to.equal('application/json; charset=utf-8'); done(); }); it('should return the correct response body', function(done) { - expect(JSON.stringify(response.body)).to.equal('{}'); + expect(JSON.stringify(data)).to.equal('{}'); done(); }); }); @@ -146,69 +124,50 @@ describe('Serve Greenwood With: ', function() { describe('Serve command with image (png) specific behavior', function() { const ext = 'png'; let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - - request.get(`${hostname}/assets/logo.${ext}`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}/assets/logo.${ext}`); + body = await response.clone().text(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.equal(`image/${ext}`); + expect(response.headers.get('content-type')).to.equal(`image/${ext}`); done(); }); it('should return binary data', function(done) { - expect(response.body).to.contain('PNG'); + expect(body).to.contain('PNG'); done(); }); }); describe('Serve command with image (ico) specific behavior', function() { let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/assets/favicon.ico`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(response); - }); - }); + response = await fetch(`${hostname}/assets/favicon.ico`); + body = await response.clone().text(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.equal('image/x-icon'); + expect(response.headers.get('content-type')).to.equal('image/x-icon'); done(); }); it('should return binary data', function(done) { - expect(response.body).to.contain('\u0000'); + expect(body).to.contain('\u0000'); done(); }); }); @@ -216,34 +175,25 @@ describe('Serve Greenwood With: ', function() { describe('Serve command with SVG specific behavior', function() { const ext = 'svg'; let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/assets/webcomponents.${ext}`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}/assets/webcomponents.${ext}`); + body = await response.clone().text(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.equal(`image/${ext}+xml`); + expect(response.headers.get('content-type')).to.equal(`image/${ext}+xml`); done(); }); it('should return the correct response body', function(done) { - expect(response.body.indexOf(' { - request.get(`${hostname}/assets/source-sans-pro.woff?v=1`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}/assets/source-sans-pro.woff?v=1`); + body = await response.clone().text(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.equal(`font/${ext}`); + expect(response.headers.get('content-type')).to.equal(`font/${ext}`); done(); }); it('should return the correct response body', function(done) { - expect(response.body).to.contain('wOFF'); + expect(body).to.contain('wOFF'); done(); }); }); @@ -286,148 +227,110 @@ describe('Serve Greenwood With: ', function() { describe('Serve command with generic video container format (.mp4) behavior', function() { const ext = 'mp4'; let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `${hostname}/assets/splash-clip.mp4` - }, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}/assets/splash-clip.mp4`); + body = await response.clone().text(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain(ext); + expect(response.headers.get('content-type')).to.equal(`video/${ext}`); done(); }); it('should return the correct content length', function(done) { - expect(response.headers['content-length']).to.equal('2498461'); + expect(response.headers.get('content-length')).to.equal('2498461'); done(); }); it('should return the correct response body', function(done) { - expect(response.body).to.contain(ext); + expect(body).to.contain(ext); done(); }); }); describe('Serve command with audio format (.mp3) behavior', function() { let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/assets/song-sample.mp3`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}/assets/song-sample.mp3`); + body = await response.clone().text(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('audio/mpeg'); + expect(response.headers.get('content-type')).to.equal('audio/mpeg'); done(); }); it('should return the correct content length', function(done) { - expect(response.headers['content-length']).to.equal('5425061'); + expect(response.headers.get('content-length')).to.equal('5425061'); done(); }); it('should return the correct response body', function(done) { - expect(response.body).to.contain('ID3'); + expect(body).to.contain('ID3'); done(); }); }); describe('Serve command with JSON specific behavior', function() { let response = {}; + let data; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/assets/data.json`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = JSON.parse(body); - - resolve(); - }); - }); + response = await fetch(`${hostname}/assets/data.json`); + data = await response.clone().json(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('application/json'); + expect(response.headers.get('content-type')).to.equal('application/json'); done(); }); it('should return the correct response body', function(done) { - expect(response.body.name).to.equal('Marvin'); + expect(data.name).to.equal('Marvin'); done(); }); }); describe('Serve command with source map specific behavior', function() { let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/assets/router.js.map`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}/assets/router.js.map`); + body = await response.clone().text(); }); it('should return a 200 status', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('application/json'); + expect(response.headers.get('content-type')).to.equal('application/json'); done(); }); it('should return the correct response body', function(done) { - expect(response.body).to.contain('"sources":["../packages/cli/src/lib/router.js"]'); + expect(body).to.contain('"sources":["../packages/cli/src/lib/router.js"]'); done(); }); }); @@ -436,26 +339,39 @@ describe('Serve Greenwood With: ', function() { let response = {}; before(async function() { - return new Promise((resolve, reject) => { - request.get(`${hostname}/foo.png`, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}/foo.png`); }); it('should return a 404 status', function(done) { - expect(response.statusCode).to.equal(404); + expect(response.status).to.equal(404); done(); }); }); + describe('Fetching graph.json client side', function() { + let response; + let graph; + + before(async function() { + response = await fetch(`${hostname}/graph.json`); + graph = await response.clone().json(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers.get('content-type')).to.contain('application/json'); + done(); + }); + + it('should return a 200', function(done) { + expect(response.status).to.equal(200); + done(); + }); + + it('should have the expected length for all content', function(done) { + expect(graph.length).to.equal(2); + done(); + }); + }); }); after(function() { 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 cfcf7558e..912a546bb 100644 --- a/packages/cli/test/cases/serve.spa/serve.spa.spec.js +++ b/packages/cli/test/cases/serve.spa/serve.spa.spec.js @@ -21,7 +21,6 @@ import chai from 'chai'; import fs from 'fs'; import { getOutputTeardownFiles } from '../../../../../test/utils.js'; import path from 'path'; -import request from 'request'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { Runner } from 'gallinago'; import { fileURLToPath, URL } from 'url'; @@ -71,116 +70,78 @@ describe('Serve Greenwood With: ', function() { describe('Serve command specific HTML behaviors for client side routing at root - /', function() { let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}/`, - headers: { - accept: 'text/html' - } - }, (err, res) => { - if (err) { - reject(); - } - - response = res; - - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}/`); + body = await response.clone().text(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the expected body contents', function(done) { - expect(removeWhiteSpace(response.body.match(BODY_REGEX)[0])).to.equal(expected); + expect(removeWhiteSpace(body.match(BODY_REGEX)[0])).to.equal(expected); done(); }); }); describe('Serve command specific HTML behaviors for client side routing at 1 level route - /', function() { let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}/artists/`, - headers: { - accept: 'text/html' - } - }, (err, res) => { - if (err) { - reject(); - } - - response = res; - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}/artists/`); + body = await response.clone().text(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the expected body contents', function(done) { - expect(removeWhiteSpace(response.body.match(BODY_REGEX)[0])).to.equal(expected); + expect(removeWhiteSpace(body.match(BODY_REGEX)[0])).to.equal(expected); done(); }); }); describe('Serve command specific HTML behaviors for client side routing at 1 level route - //:id', function() { let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}/artists/1`, - headers: { - accept: 'text/html' - } - }, (err, res) => { - if (err) { - reject(); - } - - response = res; - - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}/artists/1`); + body = await response.clone().text(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the expected body contents', function(done) { - expect(removeWhiteSpace(response.body.match(BODY_REGEX)[0])).to.equal(expected); + expect(removeWhiteSpace(body.match(BODY_REGEX)[0])).to.equal(expected); done(); }); }); diff --git a/packages/cli/test/cases/theme-pack/theme-pack.build.spec.js b/packages/cli/test/cases/theme-pack/theme-pack.build.spec.js index f7de5235c..dfde9d685 100644 --- a/packages/cli/test/cases/theme-pack/theme-pack.build.spec.js +++ b/packages/cli/test/cases/theme-pack/theme-pack.build.spec.js @@ -133,9 +133,8 @@ describe('Build Greenwood With: ', function() { it('should have expected script tag in the head', function() { const scriptTags = Array.from(dom.window.document.querySelectorAll('head script')) - .filter((scriptTag) => { - return scriptTag.getAttribute('src').indexOf('/header.') === 0; - }); + .filter((tag) => !tag.getAttribute('data-gwd')) + .filter((tag) => tag.getAttribute('src').indexOf('/header.') === 0); expect(scriptTags.length).to.equal(1); }); diff --git a/packages/cli/test/cases/theme-pack/theme-pack.develop.spec.js b/packages/cli/test/cases/theme-pack/theme-pack.develop.spec.js index b6a583737..85b455571 100644 --- a/packages/cli/test/cases/theme-pack/theme-pack.develop.spec.js +++ b/packages/cli/test/cases/theme-pack/theme-pack.develop.spec.js @@ -29,7 +29,6 @@ import chai from 'chai'; import fs from 'fs'; import { JSDOM } from 'jsdom'; import path from 'path'; -import request from 'request'; import { Runner } from 'gallinago'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { fileURLToPath, URL } from 'url'; @@ -73,27 +72,13 @@ describe('Develop Greenwood With: ', function() { let dom; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}`, - headers: { - accept: 'text/html' - } - }, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - - dom = new JSDOM(body); - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}`); + const body = await response.clone().text(); + dom = new JSDOM(body); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); @@ -116,37 +101,26 @@ describe('Develop Greenwood With: ', function() { describe('Custom Theme Pack internal logic for resolving theme.css for local development', function() { let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}/node_modules/${packageJson.name}/dist/styles/theme.css` - }, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}/node_modules/${packageJson.name}/dist/styles/theme.css`); + body = await response.clone().text(); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/css'); + expect(response.headers.get('content-type')).to.equal('text/css'); done(); }); it('should correctly return CSS from the developers local files', function(done) { - expect(response.body).to.equal(':root {\n --color-primary: #135;\n --color-secondary: #74b238;\n --font-family: \'Optima\', sans-serif;\n}'); + expect(body).to.equal(':root {\n --color-primary: #135;\n --color-secondary: #74b238;\n --font-family: \'Optima\', sans-serif;\n}'); done(); }); @@ -154,37 +128,26 @@ describe('Develop Greenwood With: ', function() { describe('Custom Theme Pack internal logic for resolving header.js for local development', function() { let response = {}; + let body; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}/node_modules/${packageJson.name}/dist/components/header.js` - }, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - response.body = body; - - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}/node_modules/${packageJson.name}/dist/components/header.js`); + body = await response.clone().text(); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/javascript'); + expect(response.headers.get('content-type')).to.equal('text/javascript'); done(); }); it('should correctly return JavaScript from the developers local files', function(done) { - expect(response.body).to.contain('customElements.define(\'x-header\', HeaderComponent);'); + expect(body).to.contain('customElements.define(\'x-header\', HeaderComponent);'); done(); }); diff --git a/packages/init/package.json b/packages/init/package.json index 8a1d730f3..5579d7657 100644 --- a/packages/init/package.json +++ b/packages/init/package.json @@ -1,6 +1,6 @@ { "name": "@greenwood/init", - "version": "0.28.5", + "version": "0.29.0-alpha.6", "description": "A package for scaffolding a new Greenwood project.", "repository": "https://github.com/ProjectEvergreen/greenwood", "homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/init", diff --git a/packages/init/test/cases/develop.default/develop.default.spec.js b/packages/init/test/cases/develop.default/develop.default.spec.js index c93689421..c6a5ba83a 100644 --- a/packages/init/test/cases/develop.default/develop.default.spec.js +++ b/packages/init/test/cases/develop.default/develop.default.spec.js @@ -16,7 +16,6 @@ import fs from 'fs'; import { JSDOM } from 'jsdom'; import path from 'path'; import { getSetupFiles } from '../../../../../test/utils.js'; -import request from 'request'; import { Runner } from 'gallinago'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; import { fileURLToPath, URL } from 'url'; @@ -93,32 +92,18 @@ xdescribe('Scaffold Greenwood and Run Develop command: ', function() { let dom; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}`, - headers: { - accept: 'text/html' - } - }, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - - dom = new JSDOM(body); - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}`); + const data = await response.text(); + dom = new JSDOM(data); }); it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); + expect(response.headers.get('content-type')).to.equal('text/html'); done(); }); it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); + expect(response.status).to.equal(200); done(); }); diff --git a/packages/plugin-adapter-netlify/README.md b/packages/plugin-adapter-netlify/README.md new file mode 100644 index 000000000..9b6c3bfb2 --- /dev/null +++ b/packages/plugin-adapter-netlify/README.md @@ -0,0 +1,88 @@ +# @greenwood/plugin-adapter-netlify + +## Overview +This plugin enables usage of the [Netlify](https://www.netlify.com/) platform for hosting a Greenwood application. + +> This package assumes you already have `@greenwood/cli` installed. + +## Features + +In addition to publishing a project's static assets to the Netlify CDN, this plugin adapts Greenwood [API routes](https://www.greenwoodjs.io/docs/api-routes/) and [SSR pages](https://www.greenwoodjs.io/docs/server-rendering/) into Netlify [Serverless functions](https://docs.netlify.com/functions/overview/) using their [custom build](https://docs.netlify.com/functions/deploy/?fn-language=js#custom-build-2) approach + +This plugin will automatically generate a custom [__redirects_](https://docs.netlify.com/routing/redirects/) file to correctly map your SSR page and API route URLs to the corresponding Netlify function endpoint (as a rewrite). You can continue to customize your Netlify project using your _netlify.toml_ file as needed. + +> _**Note:** You can see a working example of this plugin [here](https://github.com/ProjectEvergreen/greenwood-demo-adapter-netlify)_. + + +## Installation +You can use your favorite JavaScript package manager to install this package. + +_examples:_ +```bash +# npm +npm install @greenwood/plugin-adapter-netlify --save-dev + +# yarn +yarn add @greenwood/plugin-adapter-netlify --dev +``` + + +You will then want to create a _netlify.toml_ file at the root of your project (or configure it via the Netlify UI), updating each value as needed per your own project's setup. + +```toml +[build] + publish = "public/" + command = "npm run build" # or yarn, pnpm, etc + +[build.processing] + skip_processing = true + +[build.environment] + NODE_VERSION = "18.x" # or pin to a specific version, like 18.15.0 +``` + +Set the `AWS_LAMBDA_JS_RUNTIME` environment variable [in your Netlify UI](https://answers.netlify.com/t/aws-lambda-js-runtime-nodejs14-x/32161/2) to the value of `nodejs18.x`. + + +## Usage +Add this plugin to your _greenwood.config.js_. + +```javascript +import { greenwoodPluginAdapterNetlify } from '@greenwood/plugin-adapter-netlify'; + +export default { + ... + + plugins: [ + greenwoodPluginAdapterNetlify() + ] +} +``` + +Optionally, your API routes will have access to Netlify's `context` object as the second parameter to the `handler` function. For example: +```js +export async function handler(request, context = {}) { + console.log({ request, context }); +} +``` + +> _Please see caveats section for more information on this feature. 👇_ + +## Netlify CLI / Local Development + +This plugin comes with the Netlify CLI as a dependency to support some local development testing for previewing a Netlify build locally. Simply add a script like this to your _package.json_ +```json +{ + "serve:netlify": "greenwood build && netlify dev" +} +``` + +Then when you run it, you will be able to run and test a production build of your site locally. + +> _Please see caveats section for more information on this feature. 👇_ + +## Caveats +1. [Edge runtime](https://docs.netlify.com/edge-functions/overview/) is not supported ([yet](https://github.com/ProjectEvergreen/greenwood/issues/1141)). +1. Netlify CLI / Local Dev + - [`context` object](https://docs.netlify.com/functions/create/?fn-language=js#code-your-function-2) not supported when running `greenwood develop` command + - [`import.meta.url` is not supported in the Netlify CLI](https://github.com/netlify/cli/issues/4601) and in particular causes [WCC to break](https://github.com/ProjectEvergreen/greenwood-demo-adapter-netlify#-importmetaurl). \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/package.json b/packages/plugin-adapter-netlify/package.json new file mode 100644 index 000000000..5acbeae34 --- /dev/null +++ b/packages/plugin-adapter-netlify/package.json @@ -0,0 +1,37 @@ +{ + "name": "@greenwood/plugin-adapter-netlify", + "version": "0.29.0-alpha.6", + "description": "A Greenwood plugin for supporting Netlify serverless and edge runtimes.", + "repository": "https://github.com/ProjectEvergreen/greenwood", + "homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-adapter-netlify", + "author": "Owen Buckley ", + "license": "MIT", + "keywords": [ + "Greenwood", + "Static Site Generator", + "SSR", + "Full Stack Web Development", + "Netlify", + "Serverless", + "Edge" + ], + "main": "src/index.js", + "type": "module", + "files": [ + "src/" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@greenwood/cli": "^0.28.0" + }, + "dependencies": { + "netlify-cli": "^15.10.0", + "zip-a-folder": "^2.0.0" + }, + "devDependencies": { + "@greenwood/cli": "^0.29.0-alpha.6", + "extract-zip": "^2.0.1" + } +} diff --git a/packages/plugin-adapter-netlify/src/index.js b/packages/plugin-adapter-netlify/src/index.js new file mode 100644 index 000000000..e028bcf1f --- /dev/null +++ b/packages/plugin-adapter-netlify/src/index.js @@ -0,0 +1,208 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { checkResourceExists, normalizePathnameForWindows } from '@greenwood/cli/src/lib/resource-utils.js'; +import { zip } from 'zip-a-folder'; + +// https://docs.netlify.com/functions/create/?fn-language=js +function generateOutputFormat(id) { + const handlerAlias = '$handler'; + + return ` + import { handler as ${handlerAlias} } from './__${id}.js'; + + export async function handler (event, context = {}) { + const { rawUrl, body, headers = {}, httpMethod } = event; + const contentType = headers['content-type'] || ''; + let format = body; + + if (['GET', 'HEAD'].includes(httpMethod.toUpperCase())) { + format = null + } else if (contentType.includes('application/x-www-form-urlencoded')) { + const searchParams = new URLSearchParams(body); + const formData = new FormData(); + + for (const key of searchParams.keys()) { + const value = searchParams.get(key); + formData.append(key, value); + } + + // when using FormData, let Request set the correct headers + // or else it will come out as multipart/form-data + // https://stackoverflow.com/a/43521052/417806 + format = formData; + delete headers['content-type']; + } else if(contentType.includes('application/json')) { + format = JSON.stringify(body); + } + + const request = new Request(rawUrl, { + body: format, + method: httpMethod, + headers: new Headers(headers) + }); + const response = await ${handlerAlias}(request, context); + + return { + statusCode: response.status, + body: await response.text(), + headers: response.headers || new Headers() + }; + } + `; +} + +async function setupOutputDirectory(id, outputRoot, outputType) { + const outputFormat = generateOutputFormat(id, outputType); + const filename = outputType === 'api' + ? `api-${id}` + : `${id}`; + + await fs.mkdir(outputRoot, { recursive: true }); + await fs.writeFile(new URL(`./${filename}.js`, outputRoot), outputFormat); + await fs.writeFile(new URL('./package.json', outputRoot), JSON.stringify({ + type: 'module' + })); +} + +// TODO manifest options, like node version? +// https://github.com/netlify/zip-it-and-ship-it#options +async function createOutputZip(id, outputType, outputRootUrl, projectDirectory) { + const filename = outputType === 'api' + ? `api-${id}` + : `${id}`; + + await zip( + normalizePathnameForWindows(outputRootUrl), + normalizePathnameForWindows(new URL(`./netlify/functions/${filename}.zip`, projectDirectory)) + ); +} + +async function netlifyAdapter(compilation) { + const { outputDir, projectDirectory, scratchDir } = compilation.context; + const { basePath } = compilation.config; + const adapterOutputUrl = new URL('./netlify/functions/', scratchDir); + const ssrPages = compilation.graph.filter(page => page.isSSR); + const apiRoutes = compilation.manifest.apis; + // https://docs.netlify.com/routing/redirects/ + // https://docs.netlify.com/routing/redirects/rewrites-proxies/ + // When you assign an HTTP status code of 200 to a redirect rule, it becomes a rewrite. + let redirects = ''; + + if (!await checkResourceExists(adapterOutputUrl)) { + await fs.mkdir(adapterOutputUrl, { recursive: true }); + } + + const files = await fs.readdir(outputDir); + const isExecuteRouteModule = files.find(file => file.startsWith('execute-route-module')); + + await fs.mkdir(new URL('./netlify/functions/', projectDirectory), { recursive: true }); + + for (const page of ssrPages) { + const { id } = page; + const outputType = 'page'; + const outputRoot = new URL(`./${id}/`, adapterOutputUrl); + + await setupOutputDirectory(id, outputRoot, outputType); + + await fs.cp( + new URL(`./_${id}.js`, outputDir), + new URL(`./_${id}.js`, outputRoot), + { recursive: true } + ); + await fs.cp( + new URL(`./__${id}.js`, outputDir), + new URL(`./__${id}.js`, outputRoot), + { recursive: true } + ); + + // TODO quick hack to make serverless pages are fully self-contained + // for example, execute-route-module.js will only get code split if there are more than one SSR pages + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + if (isExecuteRouteModule) { + await fs.cp( + new URL(`./${isExecuteRouteModule}`, outputDir), + new URL(`./${isExecuteRouteModule}`, outputRoot) + ); + } + + // TODO how to track SSR resources that get dumped out in the public directory? + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + const ssrPageAssets = (await fs.readdir(outputDir)) + .filter(file => !path.basename(file).startsWith('_') + && !path.basename(file).startsWith('execute') + && path.basename(file).endsWith('.js') + ); + + for (const asset of ssrPageAssets) { + await fs.cp( + new URL(`./${asset}`, outputDir), + new URL(`./${asset}`, outputRoot), + { recursive: true } + ); + } + + await createOutputZip(id, outputType, new URL(`./${id}/`, adapterOutputUrl), projectDirectory); + + redirects += `${basePath}/${id}/ /.netlify/functions/${id} 200 +`; + } + + if (apiRoutes.size > 0) { + redirects += `${basePath}/api/* /.netlify/functions/api-:splat 200`; + } + + for (const [key] of apiRoutes) { + const outputType = 'api'; + const id = key.replace(`${basePath}/api/`, ''); + const outputRoot = new URL(`./api/${id}/`, adapterOutputUrl); + + await setupOutputDirectory(id, outputRoot, outputType); + + // TODO ideally all functions would be self contained + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + await fs.cp( + new URL(`./api/${id}.js`, outputDir), + new URL(`./__${id}.js`, outputRoot), + { recursive: true } + ); + + if (await checkResourceExists(new URL('./api/assets/', outputDir))) { + await fs.cp( + new URL('./api/assets/', outputDir), + new URL('./assets/', outputRoot), + { recursive: true } + ); + } + + const ssrApiAssets = (await fs.readdir(new URL('./api/', outputDir))) + .filter(file => new RegExp(/^[\w][\w-]*\.[a-zA-Z0-9]{4,20}\.[\w]{2,4}$/).test(path.basename(file))); + + for (const asset of ssrApiAssets) { + await fs.cp( + new URL(`./${asset}`, new URL('./api/', outputDir)), + new URL(`./${asset}`, outputRoot), + { recursive: true } + ); + } + + // NOTE: All functions must live at the top level + // https://github.com/netlify/netlify-lambda/issues/90#issuecomment-486047201 + await createOutputZip(id, outputType, outputRoot, projectDirectory); + } + + if (redirects !== '') { + await fs.writeFile(new URL('./_redirects', outputDir), redirects); + } +} + +const greenwoodPluginAdapterNetlify = (options = {}) => [{ + type: 'adapter', + name: 'plugin-adapter-netlify', + provider: (compilation) => { + return async () => { + await netlifyAdapter(compilation, options); + }; + } +}]; + +export { greenwoodPluginAdapterNetlify }; \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.config.base-path/build.config.base-path.spec.js b/packages/plugin-adapter-netlify/test/cases/build.config.base-path/build.config.base-path.spec.js new file mode 100644 index 000000000..9bd94ebf5 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.config.base-path/build.config.base-path.spec.js @@ -0,0 +1,180 @@ +/* + * Use Case + * Run Greenwood with the Netlify adapter plugin and a custom base path set. + * + * User Result + * Should generate a static Greenwood build with serverless and edge functions output. + * + * User Command + * greenwood build + * + * User Config + * import { greenwoodPluginAdapterNetlify } from '@greenwood/plugin-adapter-netlify'; + * + * { + * baseOath: '/my-app', + * plugins: [{ + * greenwoodPluginAdapterNetlify() + * }] + * } + * + * User Workspace + * package.json + * src/ + * api/ + * greeting.js + * pages/ + * users.js + */ +import chai from 'chai'; +import fs from 'fs/promises'; +import glob from 'glob-promise'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; +import { normalizePathnameForWindows } from '../../../../cli/src/lib/resource-utils.js'; +import extract from 'extract-zip'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Netlify Adapter plugin output w/ base path configuration'; + const basePath = '/my-app'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const netlifyFunctionsOutputUrl = new URL('./netlify/functions/', import.meta.url); + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + }); + + describe('Default Output', function() { + let zipFiles; + let redirectsFile; + + before(async function() { + zipFiles = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), '*.zip')); + redirectsFile = await glob.promise(path.join(outputPath, 'public/_redirects')); + }); + + it('should output the expected number of serverless function zip files', function() { + expect(zipFiles.length).to.be.equal(2); + }); + + it('should output the expected number of serverless function API zip files', function() { + expect(zipFiles.filter(file => path.basename(file).startsWith('api-')).length).to.be.equal(1); + }); + + it('should output the expected number of serverless function SSR page zip files', function() { + expect(zipFiles.filter(file => !path.basename(file).startsWith('api-')).length).to.be.equal(1); + }); + + it('should output a _redirects file', function() { + expect(redirectsFile.length).to.be.equal(1); + }); + }); + + describe('Greeting API Route adapter', function() { + let apiFunctions; + + before(async function() { + apiFunctions = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), 'api-greeting.zip')); + }); + + it('should output one API route as a serverless function zip file', function() { + expect(apiFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const param = 'Greenwood'; + const name = path.basename(apiFunctions[0]).replace('.zip', ''); + + await extract(apiFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: `http://www.example.com/${basePath}api/greeting?name=${param}`, + httpMethod: 'GET' + }, {}); + const { statusCode, body, headers } = response; + + expect(statusCode).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('application/json'); + expect(JSON.parse(body).message).to.be.equal(`Hello ${param}!`); + }); + }); + + describe('Users SSR Page adapter', function() { + let pageFunctions; + + before(async function() { + pageFunctions = (await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), '*.zip'))) + .filter(zipFile => path.basename(zipFile).startsWith('users')); + }); + + it('should output one SSR page as a serverless function zip file', function() { + expect(pageFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const name = path.basename(pageFunctions[0]).replace('.zip', ''); + const count = 1; + + await extract(pageFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: `http://www.example.com/${basePath}users/`, + httpMethod: 'GET' + }, {}); + const { statusCode, body, headers } = response; + const dom = new JSDOM(body); + const articleTags = dom.window.document.querySelectorAll('body > article'); + const headings = dom.window.document.querySelectorAll('body > h1'); + + expect(statusCode).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('text/html'); + expect(articleTags.length).to.be.equal(count); + expect(headings.length).to.be.equal(1); + expect(headings[0].textContent).to.be.equal(`List of Users: ${count}`); + }); + }); + + describe('_redirects file contents', function() { + let redirectsFileContents; + + before(async function() { + redirectsFileContents = await fs.readFile(path.join(outputPath, 'public/_redirects'), 'utf-8'); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + expect(redirectsFileContents).to.be.equal( +`${basePath}/users/ /.netlify/functions/users 200 +${basePath}/api/* /.netlify/functions/api-:splat 200` + ); + }); + }); + }); + + after(function() { + runner.teardown([ + path.join(outputPath, 'netlify'), + ...getOutputTeardownFiles(outputPath) + ]); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.config.base-path/greenwood.config.js b/packages/plugin-adapter-netlify/test/cases/build.config.base-path/greenwood.config.js new file mode 100644 index 000000000..e6cb67ed6 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.config.base-path/greenwood.config.js @@ -0,0 +1,8 @@ +import { greenwoodPluginAdapterNetlify } from '../../../src/index.js'; + +export default { + basePath: '/my-app', + plugins: [ + greenwoodPluginAdapterNetlify() + ] +}; \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.config.base-path/src/api/greeting.js b/packages/plugin-adapter-netlify/test/cases/build.config.base-path/src/api/greeting.js new file mode 100644 index 000000000..5a0291413 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.config.base-path/src/api/greeting.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: `Hello ${name}!` }; + + return new Response(JSON.stringify(body), { + headers: new Headers({ + 'Content-Type': 'application/json' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.config.base-path/src/pages/users.js b/packages/plugin-adapter-netlify/test/cases/build.config.base-path/src/pages/users.js new file mode 100644 index 000000000..0d7f2745d --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.config.base-path/src/pages/users.js @@ -0,0 +1,24 @@ +export default class UsersPage extends HTMLElement { + async connectedCallback() { + const users = [{ + name: 'Foo', + thumbnail: 'foo.jpg' + }]; + const html = users.map(user => { + const { name, imageUrl } = user; + + return ` +
+

${name}

+ +
+ `; + }).join(''); + + this.innerHTML = ` + < Back +

List of Users: ${users.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js b/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js new file mode 100644 index 000000000..96db93136 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js @@ -0,0 +1,377 @@ +/* + * Use Case + * Run Greenwood with the Netlify adapter plugin. + * + * User Result + * Should generate a static Greenwood build with serverless and edge functions output. + * + * User Command + * greenwood build + * + * User Config + * import { greenwoodPluginAdapterNetlify } from '@greenwood/plugin-adapter-netlify'; + * + * { + * plugins: [{ + * greenwoodPluginAdapterNetlify() + * }] + * } + * + * User Workspace + * package.json + * src/ + * api/ + * fragment.js + * greeting.js + * search.js + * submit-form-data.js + * submit-json.js + * components/ + * card.js + * pages/ + * artists.js + * post.js + * users.js + * services/ + * artists.js + * greeting.js + */ +import chai from 'chai'; +import fs from 'fs/promises'; +import glob from 'glob-promise'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; +import { normalizePathnameForWindows } from '../../../../cli/src/lib/resource-utils.js'; +import extract from 'extract-zip'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Netlify Adapter plugin output'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const netlifyFunctionsOutputUrl = new URL('./netlify/functions/', import.meta.url); + const hostname = 'http://www.example.com'; + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + }); + + describe('Default Output', function() { + let zipFiles; + let redirectsFile; + + before(async function() { + zipFiles = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), '*.zip')); + redirectsFile = await glob.promise(path.join(outputPath, 'public/_redirects')); + }); + + it('', function() { + expect(zipFiles.length).to.be.equal(8); + }); + + it('should output the expected number of serverless function API zip files', function() { + expect(zipFiles.filter(file => path.basename(file).startsWith('api-')).length).to.be.equal(5); + }); + + it('should output the expected number of serverless function SSR page zip files', function() { + expect(zipFiles.filter(file => !path.basename(file).startsWith('api-')).length).to.be.equal(3); + }); + + it('should output a _redirects file', function() { + expect(redirectsFile.length).to.be.equal(1); + }); + }); + + describe('Greeting API Route adapter', function() { + let apiFunctions; + + before(async function() { + apiFunctions = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), 'api-greeting.zip')); + }); + + it('should output one API route as a serverless function zip file', function() { + expect(apiFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const param = 'Greenwood'; + const name = path.basename(apiFunctions[0]).replace('.zip', ''); + + await extract(apiFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: `${hostname}/api/greeting?name=${param}`, + httpMethod: 'GET' + }, {}); + const { statusCode, body, headers } = response; + + expect(statusCode).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('application/json'); + expect(JSON.parse(body).message).to.be.equal(`Hello ${param}!`); + }); + }); + + describe('Fragments API Route adapter', function() { + let apiFunctions; + + before(async function() { + apiFunctions = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), 'api-fragment.zip')); + }); + + it('should output one API route as a serverless function zip file', function() { + expect(apiFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const param = 'Greenwood'; + const name = path.basename(apiFunctions[0]).replace('.zip', ''); + + await extract(apiFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: `${hostname}/api/greeting?name=${param}`, + httpMethod: 'GET' + }, {}); + const { statusCode, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('app-card'); + + expect(statusCode).to.be.equal(200); + expect(cardTags.length).to.be.equal(2); + expect(headers.get('content-type')).to.be.equal('text/html'); + }); + }); + + describe('Submit JSON API Route adapter', function() { + let apiFunctions; + + before(async function() { + apiFunctions = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), 'api-submit-json.zip')); + }); + + it('should output one API route as a serverless function zip file', function() { + expect(apiFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const param = 'Greenwood'; + const name = path.basename(apiFunctions[0]).replace('.zip', ''); + + await extract(apiFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: `${hostname}/api/submit-json`, + body: { name: param }, + httpMethod: 'POST', + headers: { + 'content-type': 'application/json' + } + }, {}); + const { statusCode, body, headers } = response; + + expect(statusCode).to.be.equal(200); + expect(JSON.parse(body).message).to.be.equal(`Thank you ${param} for your submission!`); + expect(headers.get('Content-Type')).to.be.equal('application/json'); + expect(headers.get('x-secret')).to.be.equal('1234'); + }); + }); + + describe('Submit FormData API Route adapter', function() { + let apiFunctions; + + before(async function() { + apiFunctions = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), 'api-submit-form-data.zip')); + }); + + it('should output one API route as a serverless function zip file', function() { + expect(apiFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const param = 'Greenwood'; + const name = path.basename(apiFunctions[0]).replace('.zip', ''); + + await extract(apiFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: `${hostname}/api/submit-form-data`, + body: `name=${param}`, + httpMethod: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }, {}); + const { statusCode, body, headers } = response; + + expect(statusCode).to.be.equal(200); + expect(body).to.be.equal(`Thank you ${param} for your submission!`); + expect(headers.get('Content-Type')).to.be.equal('text/html'); + }); + }); + + describe('Artists SSR Page adapter', function() { + const count = 2; + let pageFunctions; + + before(async function() { + pageFunctions = (await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), '*.zip'))) + .filter(zipFile => path.basename(zipFile).startsWith('artists')); + }); + + it('should output one SSR page as a serverless function zip file', function() { + expect(pageFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const name = path.basename(pageFunctions[0]).replace('.zip', ''); + + await extract(pageFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: `${hostname}/artists/`, + httpMethod: 'GET' + }, {}); + const { statusCode, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('body > app-card'); + const headings = dom.window.document.querySelectorAll('body > h1'); + + expect(statusCode).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('text/html'); + expect(cardTags.length).to.be.equal(count); + expect(headings.length).to.be.equal(1); + expect(headings[0].textContent).to.be.equal(`List of Artists: ${count}`); + }); + }); + + describe('Users SSR Page adapter', function() { + let pageFunctions; + + before(async function() { + pageFunctions = (await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), '*.zip'))) + .filter(zipFile => path.basename(zipFile).startsWith('users')); + }); + + it('should output one SSR page as a serverless function zip file', function() { + expect(pageFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const name = path.basename(pageFunctions[0]).replace('.zip', ''); + const count = 1; + + await extract(pageFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: `${hostname}/users/`, + httpMethod: 'GET' + }, {}); + const { statusCode, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('body > app-card'); + const headings = dom.window.document.querySelectorAll('body > h1'); + + expect(statusCode).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('text/html'); + expect(cardTags.length).to.be.equal(count); + expect(headings.length).to.be.equal(1); + expect(headings[0].textContent).to.be.equal(`List of Users: ${count}`); + }); + }); + + describe('Post SSR Page adapter', function() { + let pageFunctions; + + before(async function() { + pageFunctions = (await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), '*.zip'))) + .filter(zipFile => path.basename(zipFile).startsWith('post')); + }); + + it('should output one SSR page as a serverless function zip file', function() { + expect(pageFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const name = path.basename(pageFunctions[0]).replace('.zip', ''); + const postId = 1; + + await extract(pageFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: `${hostname}/post/?id=${postId}`, + httpMethod: 'GET' + }, {}); + + const { statusCode, body, headers } = response; + const dom = new JSDOM(body); + const headingOne = dom.window.document.querySelectorAll('body > h1'); + const headingTwo = dom.window.document.querySelectorAll('body > h2'); + const paragraph = dom.window.document.querySelectorAll('body > p'); + + expect(statusCode).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('text/html'); + + expect(headingOne.length).to.be.equal(1); + expect(headingTwo.length).to.be.equal(1); + expect(paragraph.length).to.be.equal(1); + + expect(headingOne[0].textContent).to.be.equal(`Fetched Post ID: ${postId}`); + expect(headingTwo[0].textContent).to.not.be.undefined; + expect(paragraph[0].textContent).to.not.be.undefined; + }); + }); + + describe('_redirects file contents', function() { + let redirectsFileContents; + + before(async function() { + redirectsFileContents = await fs.readFile(path.join(outputPath, 'public/_redirects'), 'utf-8'); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + expect(redirectsFileContents).to.be.equal( +`/artists/ /.netlify/functions/artists 200 +/post/ /.netlify/functions/post 200 +/users/ /.netlify/functions/users 200 +/api/* /.netlify/functions/api-:splat 200` + ); + }); + }); + }); + + after(function() { + runner.teardown([ + path.join(outputPath, 'netlify'), + ...getOutputTeardownFiles(outputPath) + ]); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/greenwood.config.js b/packages/plugin-adapter-netlify/test/cases/build.default/greenwood.config.js new file mode 100644 index 000000000..ed54e5737 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/greenwood.config.js @@ -0,0 +1,7 @@ +import { greenwoodPluginAdapterNetlify } from '../../../src/index.js'; + +export default { + plugins: [ + greenwoodPluginAdapterNetlify() + ] +}; \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/api/fragment.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/fragment.js new file mode 100644 index 000000000..c00251f91 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/fragment.js @@ -0,0 +1,27 @@ +import { renderFromHTML } from 'wc-compiler'; +import { getArtists } from '../services/artists.js'; + +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const offset = params.has('offset') ? parseInt(params.get('offset'), 10) : null; + const headers = new Headers({ 'Content-Type': 'text/html' }); + const artists = getArtists(offset); + const { html } = await renderFromHTML(` + ${ + artists.map((item, idx) => { + const { name, imageUrl } = item; + + return ` + + `; + }).join('') + } + `, [ + new URL('../components/card.js', import.meta.url) + ]); + + return new Response(html, { headers }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/api/greeting.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/greeting.js new file mode 100644 index 000000000..c5b2f9ded --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/greeting.js @@ -0,0 +1,14 @@ +import { getMessage } from '../services/message.js'; + +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: getMessage(name) }; + const headers = new Headers(); + + headers.append('Content-Type', 'application/json'); + + return new Response(JSON.stringify(body), { + headers + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/api/search.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/search.js new file mode 100644 index 000000000..959eb2c22 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/search.js @@ -0,0 +1,41 @@ +import { renderFromHTML } from 'wc-compiler'; +import { getArtists } from '../services/artists.js'; + +export async function handler(request) { + const formData = await request.formData(); + const term = formData.has('term') ? formData.get('term') : ''; + const artists = (await getArtists()) + .filter((artist) => { + return term !== '' && artist.name.toLowerCase().includes(term.toLowerCase()); + }); + let body = ''; + + if (artists.length === 0) { + body = 'No results found.'; + } else { + const { html } = await renderFromHTML(` + ${ + artists.map((item, idx) => { + const { name, imageUrl } = item; + + return ` + + `; + }).join('') + } + `, [ + new URL('../components/card.js', import.meta.url) + ]); + + body = html; + } + + return new Response(body, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-form-data.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-form-data.js new file mode 100644 index 000000000..5a4716f26 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-form-data.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const formData = await request.formData(); + const name = formData.get('name'); + const body = `Thank you ${name} for your submission!`; + + return new Response(body, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-json.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-json.js new file mode 100644 index 000000000..391443e10 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-json.js @@ -0,0 +1,14 @@ +export async function handler(request) { + const formData = await request.json(); + const { name } = formData; + const body = { + message: `Thank you ${name} for your submission!` + }; + + return new Response(JSON.stringify(body), { + headers: new Headers({ + 'Content-Type': 'application/json', + 'x-secret': 1234 + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/components/card.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/components/card.js new file mode 100644 index 000000000..db51913ce --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/components/card.js @@ -0,0 +1,27 @@ +export default class Card extends HTMLElement { + + selectArtist() { + alert(`selected artist is => ${this.getAttribute('title')}!`); + } + + connectedCallback() { + if (!this.shadowRoot) { + const thumbnail = this.getAttribute('thumbnail'); + const title = this.getAttribute('title'); + const template = document.createElement('template'); + + template.innerHTML = ` +
+

${title}

+ + +
+
+ `; + this.attachShadow({ mode: 'open' }); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + } + } +} + +customElements.define('app-card', Card); \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/pages/artists.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/pages/artists.js new file mode 100644 index 000000000..9a4887b16 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/pages/artists.js @@ -0,0 +1,25 @@ +import '../components/card.js'; +import { getArtists } from '../services/artists.js'; + +export default class ArtistsPage extends HTMLElement { + async connectedCallback() { + const artists = getArtists(); + const html = artists.map(artist => { + const { name, imageUrl } = artist; + + return ` + + + `; + }).join(''); + + this.innerHTML = ` + < Back +

List of Artists: ${artists.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/pages/post.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/pages/post.js new file mode 100644 index 000000000..a5d7d10d6 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/pages/post.js @@ -0,0 +1,20 @@ +export default class PostPage extends HTMLElement { + constructor(request) { + super(); + + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + this.postId = params.get('id'); + } + + async connectedCallback() { + const { postId } = this; + const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`).then(resp => resp.json()); + const { id, title, body } = post; + + this.innerHTML = ` +

Fetched Post ID: ${id}

+

${title}

+

${body}

+ `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/pages/users.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/pages/users.js new file mode 100644 index 000000000..007d96cf5 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/pages/users.js @@ -0,0 +1,27 @@ +import '../components/card.js'; + +export default class UsersPage extends HTMLElement { + async connectedCallback() { + const users = [{ + name: 'Foo', + thumbnail: 'foo.jpg' + }]; + const html = users.map(user => { + const { name, imageUrl } = user; + + return ` + + + `; + }).join(''); + + this.innerHTML = ` + < Back +

List of Users: ${users.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/services/artists.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/services/artists.js new file mode 100644 index 000000000..9d32ec97b --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/services/artists.js @@ -0,0 +1,11 @@ +function getArtists() { + return [{ + name: 'Analog', + imageUrl: 'analog.png' + }, { + name: 'Fave', + imageUrl: 'fave.png' + }]; +} + +export { getArtists }; \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/services/message.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/services/message.js new file mode 100644 index 000000000..3085719e8 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/services/message.js @@ -0,0 +1,7 @@ +function getMessage(name) { + return `Hello ${name}!`; +} + +export { + getMessage +}; \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/README.md b/packages/plugin-adapter-vercel/README.md new file mode 100644 index 000000000..d8f3458a2 --- /dev/null +++ b/packages/plugin-adapter-vercel/README.md @@ -0,0 +1,64 @@ +# @greenwood/plugin-adapter-vercel + +## Overview +Enables usage of Vercel Serverless runtimes for API routes and SSR pages. + +> This package assumes you already have `@greenwood/cli` installed. + +## Features + +In addition to publishing a project's static assets to the Vercel's CDN, this plugin adapts Greenwood [API routes](https://www.greenwoodjs.io/docs/api-routes/) and [SSR pages](https://www.greenwoodjs.io/docs/server-rendering/) into Vercel [Serverless functions](https://vercel.com/docs/concepts/functions/serverless-functions) using their [Build Output API](https://vercel.com/docs/build-output-api/v3). + +> _**Note:** You can see a working example of this plugin [here](https://github.com/ProjectEvergreen/greenwood-demo-adapter-vercel)_. + + +## Installation +You can use your favorite JavaScript package manager to install this package. + +_examples:_ +```bash +# npm +npm install @greenwood/plugin-adapter-vercel --save-dev + +# yarn +yarn add @greenwood/plugin-adapter-vercel --dev +``` + +You will then want to create a _vercel.json_ file, customized to match your project. Assuming you have an npm script called `build` +```json +{ + "scripts": { + "build": "greenwood build" + } +} +``` + +This would be the minimum _vercel.json_ configuration you would need +```json +{ + "buildCommand": "npm run build" +} +``` + +## Usage +Add this plugin to your _greenwood.config.js_. + +```javascript +import { greenwoodPluginAdapterVercel } from '@greenwood/plugin-adapter-vercel'; + +export default { + ... + + plugins: [ + greenwoodPluginAdapterVercel() + ] +} +``` + + +## Caveats +1. [Edge runtime](https://vercel.com/docs/concepts/functions/edge-functions) is not supported ([yet](https://github.com/ProjectEvergreen/greenwood/issues/1141)). +1. The Vercel CLI (`vercel dev`) is not compatible with Build Output v3. + ```sh + Error: Detected Build Output v3 from "npm run build", but it is not supported for `vercel dev`. Please set the Development Command in your Project Settings. + ``` \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/package.json b/packages/plugin-adapter-vercel/package.json new file mode 100644 index 000000000..133c0c2e9 --- /dev/null +++ b/packages/plugin-adapter-vercel/package.json @@ -0,0 +1,32 @@ +{ + "name": "@greenwood/plugin-adapter-vercel", + "version": "0.29.0-alpha.6", + "description": "A Greenwood plugin for supporting Vercel serverless and edge runtimes.", + "repository": "https://github.com/ProjectEvergreen/greenwood", + "homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-adapter-vercel", + "author": "Owen Buckley ", + "license": "MIT", + "keywords": [ + "Greenwood", + "Static Site Generator", + "SSR", + "Full Stack Web Development", + "Vercel", + "Serverless", + "Edge" + ], + "main": "src/index.js", + "type": "module", + "files": [ + "src/" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@greenwood/cli": "^0.28.0" + }, + "devDependencies": { + "@greenwood/cli": "^0.29.0-alpha.6" + } +} diff --git a/packages/plugin-adapter-vercel/src/index.js b/packages/plugin-adapter-vercel/src/index.js new file mode 100644 index 000000000..77602ae88 --- /dev/null +++ b/packages/plugin-adapter-vercel/src/index.js @@ -0,0 +1,189 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { checkResourceExists } from '@greenwood/cli/src/lib/resource-utils.js'; + +// https://vercel.com/docs/functions/serverless-functions/runtimes/node-js#node.js-helpers +function generateOutputFormat(id, type) { + const handlerAlias = '$handler'; + const path = type === 'page' + ? `__${id}` + : id; + + return ` + import { handler as ${handlerAlias} } from './${path}.js'; + + export default async function handler (request, response) { + const { body, url, headers = {}, method } = request; + const contentType = headers['content-type'] || ''; + let format = body; + + if (['GET', 'HEAD'].includes(method.toUpperCase())) { + format = null + } else if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = new FormData(); + + for (const key of Object.keys(body)) { + formData.append(key, body[key]); + } + + // when using FormData, let Request set the correct headers + // or else it will come out as multipart/form-data + // https://stackoverflow.com/a/43521052/417806 + format = formData; + delete headers['content-type']; + } else if(contentType.includes('application/json')) { + format = JSON.stringify(body); + } + + const req = new Request(new URL(url, \`http://\${headers.host}\`), { + body: format, + headers: new Headers(headers), + method + }); + const res = await ${handlerAlias}(req); + + res.headers.forEach((value, key) => { + response.setHeader(key, value); + }); + response.status(res.status); + response.send(await res.text()); + } + `; +} + +async function setupFunctionBuildFolder(id, outputType, outputRoot) { + const outputFormat = generateOutputFormat(id, outputType); + + await fs.mkdir(outputRoot, { recursive: true }); + await fs.writeFile(new URL('./index.js', outputRoot), outputFormat); + await fs.writeFile(new URL('./package.json', outputRoot), JSON.stringify({ + type: 'module' + })); + await fs.writeFile(new URL('./.vc-config.json', outputRoot), JSON.stringify({ + runtime: 'nodejs18.x', + handler: 'index.js', + launcherType: 'Nodejs', + shouldAddHelpers: true + })); +} + +async function vercelAdapter(compilation) { + const { outputDir, projectDirectory } = compilation.context; + const { basePath } = compilation.config; + const adapterOutputUrl = new URL('./.vercel/output/functions/', projectDirectory); + const ssrPages = compilation.graph.filter(page => page.isSSR); + const apiRoutes = compilation.manifest.apis; + + if (!await checkResourceExists(adapterOutputUrl)) { + await fs.mkdir(adapterOutputUrl, { recursive: true }); + } + + await fs.writeFile(new URL('./.vercel/output/config.json', projectDirectory), JSON.stringify({ + 'version': 3 + })); + + const files = await fs.readdir(outputDir); + const isExecuteRouteModule = files.find(file => file.startsWith('execute-route-module')); + + for (const page of ssrPages) { + const outputType = 'page'; + const { id } = page; + const outputRoot = new URL(`./${basePath}/${id}.func/`, adapterOutputUrl); + + await setupFunctionBuildFolder(id, outputType, outputRoot); + + await fs.cp( + new URL(`./_${id}.js`, outputDir), + new URL(`./_${id}.js`, outputRoot), + { recursive: true } + ); + + await fs.cp( + new URL(`./__${id}.js`, outputDir), + new URL(`./__${id}.js`, outputRoot), + { recursive: true } + ); + + // TODO quick hack to make serverless pages are fully self-contained + // for example, execute-route-module.js will only get code split if there are more than one SSR pages + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + if (isExecuteRouteModule) { + await fs.cp( + new URL(`./${isExecuteRouteModule}`, outputDir), + new URL(`./${isExecuteRouteModule}`, outputRoot) + ); + } + + // TODO how to track SSR resources that get dumped out in the public directory? + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + const ssrPageAssets = (await fs.readdir(outputDir)) + .filter(file => !path.basename(file).startsWith('_') + && !path.basename(file).startsWith('execute') + && path.basename(file).endsWith('.js') + ); + + for (const asset of ssrPageAssets) { + await fs.cp( + new URL(`./${asset}`, outputDir), + new URL(`./${asset}`, outputRoot), + { recursive: true } + ); + } + } + + for (const [key] of apiRoutes) { + const outputType = 'api'; + const id = key.replace(`${basePath}/api/`, ''); + const outputRoot = new URL(`./${basePath}/api/${id}.func/`, adapterOutputUrl); + + await setupFunctionBuildFolder(id, outputType, outputRoot); + + // TODO ideally all functions would be self contained + // https://github.com/ProjectEvergreen/greenwood/issues/1118 + await fs.cp( + new URL(`./api/${id}.js`, outputDir), + new URL(`./${id}.js`, outputRoot), + { recursive: true } + ); + + if (await checkResourceExists(new URL('./api/assets/', outputDir))) { + await fs.cp( + new URL('./api/assets/', outputDir), + new URL('./assets/', outputRoot), + { recursive: true } + ); + } + + const ssrApiAssets = (await fs.readdir(new URL('./api/', outputDir))) + .filter(file => new RegExp(/^[\w][\w-]*\.[a-zA-Z0-9]{4,20}\.[\w]{2,4}$/).test(path.basename(file))); + + for (const asset of ssrApiAssets) { + await fs.cp( + new URL(`./${asset}`, new URL('./api/', outputDir)), + new URL(`./${asset}`, outputRoot), + { recursive: true } + ); + } + } + + // static assets / build + await fs.cp( + outputDir, + new URL('./.vercel/output/static/', projectDirectory), + { + recursive: true + } + ); +} + +const greenwoodPluginAdapterVercel = (options = {}) => [{ + type: 'adapter', + name: 'plugin-adapter-vercel', + provider: (compilation) => { + return async () => { + await vercelAdapter(compilation, options); + }; + } +}]; + +export { greenwoodPluginAdapterVercel }; \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.config.base-path/build.config.base-path.spec.js b/packages/plugin-adapter-vercel/test/cases/build.config.base-path/build.config.base-path.spec.js new file mode 100644 index 000000000..f0c82987b --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.config.base-path/build.config.base-path.spec.js @@ -0,0 +1,191 @@ +/* + * Use Case + * Run Greenwood with the Vercel adapter plugin and custom base path. + * + * User Result + * Should generate a static Greenwood build with serverless and edge functions output. + * + * User Command + * greenwood build + * + * User Config + * import { greenwoodPluginAdapterVercel } from '@greenwood/plugin-adapter-vercel'; + * + * { + * baseOath: '/my-app', + * plugins: [{ + * greenwoodPluginAdapterVercel() + * }] + * } + * + * User Workspace + * package.json + * src/ + * api/ + * greeting.js + * pages/ + * users.js + */ +import chai from 'chai'; +import fs from 'fs/promises'; +import glob from 'glob-promise'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { checkResourceExists } from '../../../../cli/src/lib/resource-utils.js'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { normalizePathnameForWindows } from '../../../../cli/src/lib/resource-utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Vercel Adapter plugin output and custom base path'; + const basePath = '/my-app'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const vercelOutputFolder = new URL('./.vercel/output/', import.meta.url); + const vercelFunctionsOutputUrl = new URL('./functions/', vercelOutputFolder); + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + }); + + describe('Default Output', function() { + let configFile; + let functionFolders; + + before(async function() { + configFile = await fs.readFile(new URL('./config.json', vercelOutputFolder), 'utf-8'); + functionFolders = await glob.promise(path.join(normalizePathnameForWindows(vercelFunctionsOutputUrl), '**/*.func')); + }); + + it('should output the expected number of serverless function output folders', function() { + expect(functionFolders.length).to.be.equal(2); + }); + + it('should output the expected configuration file for the build output', function() { + expect(configFile).to.be.equal('{"version":3}'); + }); + + it('should output the expected package.json for each serverless function', function() { + functionFolders.forEach(async (folder) => { + const packageJson = await fs.readFile(new URL('./package.json', `file://${folder}/`), 'utf-8'); + + expect(packageJson).to.be.equal('{"type":"module"}'); + }); + }); + + it('should output the expected .vc-config.json for each serverless function', function() { + functionFolders.forEach(async (folder) => { + const packageJson = await fs.readFile(new URL('./vc-config.json', `file://${folder}/`), 'utf-8'); + + expect(packageJson).to.be.equal('{"runtime":"nodejs18.x","handler":"index.js","launcherType":"Nodejs","shouldAddHelpers":true}'); + }); + }); + }); + + describe('Static directory output', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const publicFiles = await glob.promise(path.join(outputPath, 'public/**/**')); + + for (const file of publicFiles) { + const buildOutputDestination = file.replace(path.join(outputPath, 'public'), path.join(vercelOutputFolder.pathname, 'static')); + const itExists = await checkResourceExists(new URL(`file://${buildOutputDestination}`)); + + expect(itExists).to.be.equal(true); + } + }); + }); + + describe('Greeting API Route adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL(`./${basePath}/api/greeting.func/index.js`, vercelFunctionsOutputUrl))).default; + const param = 'Greenwood'; + const response = { + headers: new Headers() + }; + + await handler({ + url: `http://www.example.com/${basePath}/api/greeting?name=${param}`, + headers: { + host: 'http://www.example.com' + }, + method: 'GET' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + const { status, body, headers } = response; + + expect(status).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('application/json'); + expect(JSON.parse(body).message).to.be.equal(`Hello ${param}!`); + }); + }); + + describe('Users SSR Page adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL(`./${basePath}/users.func/index.js`, vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + const count = 1; + + await handler({ + url: `http://www.example.com/${basePath}/users/`, + headers: { + host: 'http://www.example.com' + }, + method: 'GET' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + + const { status, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('body > article'); + const headings = dom.window.document.querySelectorAll('body > h1'); + + expect(status).to.be.equal(200); + expect(cardTags.length).to.be.equal(count); + expect(headings.length).to.be.equal(1); + expect(headings[0].textContent).to.be.equal(`List of Users: ${count}`); + expect(headers.get('content-type')).to.be.equal('text/html'); + }); + }); + }); + + after(function() { + runner.teardown([ + path.join(outputPath, '.vercel'), + ...getOutputTeardownFiles(outputPath) + ]); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.config.base-path/greenwood.config.js b/packages/plugin-adapter-vercel/test/cases/build.config.base-path/greenwood.config.js new file mode 100644 index 000000000..b4d1a044f --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.config.base-path/greenwood.config.js @@ -0,0 +1,8 @@ +import { greenwoodPluginAdapterVercel } from '../../../src/index.js'; + +export default { + basePath: '/my-app', + plugins: [ + greenwoodPluginAdapterVercel() + ] +}; \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.config.base-path/src/api/greeting.js b/packages/plugin-adapter-vercel/test/cases/build.config.base-path/src/api/greeting.js new file mode 100644 index 000000000..5a0291413 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.config.base-path/src/api/greeting.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: `Hello ${name}!` }; + + return new Response(JSON.stringify(body), { + headers: new Headers({ + 'Content-Type': 'application/json' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.config.base-path/src/pages/users.js b/packages/plugin-adapter-vercel/test/cases/build.config.base-path/src/pages/users.js new file mode 100644 index 000000000..0d7f2745d --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.config.base-path/src/pages/users.js @@ -0,0 +1,24 @@ +export default class UsersPage extends HTMLElement { + async connectedCallback() { + const users = [{ + name: 'Foo', + thumbnail: 'foo.jpg' + }]; + const html = users.map(user => { + const { name, imageUrl } = user; + + return ` +
+

${name}

+ +
+ `; + }).join(''); + + this.innerHTML = ` + < Back +

List of Users: ${users.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js new file mode 100644 index 000000000..c553414e6 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js @@ -0,0 +1,393 @@ +/* + * Use Case + * Run Greenwood with the Vercel adapter plugin. + * + * User Result + * Should generate a static Greenwood build with serverless and edge functions output. + * + * User Command + * greenwood build + * + * User Config + * import { greenwoodPluginAdapterVercel } from '@greenwood/plugin-adapter-vercel'; + * + * { + * plugins: [{ + * greenwoodPluginAdapterVercel() + * }] + * } + * + * User Workspace + * package.json + * src/ + * api/ + * fragment.js + * greeting.js + * search.js + * submit-form-data.js + * submit-json.js + * components/ + * card.js + * pages/ + * artists.js + * post.js + * users.js + * services/ + * artists.js + * greeting.js + */ +import chai from 'chai'; +import fs from 'fs/promises'; +import glob from 'glob-promise'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { checkResourceExists } from '../../../../cli/src/lib/resource-utils.js'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { normalizePathnameForWindows } from '../../../../cli/src/lib/resource-utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Vercel Adapter plugin output'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const vercelOutputFolder = new URL('./.vercel/output/', import.meta.url); + const vercelFunctionsOutputUrl = new URL('./functions/', vercelOutputFolder); + const hostname = 'http://www.example.com'; + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + }); + + describe('Default Output', function() { + let configFile; + let functionFolders; + + before(async function() { + configFile = await fs.readFile(new URL('./config.json', vercelOutputFolder), 'utf-8'); + functionFolders = await glob.promise(path.join(normalizePathnameForWindows(vercelFunctionsOutputUrl), '**/*.func')); + }); + + it('should output the expected number of serverless function output folders', function() { + expect(functionFolders.length).to.be.equal(8); + }); + + it('should output the expected configuration file for the build output', function() { + expect(configFile).to.be.equal('{"version":3}'); + }); + + it('should output the expected package.json for each serverless function', function() { + functionFolders.forEach(async (folder) => { + const packageJson = await fs.readFile(new URL('./package.json', `file://${folder}/`), 'utf-8'); + + expect(packageJson).to.be.equal('{"type":"module"}'); + }); + }); + + it('should output the expected .vc-config.json for each serverless function', function() { + functionFolders.forEach(async (folder) => { + const packageJson = await fs.readFile(new URL('./vc-config.json', `file://${folder}/`), 'utf-8'); + + expect(packageJson).to.be.equal('{"runtime":"nodejs18.x","handler":"index.js","launcherType":"Nodejs","shouldAddHelpers":true}'); + }); + }); + }); + + describe('Static directory output', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const publicFiles = await glob.promise(path.join(outputPath, 'public/**/**')); + + for (const file of publicFiles) { + const buildOutputDestination = file.replace(path.join(outputPath, 'public'), path.join(vercelOutputFolder.pathname, 'static')); + const itExists = await checkResourceExists(new URL(`file://${buildOutputDestination}`)); + + expect(itExists).to.be.equal(true); + } + }); + }); + + describe('Greeting API Route adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./api/greeting.func/index.js', vercelFunctionsOutputUrl))).default; + const param = 'Greenwood'; + const response = { + headers: new Headers() + }; + + await handler({ + url: `${hostname}/api/greeting?name=${param}`, + headers: { + host: hostname + }, + method: 'GET' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + const { status, body, headers } = response; + + expect(status).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('application/json'); + expect(JSON.parse(body).message).to.be.equal(`Hello ${param}!`); + }); + }); + + describe('Fragments API Route adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./api/fragment.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + + await handler({ + url: `${hostname}/api/fragment`, + headers: { + host: hostname + }, + method: 'GET' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + const { status, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('app-card'); + + expect(status).to.be.equal(200); + expect(cardTags.length).to.be.equal(2); + expect(headers.get('content-type')).to.be.equal('text/html'); + }); + }); + + describe('Submit JSON API Route adapter', function() { + const name = 'Greenwood'; + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./api/submit-json.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + + await handler({ + url: `${hostname}/api/submit-json`, + headers: { + 'host': hostname, + 'content-type': 'application/json' + }, + body: { name }, + method: 'POST' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + const { status, body, headers } = response; + + expect(status).to.be.equal(200); + expect(JSON.parse(body).message).to.be.equal(`Thank you ${name} for your submission!`); + expect(headers.get('Content-Type')).to.be.equal('application/json'); + expect(headers.get('x-secret')).to.be.equal('1234'); + }); + }); + + describe('Submit FormData JSON API Route adapter', function() { + const name = 'Greenwood'; + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./api/submit-form-data.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + + await handler({ + url: `${hostname}/api/submit-form-data`, + headers: { + 'host': hostname, + 'content-type': 'application/x-www-form-urlencoded' + }, + body: { name }, + method: 'POST' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + const { status, body, headers } = response; + + expect(status).to.be.equal(200); + expect(body).to.be.equal(`Thank you ${name} for your submission!`); + expect(headers.get('Content-Type')).to.be.equal('text/html'); + }); + }); + + describe('Artists SSR Page adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./artists.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + const count = 2; + + await handler({ + url: `${hostname}/artists`, + headers: { + host: hostname + }, + method: 'GET' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + + const { status, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('body > app-card'); + const headings = dom.window.document.querySelectorAll('body > h1'); + + expect(status).to.be.equal(200); + expect(cardTags.length).to.be.equal(count); + expect(headings.length).to.be.equal(1); + expect(headings[0].textContent).to.be.equal(`List of Artists: ${count}`); + expect(headers.get('content-type')).to.be.equal('text/html'); + }); + }); + + describe('Users SSR Page adapter', function() { + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./users.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + const count = 1; + + await handler({ + url: `${hostname}/users`, + headers: { + host: hostname + }, + method: 'GET' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + + const { status, body, headers } = response; + const dom = new JSDOM(body); + const cardTags = dom.window.document.querySelectorAll('body > app-card'); + const headings = dom.window.document.querySelectorAll('body > h1'); + + expect(status).to.be.equal(200); + expect(cardTags.length).to.be.equal(count); + expect(headings.length).to.be.equal(1); + expect(headings[0].textContent).to.be.equal(`List of Users: ${count}`); + expect(headers.get('content-type')).to.be.equal('text/html'); + }); + }); + + describe('Post SSR Page adapter', function() { + const postId = 1; + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./post.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + + await handler({ + url: `${hostname}/post/?id=${postId}`, + headers: { + host: hostname + }, + method: 'GET' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + + const { status, body, headers } = response; + const dom = new JSDOM(body); + const headingOne = dom.window.document.querySelectorAll('body > h1'); + const headingTwo = dom.window.document.querySelectorAll('body > h2'); + const paragraph = dom.window.document.querySelectorAll('body > p'); + + expect(status).to.be.equal(200); + expect(headers.get('content-type')).to.be.equal('text/html'); + + expect(headingOne.length).to.be.equal(1); + expect(headingTwo.length).to.be.equal(1); + expect(paragraph.length).to.be.equal(1); + + expect(headingOne[0].textContent).to.be.equal(`Fetched Post ID: ${postId}`); + expect(headingTwo[0].textContent).to.not.be.undefined; + expect(paragraph[0].textContent).to.not.be.undefined; + }); + }); + }); + + after(function() { + runner.teardown([ + path.join(outputPath, '.vercel'), + ...getOutputTeardownFiles(outputPath) + ]); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/greenwood.config.js b/packages/plugin-adapter-vercel/test/cases/build.default/greenwood.config.js new file mode 100644 index 000000000..3aba8de13 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/greenwood.config.js @@ -0,0 +1,7 @@ +import { greenwoodPluginAdapterVercel } from '../../../src/index.js'; + +export default { + plugins: [ + greenwoodPluginAdapterVercel() + ] +}; \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/api/fragment.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/fragment.js new file mode 100644 index 000000000..c00251f91 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/fragment.js @@ -0,0 +1,27 @@ +import { renderFromHTML } from 'wc-compiler'; +import { getArtists } from '../services/artists.js'; + +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const offset = params.has('offset') ? parseInt(params.get('offset'), 10) : null; + const headers = new Headers({ 'Content-Type': 'text/html' }); + const artists = getArtists(offset); + const { html } = await renderFromHTML(` + ${ + artists.map((item, idx) => { + const { name, imageUrl } = item; + + return ` + + `; + }).join('') + } + `, [ + new URL('../components/card.js', import.meta.url) + ]); + + return new Response(html, { headers }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/api/greeting.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/greeting.js new file mode 100644 index 000000000..c5b2f9ded --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/greeting.js @@ -0,0 +1,14 @@ +import { getMessage } from '../services/message.js'; + +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: getMessage(name) }; + const headers = new Headers(); + + headers.append('Content-Type', 'application/json'); + + return new Response(JSON.stringify(body), { + headers + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/api/search.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/search.js new file mode 100644 index 000000000..959eb2c22 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/search.js @@ -0,0 +1,41 @@ +import { renderFromHTML } from 'wc-compiler'; +import { getArtists } from '../services/artists.js'; + +export async function handler(request) { + const formData = await request.formData(); + const term = formData.has('term') ? formData.get('term') : ''; + const artists = (await getArtists()) + .filter((artist) => { + return term !== '' && artist.name.toLowerCase().includes(term.toLowerCase()); + }); + let body = ''; + + if (artists.length === 0) { + body = 'No results found.'; + } else { + const { html } = await renderFromHTML(` + ${ + artists.map((item, idx) => { + const { name, imageUrl } = item; + + return ` + + `; + }).join('') + } + `, [ + new URL('../components/card.js', import.meta.url) + ]); + + body = html; + } + + return new Response(body, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-form-data.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-form-data.js new file mode 100644 index 000000000..5a4716f26 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-form-data.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const formData = await request.formData(); + const name = formData.get('name'); + const body = `Thank you ${name} for your submission!`; + + return new Response(body, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-json.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-json.js new file mode 100644 index 000000000..391443e10 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-json.js @@ -0,0 +1,14 @@ +export async function handler(request) { + const formData = await request.json(); + const { name } = formData; + const body = { + message: `Thank you ${name} for your submission!` + }; + + return new Response(JSON.stringify(body), { + headers: new Headers({ + 'Content-Type': 'application/json', + 'x-secret': 1234 + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/components/card.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/components/card.js new file mode 100644 index 000000000..db51913ce --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/components/card.js @@ -0,0 +1,27 @@ +export default class Card extends HTMLElement { + + selectArtist() { + alert(`selected artist is => ${this.getAttribute('title')}!`); + } + + connectedCallback() { + if (!this.shadowRoot) { + const thumbnail = this.getAttribute('thumbnail'); + const title = this.getAttribute('title'); + const template = document.createElement('template'); + + template.innerHTML = ` +
+

${title}

+ + +
+
+ `; + this.attachShadow({ mode: 'open' }); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + } + } +} + +customElements.define('app-card', Card); \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/artists.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/artists.js new file mode 100644 index 000000000..9a4887b16 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/artists.js @@ -0,0 +1,25 @@ +import '../components/card.js'; +import { getArtists } from '../services/artists.js'; + +export default class ArtistsPage extends HTMLElement { + async connectedCallback() { + const artists = getArtists(); + const html = artists.map(artist => { + const { name, imageUrl } = artist; + + return ` + + + `; + }).join(''); + + this.innerHTML = ` + < Back +

List of Artists: ${artists.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/post.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/post.js new file mode 100644 index 000000000..a5d7d10d6 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/post.js @@ -0,0 +1,20 @@ +export default class PostPage extends HTMLElement { + constructor(request) { + super(); + + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + this.postId = params.get('id'); + } + + async connectedCallback() { + const { postId } = this; + const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`).then(resp => resp.json()); + const { id, title, body } = post; + + this.innerHTML = ` +

Fetched Post ID: ${id}

+

${title}

+

${body}

+ `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/users.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/users.js new file mode 100644 index 000000000..007d96cf5 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/pages/users.js @@ -0,0 +1,27 @@ +import '../components/card.js'; + +export default class UsersPage extends HTMLElement { + async connectedCallback() { + const users = [{ + name: 'Foo', + thumbnail: 'foo.jpg' + }]; + const html = users.map(user => { + const { name, imageUrl } = user; + + return ` + + + `; + }).join(''); + + this.innerHTML = ` + < Back +

List of Users: ${users.length}

+ ${html} + `; + } +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/services/artists.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/services/artists.js new file mode 100644 index 000000000..9d32ec97b --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/services/artists.js @@ -0,0 +1,11 @@ +function getArtists() { + return [{ + name: 'Analog', + imageUrl: 'analog.png' + }, { + name: 'Fave', + imageUrl: 'fave.png' + }]; +} + +export { getArtists }; \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/services/message.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/services/message.js new file mode 100644 index 000000000..3085719e8 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/services/message.js @@ -0,0 +1,7 @@ +function getMessage(name) { + return `Hello ${name}!`; +} + +export { + getMessage +}; \ No newline at end of file diff --git a/packages/plugin-babel/package.json b/packages/plugin-babel/package.json index aa00fb0fa..6abd46fbd 100644 --- a/packages/plugin-babel/package.json +++ b/packages/plugin-babel/package.json @@ -1,6 +1,6 @@ { "name": "@greenwood/plugin-babel", - "version": "0.28.5", + "version": "0.29.0-alpha.6", "description": "A Greenwood plugin for using Babel and applying it to your JavaScript.", "repository": "https://github.com/ProjectEvergreen/greenwood", "homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-babel", @@ -36,6 +36,6 @@ "@babel/plugin-proposal-class-properties": "^7.10.4", "@babel/plugin-proposal-private-methods": "^7.10.4", "@babel/runtime": "^7.10.4", - "@greenwood/cli": "^0.28.5" + "@greenwood/cli": "^0.29.0-alpha.6" } } diff --git a/packages/plugin-google-analytics/package.json b/packages/plugin-google-analytics/package.json index df5e3c6c6..d7c4ab9aa 100644 --- a/packages/plugin-google-analytics/package.json +++ b/packages/plugin-google-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@greenwood/plugin-google-analytics", - "version": "0.28.5", + "version": "0.29.0-alpha.6", "description": "A Greenwood plugin adding support for Google Analytics JavaScript tracker.", "repository": "https://github.com/ProjectEvergreen/greenwood", "homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-google-analytics", @@ -26,6 +26,6 @@ "@greenwood/cli": "^0.4.0" }, "devDependencies": { - "@greenwood/cli": "^0.28.5" + "@greenwood/cli": "^0.29.0-alpha.6" } } diff --git a/packages/plugin-google-analytics/test/cases/default/default.spec.js b/packages/plugin-google-analytics/test/cases/default/default.spec.js index 4db2a6af1..bfaba3b53 100644 --- a/packages/plugin-google-analytics/test/cases/default/default.spec.js +++ b/packages/plugin-google-analytics/test/cases/default/default.spec.js @@ -61,7 +61,7 @@ describe('Build Greenwood With: ', function() { before(async function() { const dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'index.html')); - const scriptTags = dom.window.document.querySelectorAll('head script'); + const scriptTags = Array.from(dom.window.document.querySelectorAll('head script')).filter(tag => !tag.getAttribute('data-gwd')); inlineScript = Array.prototype.slice.call(scriptTags).filter(script => { return !script.src && !script.getAttribute('data-state'); diff --git a/packages/plugin-google-analytics/test/cases/option-anonymous/option-anonymous.spec.js b/packages/plugin-google-analytics/test/cases/option-anonymous/option-anonymous.spec.js index b3035f1c3..3705dca7d 100644 --- a/packages/plugin-google-analytics/test/cases/option-anonymous/option-anonymous.spec.js +++ b/packages/plugin-google-analytics/test/cases/option-anonymous/option-anonymous.spec.js @@ -61,7 +61,7 @@ describe('Build Greenwood With: ', function() { before(async function() { const dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'index.html')); - const scriptTags = dom.window.document.querySelectorAll('head script'); + const scriptTags = Array.from(dom.window.document.querySelectorAll('head script')).filter(tag => !tag.getAttribute('data-gwd')); inlineScript = Array.prototype.slice.call(scriptTags).filter(script => { return !script.src && !script.getAttribute('data-state'); diff --git a/packages/plugin-graphql/README.md b/packages/plugin-graphql/README.md index dc4896c50..21f37bc41 100644 --- a/packages/plugin-graphql/README.md +++ b/packages/plugin-graphql/README.md @@ -1,23 +1,15 @@ # @greenwood/plugin-graphql ## Overview -A plugin for Greenwood to support using [GraphQL](https://graphql.org/) to query Greenwood's content graph. It runs [**apollo-server**](https://www.apollographql.com/docs/apollo-server/) on the backend and provides an [**@apollo/client** _"like"_](https://www.apollographql.com/docs/react/api/core/ApolloClient/#ApolloClient.readQuery) interface for the frontend that you can use. +A plugin for Greenwood to support using [GraphQL](https://graphql.org/) to query Greenwood's [content graph](https://www.greenwoodjs.io/docs/data/) with our optional [pre-made queries](https://www.greenwoodjs.io/docs/menus/). It runs [**apollo-server**](https://www.apollographql.com/docs/apollo-server/) on the backend at build time and provides a **"read-only"** [**@apollo/client** _"like"_](https://www.apollographql.com/docs/react/api/core/ApolloClient/#ApolloClient.readQuery) interface for the frontend that you can use. > This package assumes you already have `@greenwood/cli` installed. ## Caveats -As of now, this plugin can only be used in conjunction with our [Puppeteer rendering plugin](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-renderer-puppeteer). Please make sure you have it installed first to get the benefits of this plugin. - -```bash -# npm -npm install @greenwood/plugin-renderer-puppeteer --save-dev - -# yarn -yarn add @greenwood/plugin-renderer-puppeteer --dev -``` - -> _We are working on re-evaluating and improving our [data loading](https://github.com/ProjectEvergreen/greenwood/issues/952) and [rendering strategies](https://github.com/ProjectEvergreen/greenwood/issues/951) as part of our [1.0 release](https://github.com/ProjectEvergreen/greenwood/milestone/3)._ +As of now, this plugin requires some form of [prerendering](https://www.greenwoodjs.io/docs/server-rendering/#render-vs-prerender) either through: +1. Enabling [custom imports](https://www.greenwoodjs.io/docs/server-rendering/#custom-imports) +1. Installing the [Puppeteer renderer plugin](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-renderer-puppeteer). ## Installation @@ -32,15 +24,15 @@ yarn add @greenwood/plugin-graphql --dev ``` ## Usage -Add this plugin and the Puppeteer plugins to your _greenwood.config.js_. +Add this plugin to your _greenwood.config.js_ and configure with either `prerender: true` _or_ by adding the `greenwoodPluginRendererPuppeteer` plugin. ```javascript import { greenwoodPluginGraphQL } from '@greenwood/plugin-graphql'; -import { greenwoodPluginRendererPuppeteer } from '@greenwood/plugin-renderer-puppeteer'; +import { greenwoodPluginRendererPuppeteer } from '@greenwood/plugin-renderer-puppeteer'; // if using puppeteer export default { // ... - + prerender: true, // if using custom imports plugins: [ greenwoodPluginGraphQL(), greenwoodPluginRendererPuppeteer() @@ -52,8 +44,8 @@ export default { This will then allow you to use GraphQL to query your content from your client side. At build time, it will generate JSON files so that the data is still accessible statically. ```js -import client from '@greenwood/plugin-graphql/core/client'; -import MenuQuery from '@greenwood/plugin-graphql/queries/menu'; +import client from '@greenwood/plugin-graphql/src/core/client.js'; +import MenuQuery from '@greenwood/plugin-graphql/src/queries/menu.gql'; class HeaderComponent extends HTMLElement { constructor() { @@ -82,7 +74,7 @@ class HeaderComponent extends HTMLElement { ${menuItem.label} `; - }).join(); + }).join(''); return `
@@ -174,7 +166,7 @@ query($name: String!) { And then you can use it in your code as such: ```js -import client from '@greenwood/plugin-graphql/core/client'; +import client from '@greenwood/plugin-graphql/src/core/client.js'; import GalleryQuery from '../relative/path/to/data/queries/gallery.gql'; client.query({ diff --git a/packages/plugin-graphql/package.json b/packages/plugin-graphql/package.json index d058f0614..0d06a20b2 100644 --- a/packages/plugin-graphql/package.json +++ b/packages/plugin-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@greenwood/plugin-graphql", - "version": "0.28.5", + "version": "0.29.0-alpha.6", "description": "A plugin for using GraphQL for querying your content.", "repository": "https://github.com/ProjectEvergreen/greenwood", "homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-graphql", @@ -28,12 +28,11 @@ }, "dependencies": { "@apollo/client": "^3.7.14", - "@rollup/plugin-alias": "^3.1.2", "apollo-server": "^2.21.0", "graphql": "^15.5.0", "graphql-tag": "^2.10.1" }, "devDependencies": { - "@greenwood/cli": "^0.28.5" + "@greenwood/cli": "^0.29.0-alpha.6" } } diff --git a/packages/plugin-graphql/src/core/client.js b/packages/plugin-graphql/src/core/client.js index 0e6881c92..629d1a96a 100644 --- a/packages/plugin-graphql/src/core/client.js +++ b/packages/plugin-graphql/src/core/client.js @@ -1,4 +1,4 @@ -import { getQueryHash } from '@greenwood/plugin-graphql/core/common'; +import { getQueryHash } from './common.js'; const client = { query: (params) => { @@ -18,14 +18,15 @@ const client = { } }; -const APOLLO_STATE = window.__APOLLO_STATE__; // eslint-disable-line no-underscore-dangle +const APOLLO_STATE = globalThis.__APOLLO_STATE__; // eslint-disable-line no-underscore-dangle +const BASE_PATH = globalThis.__GWD_BASE_PATH__; // eslint-disable-line no-underscore-dangle const backupQuery = client.query; client.query = (params) => { if (APOLLO_STATE) { // __APOLLO_STATE__ defined, in production mode const queryHash = getQueryHash(params.query, params.variables); - const cachePath = `/${queryHash}-cache.json`; + const cachePath = `${BASE_PATH}/${queryHash}-cache.json`; return fetch(cachePath) .then(response => response.json()) diff --git a/packages/plugin-graphql/src/index.js b/packages/plugin-graphql/src/index.js index cf0c4cb00..05ac42787 100644 --- a/packages/plugin-graphql/src/index.js +++ b/packages/plugin-graphql/src/index.js @@ -3,16 +3,15 @@ import { graphqlServer } from './core/server.js'; import { mergeImportMap } from '@greenwood/cli/src/lib/walker-package-ranger.js'; import { ResourceInterface } from '@greenwood/cli/src/lib/resource-interface.js'; import { ServerInterface } from '@greenwood/cli/src/lib/server-interface.js'; -import rollupPluginAlias from '@rollup/plugin-alias'; const importMap = { '@greenwood/cli/src/lib/hashing-utils.js': '/node_modules/@greenwood/cli/src/lib/hashing-utils.js', - '@greenwood/plugin-graphql/core/client': '/node_modules/@greenwood/plugin-graphql/src/core/client.js', - '@greenwood/plugin-graphql/core/common': '/node_modules/@greenwood/plugin-graphql/src/core/common.js', - '@greenwood/plugin-graphql/queries/children': '/node_modules/@greenwood/plugin-graphql/src/queries/children.gql', - '@greenwood/plugin-graphql/queries/config': '/node_modules/@greenwood/plugin-graphql/src/queries/config.gql', - '@greenwood/plugin-graphql/queries/graph': '/node_modules/@greenwood/plugin-graphql/src/queries/graph.gql', - '@greenwood/plugin-graphql/queries/menu': '/node_modules/@greenwood/plugin-graphql/src/queries/menu.gql' + '@greenwood/plugin-graphql/src/core/client.js': '/node_modules/@greenwood/plugin-graphql/src/core/client.js', + '@greenwood/plugin-graphql/src/core/common.js': '/node_modules/@greenwood/plugin-graphql/src/core/common.js', + '@greenwood/plugin-graphql/src/queries/children.gql': '/node_modules/@greenwood/plugin-graphql/src/queries/children.gql', + '@greenwood/plugin-graphql/src/queries/config.gql': '/node_modules/@greenwood/plugin-graphql/src/queries/config.gql', + '@greenwood/plugin-graphql/src/queries/graph.gql': '/node_modules/@greenwood/plugin-graphql/src/queries/graph.gql', + '@greenwood/plugin-graphql/src/queries/menu.gql': '/node_modules/@greenwood/plugin-graphql/src/queries/menu.gql' }; class GraphQLResource extends ResourceInterface { @@ -89,21 +88,6 @@ const greenwoodPluginGraphQL = (options = {}) => { type: 'resource', name: 'plugin-graphql:resource', provider: (compilation) => new GraphQLResource(compilation, options) - }, { - type: 'rollup', - name: 'plugin-graphql:rollup', - provider: () => { - const aliasEntries = Object.keys(importMap).map(key => { - return { - find: key, - replacement: importMap[key].replace('/node_modules/', '') - }; - }); - - return [ - rollupPluginAlias({ entries: aliasEntries }) - ]; - } }]; }; diff --git a/packages/plugin-graphql/src/schema/graph.js b/packages/plugin-graphql/src/schema/graph.js index 437034368..0a7b0b616 100644 --- a/packages/plugin-graphql/src/schema/graph.js +++ b/packages/plugin-graphql/src/schema/graph.js @@ -2,6 +2,7 @@ import gql from 'graphql-tag'; const getMenuFromGraph = async (root, { name, pathname, orderBy }, context) => { const { graph } = context; + const { basePath } = context.config; let items = []; graph @@ -12,14 +13,11 @@ const getMenuFromGraph = async (root, { name, pathname, orderBy }, context) => { if (menu && menu.search(name) > -1) { if (pathname) { - // check we're querying only pages that contain base route - let baseRoute = pathname; - let baseRouteIndex = pathname.substring(1, pathname.length).indexOf('/'); - if (baseRouteIndex > -1) { - baseRoute = pathname.substring(0, baseRouteIndex + 1); - } + const normalizedRoute = basePath === '' + ? route + : route.replace(basePath, ''); - if (route.includes(baseRoute)) { + if (normalizedRoute.startsWith(pathname)) { items.push({ item: { route, label, index }, children }); } } else { @@ -87,13 +85,17 @@ const getChildrenFromParentRoute = async (root, query, context) => { const pages = []; const { parent } = query; const { graph } = context; + const { basePath } = context.config; graph .forEach((page) => { const { route, path } = page; - const root = route.split('/')[1]; + const normalizedRoute = basePath === '' + ? route + : route.replace(basePath, '/'); + const root = normalizedRoute.split('/')[1]; - if (root === parent && path.indexOf(`${parent}/index.md`) < 0) { + if (`/${root}` === parent && path.indexOf(`${parent}/index.md`) < 0) { pages.push(page); } }); diff --git a/packages/plugin-graphql/test/cases/develop.default/develop.default.spec.js b/packages/plugin-graphql/test/cases/develop.default/develop.default.spec.js index 84b016e8b..bb96f48e0 100644 --- a/packages/plugin-graphql/test/cases/develop.default/develop.default.spec.js +++ b/packages/plugin-graphql/test/cases/develop.default/develop.default.spec.js @@ -20,7 +20,6 @@ import chai from 'chai'; import { JSDOM } from 'jsdom'; import path from 'path'; -import request from 'request'; import { Runner } from 'gallinago'; import { fileURLToPath, URL } from 'url'; import { runSmokeTest } from '../../../../../test/smoke-test.js'; @@ -60,49 +59,33 @@ describe('Develop Greenwood With: ', function() { describe('Develop command import map for GraphQL', function() { let response = {}; + let data; let dom; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://127.0.0.1:${port}`, - headers: { - accept: 'text/html' - } - }, (err, res, body) => { - if (err) { - reject(); - } - - response = res; - - dom = new JSDOM(body); - resolve(); - }); - }); + response = await fetch(`${hostname}:${port}`); + data = await response.text(); + dom = new JSDOM(data); }); - it('should return a 200', function(done) { - expect(response.statusCode).to.equal(200); - - done(); + it('should return a 200', function() { + expect(response.status).to.equal(200); }); - it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); - done(); + it('should return the correct content type', function() { + expect(response.headers.get('content-type')).to.equal('text/html'); }); it('should return an import map shim + + + + + + + \ No newline at end of file diff --git a/packages/plugin-graphql/test/cases/qraphql-server/graphql-server.spec.js b/packages/plugin-graphql/test/cases/qraphql-server/graphql-server.spec.js index 3f1a0a883..2bcc15a43 100644 --- a/packages/plugin-graphql/test/cases/qraphql-server/graphql-server.spec.js +++ b/packages/plugin-graphql/test/cases/qraphql-server/graphql-server.spec.js @@ -15,7 +15,6 @@ * Greenwood default (src/) */ import chai from 'chai'; -import request from 'request'; import path from 'path'; import { Runner } from 'gallinago'; import { fileURLToPath, URL } from 'url'; @@ -26,7 +25,7 @@ describe('Develop Greenwood With: ', function() { const LABEL = 'GraphQL Server'; const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); const outputPath = fileURLToPath(new URL('.', import.meta.url)); - const hostname = '127.0.0.1'; + const hostname = 'localhost'; const port = 4000; let runner; @@ -56,33 +55,19 @@ describe('Develop Greenwood With: ', function() { }; before(async function() { - return new Promise((resolve, reject) => { - request.get({ - url: `http://${hostname}:${port}`, - headers: { - accept: 'text/html' - } - }, (err, res) => { - if (err) { - reject(); - } - - response.status = res.statusCode; - response.headers = res.headers; - - resolve(response); - }); + response = await fetch(`http://${hostname}:${port}`, { + headers: { + accept: 'text/html' + } }); }); - it('should return a 200 status', function(done) { + it('should return a 200 status', function() { expect(response.status).to.equal(200); - done(); }); - it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('text/html'); - done(); + it('should return the correct content type', function() { + expect(response.headers.get('content-type')).to.equal('text/html'); }); }); @@ -92,6 +77,7 @@ describe('Develop Greenwood With: ', function() { body: '', code: 0 }; + let data; const body = { 'operationName': null, @@ -100,38 +86,26 @@ describe('Develop Greenwood With: ', function() { }; before(async function() { - return new Promise((resolve, reject) => { - request.post({ - url: `http://${hostname}:${port}/graphql`, - json: true, - body - }, (err, res, body) => { - if (err) { - reject(); - } - - response.status = res.statusCode; - response.headers = res.headers; - response.body = body; - - resolve(response); - }); + response = await fetch(`http://${hostname}:${port}/graphql`, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'content-type': 'application/json' + } }); + data = await response.json(); }); - it('should return a 200 status', function(done) { + it('should return a 200 status', function() { expect(response.status).to.equal(200); - done(); }); - it('should return the correct content type', function(done) { - expect(response.headers['content-type']).to.contain('application/json'); - done(); + it('should return the correct content type', function() { + expect(response.headers.get('content-type')).to.equal('application/json; charset=utf-8'); }); - it('should return the expected query response', function(done) { - expect(response.body.data.config.workspace).to.equal(new URL('./src/', import.meta.url).href); - done(); + it('should return the expected query response', function() { + expect(data.data.config.workspace).to.equal(new URL('./src/', import.meta.url).href); }); }); }); diff --git a/packages/plugin-graphql/test/cases/query-children/src/components/posts-list.js b/packages/plugin-graphql/test/cases/query-children/src/components/posts-list.js index 72f782830..c35606a36 100644 --- a/packages/plugin-graphql/test/cases/query-children/src/components/posts-list.js +++ b/packages/plugin-graphql/test/cases/query-children/src/components/posts-list.js @@ -1,6 +1,6 @@ import { html, LitElement } from 'lit'; -import client from '@greenwood/plugin-graphql/core/client'; -import ChildrenQuery from '@greenwood/plugin-graphql/queries/children'; +import client from '@greenwood/plugin-graphql/src/core/client.js'; +import ChildrenQuery from '@greenwood/plugin-graphql/src/queries/children.gql'; class PostsListTemplate extends LitElement { @@ -22,7 +22,7 @@ class PostsListTemplate extends LitElement { const response = await client.query({ query: ChildrenQuery, variables: { - parent: 'blog' + parent: '/blog' } }); diff --git a/packages/plugin-graphql/test/cases/query-config/src/components/footer.js b/packages/plugin-graphql/test/cases/query-config/src/components/footer.js index 94c21ac6a..4d5b83d23 100644 --- a/packages/plugin-graphql/test/cases/query-config/src/components/footer.js +++ b/packages/plugin-graphql/test/cases/query-config/src/components/footer.js @@ -1,5 +1,5 @@ -import client from '@greenwood/plugin-graphql/core/client'; -import ConfigQuery from '@greenwood/plugin-graphql/queries/config'; +import client from '@greenwood/plugin-graphql/src/core/client.js'; +import ConfigQuery from '@greenwood/plugin-graphql/src/queries/config.gql'; class FooterComponent extends HTMLElement { constructor() { diff --git a/packages/plugin-graphql/test/cases/query-custom-frontmatter/src/components/posts-list.js b/packages/plugin-graphql/test/cases/query-custom-frontmatter/src/components/posts-list.js index f7f37ecde..06fdaf760 100644 --- a/packages/plugin-graphql/test/cases/query-custom-frontmatter/src/components/posts-list.js +++ b/packages/plugin-graphql/test/cases/query-custom-frontmatter/src/components/posts-list.js @@ -1,5 +1,5 @@ import { html, LitElement } from 'lit'; -import client from '@greenwood/plugin-graphql/core/client'; +import client from '@greenwood/plugin-graphql/src/core/client.js'; class PostsListTemplate extends LitElement { diff --git a/packages/plugin-graphql/test/cases/query-custom-schema/src/pages/index.html b/packages/plugin-graphql/test/cases/query-custom-schema/src/pages/index.html index 6f6ac74f2..3d0ce48d9 100644 --- a/packages/plugin-graphql/test/cases/query-custom-schema/src/pages/index.html +++ b/packages/plugin-graphql/test/cases/query-custom-schema/src/pages/index.html @@ -3,7 +3,7 @@ + + + +

Hello World!

+ + + + \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js b/packages/plugin-typescript/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js new file mode 100644 index 000000000..01ed288b4 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js @@ -0,0 +1,103 @@ +/* + * Use Case + * Run Greenwood with an API and SSR routes that import TypeScript. + * + * User Result + * Should generate a Greenwood build that correctly builds and bundles all assets. + * + * User Command + * greenwood build + * + * User Config + * { + * plugins: [ + * greenwoodPluginTypeScript() + * ] + * } + * + * User Workspace + * src/ + * api/ + * fragment.js + * components/ + * card.ts + */ +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; + +const expect = chai.expect; + +describe('Serve Greenwood With: ', function() { + const LABEL = 'A Server Rendered Application (SSR) with API Routes importing TypeScript'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const hostname = 'http://localhost:8080'; + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public'), + hostname + }; + runner = new Runner(false, true); + }); + + describe(LABEL, function() { + + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + + return new Promise(async (resolve) => { + setTimeout(() => { + resolve(); + }, 10000); + + await runner.runCommand(cliPath, 'serve'); + }); + }); + + describe('Serve command with API specific behaviors for an HTML ("fragment") API', function() { + let response = {}; + let dom; + + before(async function() { + response = await fetch(`${hostname}/api/fragment`); + const body = await response.text(); + dom = new JSDOM(body); + }); + + it('should return a 200 status', function() { + expect(response.status).to.equal(200); + }); + + it('should return a custom status message', function() { + expect(response.statusText).to.equal('OK'); + }); + + it('should return the correct content type', function() { + expect(response.headers.get('content-type')).to.equal('text/html'); + }); + + it('should make sure to have the expected CSS inlined into the page for each ', function(done) { + const cardComponents = dom.window.document.querySelectorAll('body > app-card'); + + expect(cardComponents.length).to.equal(2); + Array.from(cardComponents).forEach((card) => { + expect(card.innerHTML).contain('font-size: 1.85rem'); + }); + done(); + }); + }); + }); + + after(function() { + runner.teardown(getOutputTeardownFiles(outputPath)); + runner.stopCommand(); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/greenwood.config.js b/packages/plugin-typescript/test/cases/exp-serve.ssr/greenwood.config.js new file mode 100644 index 000000000..e0f06cfb5 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/greenwood.config.js @@ -0,0 +1,7 @@ +import { greenwoodPluginTypeScript } from '../../../src/index.js'; + +export default { + plugins: [ + ...greenwoodPluginTypeScript() + ] +}; \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/package.json b/packages/plugin-typescript/test/cases/exp-serve.ssr/package.json new file mode 100644 index 000000000..4929e3519 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-plugin-import-ts-serve-ssr", + "type": "module" +} \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/api/fragment.js b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/api/fragment.js new file mode 100644 index 000000000..d03a8f3e6 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/api/fragment.js @@ -0,0 +1,33 @@ +import { renderFromHTML } from 'wc-compiler'; + +export async function handler() { + const products = [{ + name: 'iPhone 12', + thumbnail: 'iphone-12.png' + }, { + name: 'Samsung Galaxy', + thumbnail: 'samsung-galaxy.png' + }]; + const { html } = await renderFromHTML(` + ${ + products.map((product) => { + const { name, thumbnail } = product; + + return ` + + `; + }).join('') + } + `, [ + new URL('../components/card/card.ts', import.meta.url) + ]); + + return new Response(html, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/card.ts b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/card.ts new file mode 100644 index 000000000..2cad71359 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/card.ts @@ -0,0 +1,33 @@ +import { styles } from './styles.ts'; + +const fallbackImage = new URL('./logo.png', import.meta.url); + +export default class Card extends HTMLElement { + + selectItem() { + alert(`selected item is => ${this.getAttribute('title')}!`); + } + + connectedCallback() { + if (!this.shadowRoot) { + const thumbnail: String = this.getAttribute('thumbnail') || fallbackImage.href; + const title: String = this.getAttribute('title'); + const template: any = document.createElement('template'); + + template.innerHTML = ` + +
+

${title}

+ ${title} + +
+ `; + this.attachShadow({ mode: 'open' }); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + } + } +} + +customElements.define('app-card', Card); \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/logo.png b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/logo.png new file mode 100644 index 000000000..786360832 Binary files /dev/null and b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/logo.png differ diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/styles.ts b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/styles.ts new file mode 100644 index 000000000..210e82df1 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/styles.ts @@ -0,0 +1,7 @@ +const styles: string = ` + h3 { + font-size: 1.85rem; + } +`; + +export { styles }; \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/pages/index.html b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/pages/index.html new file mode 100644 index 000000000..555a68287 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/pages/index.html @@ -0,0 +1,16 @@ + + + + + + + + +

Hello World!

+ + + + \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/options.extend-config/src/scripts/main.ts b/packages/plugin-typescript/test/cases/options.extend-config/src/scripts/main.ts index bcd28341d..cddcb1a4e 100644 --- a/packages/plugin-typescript/test/cases/options.extend-config/src/scripts/main.ts +++ b/packages/plugin-typescript/test/cases/options.extend-config/src/scripts/main.ts @@ -1,17 +1,16 @@ import { html, css, LitElement, customElement, property } from 'lit-element'; +import { TemplateResult } from 'lit-html'; @customElement('app-greeting') export class GreetingComponent extends LitElement { static styles = css`p { color: blue }`; @property() - name = 'Somebody'; + name = 'Somebody'; - render() { - const greeting: Greeting = { - message: html`

Hello, ${this.name}!

` - }; + render(): TemplateResult { + const greeting: TemplateResult = html`

Hello, ${this.name}!

`; - return greeting.message; + return greeting; } } \ No newline at end of file diff --git a/www/assets/serverless.webp b/www/assets/serverless.webp new file mode 100644 index 000000000..7ec47d815 Binary files /dev/null and b/www/assets/serverless.webp differ diff --git a/www/components/header/header.js b/www/components/header/header.js index 345b9e4d5..5a708a06e 100644 --- a/www/components/header/header.js +++ b/www/components/header/header.js @@ -1,6 +1,6 @@ import { css, html, LitElement, unsafeCSS } from 'lit'; -import client from '@greenwood/plugin-graphql/core/client'; -import MenuQuery from '@greenwood/plugin-graphql/queries/menu'; +import client from '@greenwood/plugin-graphql/src/core/client.js'; +import MenuQuery from '@greenwood/plugin-graphql/src/queries/menu.gql'; import '@evergreen-wc/eve-container'; import headerCss from './header.css?type=css'; import '../social-icons/social-icons.js'; diff --git a/www/components/shelf/shelf.js b/www/components/shelf/shelf.js index 90cbd0b8b..1b555977a 100644 --- a/www/components/shelf/shelf.js +++ b/www/components/shelf/shelf.js @@ -1,6 +1,6 @@ import { css, html, LitElement, unsafeCSS } from 'lit'; -import client from '@greenwood/plugin-graphql/core/client'; -import MenuQuery from '@greenwood/plugin-graphql/queries/menu'; +import client from '@greenwood/plugin-graphql/src/core/client.js'; +import MenuQuery from '@greenwood/plugin-graphql/src/queries/menu.gql'; import shelfCss from './shelf.css?type=css'; import chevronRt from '../icons/chevron-right.js'; import chevronDwn from '../icons/chevron-down.js'; diff --git a/www/package.json b/www/package.json index b64517473..6531c5e5c 100644 --- a/www/package.json +++ b/www/package.json @@ -1,6 +1,6 @@ { "name": "@greenwood/www", - "version": "0.28.5", + "version": "0.29.0-alpha.6", "private": true, "type": "module", "description": "Greenwood website workspace.", diff --git a/www/pages/blog/index.md b/www/pages/blog/index.md index 6e7f78b55..fd8fb6e08 100644 --- a/www/pages/blog/index.md +++ b/www/pages/blog/index.md @@ -19,6 +19,7 @@ template: blog # News and Announcements +- [Release: v0.29.0](/blog/release/v0-29-0/) 📝 - [State of Greenwood (2023)](/blog/state-of-greenwood-2023/) 📣 - [Release: v0.28.0](/blog/release/v0-28-0/) 📝 - [Release: v0.27.0](/blog/release/v0-27-0/) 📝 diff --git a/www/pages/blog/release/v0-29-0.md b/www/pages/blog/release/v0-29-0.md new file mode 100644 index 000000000..0493f9df6 --- /dev/null +++ b/www/pages/blog/release/v0-29-0.md @@ -0,0 +1,116 @@ +--- +label: 'blog' +title: v0.29.0 Release +template: blog +--- + +# Greenwood v0.29.0 + +**Published: Nov 8, 2023** + +Serverless function cloud + +## What's New + +The Greenwood team is back with a new release and we're excited to share with you what we've been up to. From this latest release, here are three features we'd like to highlight: + +1. Serverless Adapters (Netlify, Vercel) +1. Web Server Components +1. Static Asset Bundling + +Let's check them out! 👇 + +### Serverless Adapters + +The simplicity of serverless hosting can be a great advantage in achieving dynamic with the ease of static. As part of this release, the Greenwood team has now made it so that you can easily adapt a Greenwood project's SSR pages or API endpoints to run on [**Netlify**](https://www.netlify.com/) and [**Vercel**](https://vercel.com/) serverless hosting. + +In the demo video below, you can see a mix of static (HTML) pages and templates rendering alongside purely SSR pages and API endpoints, all running on serverless hosting. SSR pages and API endpoints are capable of server rendering real custom elements, meaning you can get **_full-stack Web Components_** with Greenwood! 🚀 + + + +It's as easy as installing and adding the plugin to your _greenwood.config.js_. +```js +// import { greenwoodPluginAdapterVercel } from '@greenwood/plugin-adapter-vercel'; +import { greenwoodPluginAdapterNetlify } from '@greenwood/plugin-adapter-netlify'; + +export default { + plugins: [ + greenwoodPluginAdapterNetlify() + ] +}; +``` + +Check out the README docs for our currently supported [**Netlify**](https://github.com/ProjectEvergreen/greenwood/tree/rmaster/packages/plugin-adapter-netlify) and [**Vercel**](https://github.com/ProjectEvergreen/greenwood/tree/rmaster/packages/plugin-adapter-vercel) plugins, and keep your eyes out for future plugins as we look to land support for [**AWS**](https://github.com/ProjectEvergreen/greenwood/issues/1142) and [**Cloudflare**](https://github.com/ProjectEvergreen/greenwood/issues/1143). 👀 + +> _You can check out our showcase repos for each platform [here](https://github.com/ProjectEvergreen/greenwood-demo-adapter-netlify) and [here](https://github.com/ProjectEvergreen/greenwood-demo-adapter-vercel)._ + + +### Web Server Components + +Although [Custom Elements as pages](/blog/release/v0-26-0/#custom-elements-as-pages) are not a new feature, as Greenwood continues to enhance its capabilities on the backend, hooking these pages into the request / response lifecycle was an obvious need, and so we are now "promoting" these custom elements to a new name; _Web Server Components_. ✨ + +The API is still the same and continues to run only on the server, except now Greenwood will provide the `Request` object for the incoming request as a ["constructor prop"](/docs/server-rendering/#data-loading), allowing dynamic request time handling to occur within the custom element. + +```js +export default class PostPage extends HTMLElement { + constructor(request) { + super(); + + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + this.postId = params.get('id'); + } + + async connectedCallback() { + const { postId } = this; + const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`).then(resp => resp.json()); + const { title, body } = post; + + this.innerHTML = ` +

${title}

+

${body}

+ `; + } +} +``` + +> _We plan to continue [building on this concept for response handling](https://github.com/ProjectEvergreen/greenwood/issues/1177) and fleshing out Greenwood's capabilities through features like [dynamic routing](https://github.com/ProjectEvergreen/greenwood/issues/882) and [hydration](https://github.com/ProjectEvergreen/greenwood/issues/880)._ + + +### Static Asset Bundling + +As an alternative to the pre-defined [_assets/_ directory](/docs/css-and-images/), Greenwood now handles static asset "bundling" when referencing resources like images in your JavaScript. Through a combination of [`new URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) and [`import.meta.url`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta), your resource can now be located anywhere in your project's workspace. + +For production builds, Greenwood will generate a unique filename for the asset as well, e.g. _logo-83bc009f.svg_. 💯 + +```js +const logo = new URL('../path/to/images/logo.svg', import.meta.url); + +class Header extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +
+

Welcome to My Site

+ + My logo +
+ `; + } +} + +customElements.define('app-header', Header); +``` + +> _We are looking to improve the developer experience around this pattern so please feel free to follow along or comment in this [GitHub issue](https://github.com/ProjectEvergreen/greenwood/issues/1163)._ + +## What's Next + +We're really excited to see the progress **Greenwood** has been able to make this year, and are looking forward to seeing where the community can take it. As we get closer to finalizing our [1.0 Roadmap](https://github.com/ProjectEvergreen/greenwood/milestone/3), we've been playing around with more ecosystem projects and making little demos to share with you all. We encourage you to check them out to see what Greenwood is capable of and help us push the boundaries of the _**full-stack web**_! 🙌 + +- [Server rendering custom elements with WCC on Vercel Serverless functions using htmx](https://github.com/thescientist13/greenwood-htmx) +- [Rendering Lit+SSR on Vercel Serverless functions](https://github.com/thescientist13/greenwood-demo-adapter-vercel-lit) + +We're also planning a significant [redesign of the Greenwood website](https://github.com/ProjectEvergreen/greenwood/issues/978) to help better showcase all of Greenwood's capabilities and to streamline and simplify the documentation. + +So stay tuned, join our [Slack](https://join.slack.com/t/thegreenhouseio/shared_invite/enQtMzcyMzE2Mjk1MjgwLTU5YmM1MDJiMTg0ODk4MjA4NzUwNWFmZmMxNDY5MTcwM2I0MjYxN2VhOTEwNDU2YWQwOWQzZmY1YzY4MWRlOGI) or [Discord](https://discord.gg/pFbynPar) communities to be part of the conversation, and we look forward to seeing you for the next release. ✌️ \ No newline at end of file diff --git a/www/pages/docs/configuration.md b/www/pages/docs/configuration.md index 089a7a265..51b1c3846 100644 --- a/www/pages/docs/configuration.md +++ b/www/pages/docs/configuration.md @@ -18,6 +18,7 @@ export default { port: 1984, host: 'localhost' }, + basePath: '', port: 8080, interpolateFrontmatter: false, markdown: { @@ -34,6 +35,31 @@ export default { }; ``` +### Base Path + +There are cases where an application might be deployed and hosted from a "sub" pathname that acts as the relative "web root". (GitHub Pages is an example of this) + +So with a URL of `http://www.example.com/app-a/`, the `basePath` could be set as such: +```js +export default { + basePath: '/app-a' +}; +``` + +This would then configure Greenwood's routing and ` +``` + +For convenience, the value of `basePath` will also be made available as a global variable in the `` of your pages. For example: +```html + +``` + +> _User content, like `` and `` tags will require manually prefixing the basePath in your code._ + ### Dev Server Configuration for Greenwood's development server is available using the `devServer` option. - `extensions`: Provide an array of extensions to watch for changes and reload the live server with. By default, Greenwood will already watch all "standard" web assets (HTML, CSS, JS, etc) it supports by default, as well as any extensions set by [resource plugins](/plugins/resource) you are using in your _greenwood.config.json_. diff --git a/www/pages/docs/css-and-images.md b/www/pages/docs/css-and-images.md index 657f048ee..6b0d5d310 100644 --- a/www/pages/docs/css-and-images.md +++ b/www/pages/docs/css-and-images.md @@ -41,7 +41,7 @@ Styles can be done in any standards compliant way that will work in a browser. ### Assets -For convenience, **Greenwood** does support an "assets" directory wherein anything copied into that will be present in the build output directory. This is the recommended location for all your local images, fonts, etc. Effectively anything that is not part of an `import`, `@import`, `