diff --git a/.c8rc.json b/.c8rc.json index 5d44abeee..3ecf54cb2 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -18,10 +18,10 @@ "checkCoverage": true, - "statements": 80, + "statements": 75, "branches": 85, "functions": 85, - "lines": 80, + "lines": 75, "watermarks": { "statements": [75, 85], diff --git a/.eslintignore b/.eslintignore index 8e786af06..216d536ef 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,5 +3,8 @@ **/node_modules/** !.eslintrc.cjs !.mocharc.js +packages/init/src/template/** +packages/init/test/cases/**/output/** +packages/init/test/cases/**/my-app/** packages/plugin-babel/test/cases/**/*main.js TODO.md \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0c06c5089..dce41cebc 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,14 +1,27 @@ +// need this custom parser configuration until ESLint natively supports import attributes +// https://github.com/eslint/eslint/discussions/15305#discussioncomment-2508948 module.exports = { - parser: '@typescript-eslint/parser', + parser: '@babel/eslint-parser', parserOptions: { - ecmaVersion: 2018, - sourceType: 'module' + ecmaVersion: 2022, + sourceType: 'module', + requireConfigFile: false, + ecmaFeatures: { + jsx: true + }, + babelOptions: { + plugins: [ + '@babel/plugin-syntax-import-assertions' + ], + presets: ['@babel/preset-react'] + } }, plugins: [ - '@typescript-eslint', 'no-only-tests' ], - extends: 'plugin:markdown/recommended', + // plugin does not seem to work well with custom parsers? + // https://github.com/eslint/eslint-plugin-markdown/discussions/221 + // extends: 'plugin:markdown/recommended-legacy', env: { browser: true, node: false diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d3e92b598..90ed23df3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -57,7 +57,7 @@ The [layout](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/ - _lib/_ - Custom utility and client facing files - _lifecycles/_ - Tasks that can be composed by commands to support the full needs of that command - _plugins/_ - Custom default plugins maintained by the CLI project -- _templates/_ - Default templates and / or pages provided by Greenwood. +- _layouts/_ - Default layouts and / or pages provided by Greenwood. #### Lifecycles diff --git a/.github/workflows/ci-exp.yml b/.github/workflows/ci-loaders.yml similarity index 89% rename from .github/workflows/ci-exp.yml rename to .github/workflows/ci-loaders.yml index f4916828d..34c079cbd 100644 --- a/.github/workflows/ci-exp.yml +++ b/.github/workflows/ci-loaders.yml @@ -1,4 +1,4 @@ -name: Continuous Integration (Experimental) +name: Continuous Integration (Loaders) on: [pull_request] @@ -25,4 +25,4 @@ jobs: yarn install --frozen-lockfile && yarn lerna bootstrap - name: Test run: | - yarn test:exp \ No newline at end of file + yarn test:loaders \ No newline at end of file diff --git a/.github/workflows/ci-win-exp.yml b/.github/workflows/ci-win-loaders.yml similarity index 85% rename from .github/workflows/ci-win-exp.yml rename to .github/workflows/ci-win-loaders.yml index ffc7ef0fd..7e69f3873 100644 --- a/.github/workflows/ci-win-exp.yml +++ b/.github/workflows/ci-win-loaders.yml @@ -1,4 +1,4 @@ -name: Continuous Integration Windows (Experimental) +name: Continuous Integration Windows (Loaders) on: [pull_request] @@ -22,4 +22,4 @@ jobs: yarn install --frozen-lockfile --network-timeout 1000000 && yarn lerna bootstrap - name: Test run: | - yarn test:exp:win \ No newline at end of file + yarn test:loaders:win \ No newline at end of file diff --git a/.gitignore b/.gitignore index a56ab6ff6..5acf30c1f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,12 @@ .vscode/ coverage/ node_modules/ +packages/init/test/**/my-app +packages/init/test/**/output packages/**/test/**/yarn.lock packages/**/test/**/package-lock.json packages/**/test/**/netlify packages/**/test/**/.netlify packages/**/test/**/.vercel public/ -adapter-outlet/ \ No newline at end of file +adapter-output/ \ No newline at end of file diff --git a/.ls-lint.yml b/.ls-lint.yml index 64ab822b4..ab2ad9cba 100644 --- a/.ls-lint.yml +++ b/.ls-lint.yml @@ -14,5 +14,6 @@ ls: ignore: - .git - node_modules + - packages/plugin-babel/node_modules - packages/init/node_modules - packages/plugin-typescript/node_modules \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 72c7744b3..23cc58a71 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.12.1 \ No newline at end of file +18.20.0 \ No newline at end of file diff --git a/greenwood.config.js b/greenwood.config.js index 73fc49abb..2ba3aca86 100644 --- a/greenwood.config.js +++ b/greenwood.config.js @@ -1,9 +1,8 @@ import { greenwoodPluginGraphQL } from '@greenwood/plugin-graphql'; import { greenwoodPluginIncludeHTML } from '@greenwood/plugin-include-html'; -import { greenwoodPluginImportCss } from '@greenwood/plugin-import-css'; -import { greenwoodPluginImportJson } from '@greenwood/plugin-import-json'; import { greenwoodPluginPolyfills } from '@greenwood/plugin-polyfills'; import { greenwoodPluginPostCss } from '@greenwood/plugin-postcss'; +import { greenwoodPluginImportRaw } from '@greenwood/plugin-import-raw'; import { greenwoodPluginRendererPuppeteer } from '@greenwood/plugin-renderer-puppeteer'; import rollupPluginAnalyzer from 'rollup-plugin-analyzer'; @@ -11,13 +10,19 @@ export default { workspace: new URL('./www/', import.meta.url), optimization: 'inline', staticRouter: true, - interpolateFrontmatter: true, + activeContent: true, plugins: [ greenwoodPluginGraphQL(), - greenwoodPluginPolyfills(), + greenwoodPluginPolyfills({ + lit: true + }), greenwoodPluginPostCss(), - greenwoodPluginImportJson(), - greenwoodPluginImportCss(), + greenwoodPluginImportRaw({ + matches: [ + 'eve-button.css', + 'eve-container.css' + ] + }), greenwoodPluginIncludeHTML(), greenwoodPluginRendererPuppeteer(), { diff --git a/lerna.json b/lerna.json index b547049dd..3be4fc550 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.29.4", + "version": "0.30.0-alpha.7", "packages": [ "packages/*", "www" diff --git a/netlify.toml b/netlify.toml index 3db7ff18d..5b5829366 100644 --- a/netlify.toml +++ b/netlify.toml @@ -10,4 +10,19 @@ [[redirects]] from = "/docs/tech-stack/" - to = "/about/tech-stack/" \ No newline at end of file + to = "/about/tech-stack/" + +[[redirects]] + from = "/docs/menus/:splat" + to = "/docs/data/" + status = 200 + +[[redirects]] + from = "/docs/data/#external-sources" + to = "/docs/data/#pages-data" + status = 200 + +[[redirects]] + from = "/docs/data/#internal-sources" + to = "/docs/data/#pages-data" + status = 200 \ No newline at end of file diff --git a/package.json b/package.json index 71518641b..fe45e2b24 100644 --- a/package.json +++ b/package.json @@ -20,22 +20,24 @@ "build": "cross-env __GWD_ROLLUP_MODE__=strict node . build", "serve": "node . serve", "develop": "node . develop", - "test": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=true __GWD_ROLLUP_MODE__=strict NODE_NO_WARNINGS=1 c8 mocha --exclude \"./packages/**/test/cases/exp-*/**\" \"./packages/**/**/*.spec.js\"", - "test:exp": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=true __GWD_ROLLUP_MODE__=strict NODE_NO_WARNINGS=1 node --experimental-loader $(pwd)/test/test-loader.js ./node_modules/mocha/bin/mocha \"./packages/**/**/*.spec.js\"", - "test:exp:win": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=true __GWD_ROLLUP_MODE__=strict NODE_NO_WARNINGS=1 node --experimental-loader file:\\\\%cd%\\test\\test-loader.js ./node_modules/mocha/bin/mocha --exclude \"./packages/init/test/cases/**\" \"./packages/**/**/*.spec.js\"", + "test": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=true __GWD_ROLLUP_MODE__=strict NODE_NO_WARNINGS=1 c8 mocha --exclude \"./packages/**/test/cases/loaders-*/**\" \"./packages/**/**/*.spec.js\"", + "test:loaders": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=true __GWD_ROLLUP_MODE__=strict NODE_NO_WARNINGS=1 node --loader $(pwd)/test/test-loader.js ./node_modules/mocha/bin/mocha \"./packages/**/**/*.spec.js\"", + "test:loaders:win": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=true __GWD_ROLLUP_MODE__=strict NODE_NO_WARNINGS=1 node --loader file:\\\\%cd%\\test\\test-loader.js ./node_modules/mocha/bin/mocha --exclude \"./packages/init/test/cases/**\" \"./packages/**/**/*.spec.js\"", "test:tdd": "yarn test --watch", - "lint:js": "eslint \"*.{js,md}\" \"./packages/**/**/*.{js,md}\" \"./test/*.js\" \"./www/**/**/*.{js,md}\"", + "lint:js": "eslint \"*.js\" \"./packages/**/**/*.js\" \"./test/*.js\" \"./www/**/**/*.js\"", "lint:ts": "eslint \"./packages/**/**/*.ts\"", "lint:css": "stylelint \"./www/**/*.js\", \"./www/**/*.css\"", - "lint": "ls-lint && yarn lint:js && yarn lint:ts && yarn lint:css" + "lint": "ls-lint && yarn lint:js && yarn lint:css" }, "resolutions": { - "lit": "^2.1.1" + "lit": "^3.1.0" }, "devDependencies": { + "@babel/core": "^7.24.4", + "@babel/eslint-parser": "^7.24.1", + "@babel/plugin-syntax-import-assertions": "^7.24.1", + "@babel/preset-react": "^7.24.1", "@ls-lint/ls-lint": "^1.10.0", - "@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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 605c481e3..7e99dc26f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@greenwood/cli", - "version": "0.29.4", + "version": "0.30.0-alpha.7", "description": "Greenwood CLI.", "type": "module", "repository": "https://github.com/ProjectEvergreen/greenwood", @@ -18,7 +18,7 @@ "NodeJS" ], "engines": { - "node": ">=18.12.1" + "node": ">=18.20.0" }, "bin": { "greenwood": "./src/index.js" @@ -30,15 +30,16 @@ "access": "public" }, "dependencies": { - "@rollup/plugin-commonjs": "^21.0.0", - "@rollup/plugin-node-resolve": "^13.0.0", - "@rollup/plugin-replace": "^2.3.4", - "@rollup/plugin-terser": "^0.1.0", + "@rollup/plugin-commonjs": "^25.0.0", + "@rollup/plugin-node-resolve": "^15.0.0", + "@rollup/plugin-replace": "^5.0.5", + "@rollup/plugin-terser": "^0.4.4", "acorn": "^8.0.1", + "acorn-import-attributes": "^1.9.5", "acorn-walk": "^8.0.0", "commander": "^2.20.0", - "css-tree": "^2.2.1", - "es-module-shims": "^1.2.0", + "css-tree": "^3.0.0", + "es-module-shims": "^1.8.3", "front-matter": "^4.0.2", "koa": "^2.13.0", "koa-body": "^6.0.1", @@ -50,9 +51,9 @@ "remark-frontmatter": "^2.0.0", "remark-parse": "^8.0.3", "remark-rehype": "^7.0.0", - "rollup": "^2.58.0", + "rollup": "^3.29.4", "unified": "^9.2.0", - "wc-compiler": "~0.10.0" + "wc-compiler": "~0.15.0" }, "devDependencies": { "@babel/runtime": "^7.10.4", @@ -62,7 +63,8 @@ "@material/mwc-button": "^0.25.2", "@stencil/core": "^2.12.0", "@types/trusted-types": "^2.0.2", - "lit": "^2.0.0", + "geist": "^1.2.0", + "lit": "^3.1.0", "lit-redux-router": "~0.20.0", "lodash-es": "^4.17.20", "postcss-nested": "^4.1.2", diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index fba4ef73f..87f13e2d1 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -1,72 +1,17 @@ import { bundleCompilation } from '../lifecycles/bundle.js'; -import { checkResourceExists, trackResourcesForRoute } from '../lib/resource-utils.js'; +import { checkResourceExists } from '../lib/resource-utils.js'; import { copyAssets } from '../lifecycles/copy.js'; +import { getDevServer } from '../lifecycles/serve.js'; import fs from 'fs/promises'; import { preRenderCompilationWorker, preRenderCompilationCustom, staticRenderCompilation } from '../lifecycles/prerender.js'; import { ServerInterface } from '../lib/server-interface.js'; -// TODO a lot of these are duplicated in the prerender lifecycle too -// would be good to refactor -async function servePage(url, request, plugins) { - let response = new Response(''); - - for (const plugin of plugins) { - if (plugin.shouldServe && await plugin.shouldServe(url, request)) { - response = await plugin.serve(url, request); - break; - } - } - - return response; -} - -async function interceptPage(url, request, plugins, body) { - let response = new Response(body, { - headers: new Headers({ 'Content-Type': 'text/html' }) - }); - - for (const plugin of plugins) { - if (plugin.shouldIntercept && await plugin.shouldIntercept(url, request, response)) { - response = await plugin.intercept(url, request, response); - } - } - - return response; -} - -function getPluginInstances (compilation) { - return [...compilation.config.plugins] - .filter(plugin => plugin.type === 'resource' && plugin.name !== 'plugin-node-modules:resource') - .map((plugin) => { - return plugin.provider(compilation); - }); -} - -// TODO does this make more sense in bundle lifecycle? -// https://github.com/ProjectEvergreen/greenwood/issues/970 -// or could this be done sooner (like in appTemplate building in html resource plugin)? -// Or do we need to ensure userland code / plugins have gone first -async function trackResourcesForRoutes(compilation) { - const plugins = getPluginInstances(compilation); - - for (const page of compilation.graph) { - const { route } = page; - const url = new URL(`http://localhost:${compilation.config.port}${route}`); - const request = new Request(url); - - let body = await (await servePage(url, request, plugins)).text(); - body = await (await interceptPage(url, request, plugins, body)).text(); - - await trackResourcesForRoute(body, compilation, route); - } -} - const runProductionBuild = async (compilation) => { return new Promise(async (resolve, reject) => { try { - const { prerender } = compilation.config; + const { prerender, activeContent, plugins } = compilation.config; const outputDir = compilation.context.outputDir; const prerenderPlugin = compilation.config.plugins.find(plugin => plugin.type === 'renderer') ? compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider(compilation) @@ -74,6 +19,7 @@ const runProductionBuild = async (compilation) => { const adapterPlugin = compilation.config.plugins.find(plugin => plugin.type === 'adapter') ? compilation.config.plugins.find(plugin => plugin.type === 'adapter').provider(compilation) : null; + const shouldPrerender = prerender || prerenderPlugin.prerender; if (!await checkResourceExists(outputDir)) { await fs.mkdir(outputDir, { @@ -81,10 +27,10 @@ const runProductionBuild = async (compilation) => { }); } - if (prerender || prerenderPlugin.prerender) { - // start any servers if needed + if (shouldPrerender || (activeContent && shouldPrerender)) { + // start any of the user's server plugins if needed const servers = [...compilation.config.plugins.filter((plugin) => { - return plugin.type === 'server'; + return plugin.type === 'server' && !plugin.isGreenwoodDefaultPlugin; }).map((plugin) => { const provider = plugin.provider(compilation); @@ -95,6 +41,16 @@ const runProductionBuild = async (compilation) => { return provider; })]; + if (activeContent) { + (await getDevServer({ + ...compilation, + // prune for the content as data plugin and start the dev server with only that plugin enabled + plugins: [plugins.find(plugin => plugin.name === 'plugin-active-content')] + })).listen(compilation.config.devServer.port, () => { + console.info('Initializing active content...'); + }); + } + await Promise.all(servers.map(async (server) => { await server.start(); @@ -102,13 +58,11 @@ const runProductionBuild = async (compilation) => { })); if (prerenderPlugin.executeModuleUrl) { - await trackResourcesForRoutes(compilation); await preRenderCompilationWorker(compilation, prerenderPlugin); } else { await preRenderCompilationCustom(compilation, prerenderPlugin); } } else { - await trackResourcesForRoutes(compilation); await staticRenderCompilation(compilation); } diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index dc0d582d6..b0bad6bfd 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -1,3 +1,4 @@ +/* eslint-disable complexity, max-depth */ import fs from 'fs'; import path from 'path'; import { checkResourceExists, normalizePathnameForWindows } from '../lib/resource-utils.js'; @@ -6,31 +7,16 @@ import commonjs from '@rollup/plugin-commonjs'; import * as walk from 'acorn-walk'; // https://github.com/rollup/rollup/issues/2121 +// would be nice to get rid of this function cleanRollupId(id) { - return id.replace('\x00', ''); + return id.replace('\x00', '').replace('?commonjs-proxy', ''); } -// 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 idUrl = new URL(`file://${cleanRollupId(id)}`); - const extension = idUrl.pathname.split('.').pop(); - - if (extension === 'json') { - const json = JSON.parse(await fs.promises.readFile(idUrl, 'utf-8')); - const contents = `export default ${JSON.stringify(json)}`; +// ConstructableStylesheets, JSON Modules +const externalizedResources = ['css', 'json']; - return contents; - } - } - }; -} - -function greenwoodResourceLoader (compilation) { +function greenwoodResourceLoader (compilation, browser = false) { + const { importAttributes } = compilation.config?.polyfills; const resourcePlugins = compilation.config.plugins.filter((plugin) => { return plugin.type === 'resource'; }).map((plugin) => { @@ -39,39 +25,78 @@ function greenwoodResourceLoader (compilation) { return { name: 'greenwood-resource-loader', - async resolveId(id) { - const normalizedId = cleanRollupId(id); // idUrl.pathname; - const { projectDirectory, userWorkspace } = compilation.context; - - if (normalizedId.startsWith('.') && !normalizedId.startsWith(projectDirectory.pathname)) { + async resolveId(id, importer) { + const normalizedId = cleanRollupId(id); + const { userWorkspace } = compilation.context; + + // check for non bare paths and resolve them to the user's workspace + // or Greenwood's scratch dir, like when bundling inline `; + }) + ].join('\n'); + + const finalBody = pageLayoutContents + ? appBody.replace(/<\/page-outlet>/, pageBody) + : appBody; + + mergedLayoutContents = ` + ${mergedHtml} + + ${title} + ${mergedMeta} + ${mergedLinks} + ${mergedStyles} + ${mergedScripts} + + + ${finalBody} + + + `; + } + + return mergedLayoutContents; +} + +async function getUserScripts (contents, compilation) { + const { config } = compilation; + + contents = contents.replace('', ` + + + `); + + return contents; +} + +export { + getAppLayout, + getPageLayout, + getUserScripts +}; \ No newline at end of file diff --git a/packages/cli/src/lib/resource-utils.js b/packages/cli/src/lib/resource-utils.js index 1032afc5e..6a78e4647 100644 --- a/packages/cli/src/lib/resource-utils.js +++ b/packages/cli/src/lib/resource-utils.js @@ -61,15 +61,18 @@ function mergeResponse(destination, source) { // https://github.com/rollup/rollup/issues/3779 function normalizePathnameForWindows(url) { const windowsDriveRegex = /\/[a-zA-Z]{1}:\//; - const { pathname = '' } = url; + const { pathname = '', searchParams } = url; + const params = searchParams.size > 0 + ? `?${searchParams.toString()}` + : ''; if (windowsDriveRegex.test(pathname)) { const driveMatch = pathname.match(windowsDriveRegex)[0]; - return pathname.replace(driveMatch, driveMatch.replace('/', '')); + return `${pathname.replace(driveMatch, driveMatch.replace('/', ''))}${params}`; } - return pathname; + return `${pathname}${params}`; } async function checkResourceExists(url) { @@ -83,7 +86,7 @@ async function checkResourceExists(url) { // turn relative paths into relatively absolute based on a known root directory // * deep link route - /blog/releases/some-post -// * and a nested path in the template - ../../styles/theme.css +// * and a nested path in the layout - ../../styles/theme.css // so will get resolved as `${rootUrl}/styles/theme.css` async function resolveForRelativeUrl(url, rootUrl) { const search = url.search || ''; @@ -108,11 +111,6 @@ async function resolveForRelativeUrl(url, rootUrl) { return reducedUrl; } -// TODO does this make more sense in bundle lifecycle? -// https://github.com/ProjectEvergreen/greenwood/issues/970 -// or could this be done sooner (like in appTemplate building in html resource plugin)? -// Or do we need to ensure userland code / plugins have gone first -// before we can curate the final list of `) - ].join('\n'); - - const finalBody = pageTemplateContents - ? appBody.replace(/<\/page-outlet>/, pageBody) - : appBody; - - mergedTemplateContents = ` - ${mergedHtml} - - ${title} - ${mergedMeta} - ${mergedLinks} - ${mergedStyles} - ${mergedScripts} - - - ${finalBody} - - - `; - } - - return mergedTemplateContents; -} - -async function getUserScripts (contents, compilation) { - const { 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); - const dependencies = userPackageJson?.dependencies || {}; - const litPolyfill = dependencies && dependencies.lit - ? '\n' - : ''; - - contents = contents.replace('', ` - - ${litPolyfill} - `); - } - - return contents; -} - -export { - getAppTemplate, - getPageTemplate, - getUserScripts -}; \ No newline at end of file diff --git a/packages/cli/src/lib/walker-package-ranger.js b/packages/cli/src/lib/walker-package-ranger.js index 5ce30c114..a92bb3d29 100644 --- a/packages/cli/src/lib/walker-package-ranger.js +++ b/packages/cli/src/lib/walker-package-ranger.js @@ -215,17 +215,33 @@ async function walkPackageJson(packageJson = {}) { return importMap; } -function mergeImportMap(html = '', map = {}) { - // es-modules-shims breaks on dangling commas in an importMap :/ - const danglingComma = html.indexOf('"imports": {}') > 0 ? '' : ','; - const importMap = JSON.stringify(map).replace('}', '').replace('{', ''); - - const merged = html.replace('"imports": {', ` - "imports": { - ${importMap}${danglingComma} - `); +function mergeImportMap(html = '', map = {}, shouldShim = false) { + const importMapType = shouldShim ? 'importmap-shim' : 'importmap'; + const hasImportMap = html.indexOf(`script type="${importMapType}"`) > 0; + const danglingComma = hasImportMap ? ',' : ''; + const importMap = JSON.stringify(map, null, 2).replace('}', '').replace('{', ''); + + if (Object.entries(map).length === 0) { + return html; + } - return merged; + if (hasImportMap) { + return html.replace('"imports": {', ` + "imports": { + ${importMap}${danglingComma} + `); + } else { + return html.replace('', ` + + + `); + } } export { diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index 4aad70159..814d1b946 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -1,15 +1,41 @@ /* 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 { getRollupConfigForApiRoutes, getRollupConfigForBrowserScripts, getRollupConfigForSsrPages } from '../config/rollup.config.js'; +import { getAppLayout, getPageLayout, getUserScripts } from '../lib/layout-utils.js'; import { hashString } from '../lib/hashing-utils.js'; -import { checkResourceExists, mergeResponse, normalizePathnameForWindows } from '../lib/resource-utils.js'; +import { checkResourceExists, mergeResponse, normalizePathnameForWindows, trackResourcesForRoute } from '../lib/resource-utils.js'; import path from 'path'; import { rollup } from 'rollup'; +async function interceptPage(url, request, plugins, body) { + let response = new Response(body, { + headers: new Headers({ 'Content-Type': 'text/html' }) + }); + + for (const plugin of plugins) { + if (plugin.shouldPreIntercept && await plugin.shouldPreIntercept(url, request, response)) { + response = await plugin.preIntercept(url, request, response); + } + + if (plugin.shouldIntercept && await plugin.shouldIntercept(url, request, response)) { + response = await plugin.intercept(url, request, response); + } + } + + return response; +} + +function getPluginInstances(compilation) { + return [...compilation.config.plugins] + .filter(plugin => plugin.type === 'resource' && plugin.name !== 'plugin-node-modules:resource') + .map((plugin) => { + return plugin.provider(compilation); + }); +} + async function emitResources(compilation) { - const { outputDir } = compilation.context; - const { resources, graph } = compilation; + const { outputDir, scratchDir } = compilation.context; + const { resources, graph, manifest } = compilation; // https://stackoverflow.com/a/56150320/417806 await fs.writeFile(new URL('./resources.json', outputDir), JSON.stringify(resources, (key, value) => { @@ -23,6 +49,17 @@ async function emitResources(compilation) { } })); + await fs.writeFile(new URL('./manifest.json', scratchDir), JSON.stringify(manifest, (key, value) => { + if (value instanceof Map) { + return { + dataType: 'Map', + value: [...value] + }; + } else { + return value; + } + })); + await fs.writeFile(new URL('./graph.json', outputDir), JSON.stringify(graph)); } @@ -46,10 +83,10 @@ async function optimizeStaticPages(compilation, plugins) { return Promise.all(compilation.graph .filter(page => !page.isSSR || (page.isSSR && page.prerender) || (page.isSSR && compilation.config.prerender)) .map(async (page) => { - const { route, outputPath } = page; - const outputDirUrl = new URL(`.${outputPath.replace('index.html', '').replace('404.html', '')}`, outputDir); + const { route, outputHref } = page; + const outputDirUrl = new URL(outputHref.replace('index.html', '').replace('404.html', '')); const url = new URL(`http://localhost:${compilation.config.port}${route}`); - const contents = await fs.readFile(new URL(`./${outputPath}`, scratchDir), 'utf-8'); + const contents = await fs.readFile(new URL(`./${outputHref.replace(outputDir.href, '')}`, scratchDir), 'utf-8'); const headers = new Headers({ 'Content-Type': 'text/html' }); let response = new Response(contents, { headers }); @@ -68,9 +105,9 @@ async function optimizeStaticPages(compilation, plugins) { } // clean up optimization markers - const body = (await response.text()).replace(/data-gwd-opt=".*[a-z]"/g, ''); + 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(outputHref), body); }) ); } @@ -118,11 +155,27 @@ async function bundleStyleResources(compilation, resourcePlugins) { } else { const url = resource.sourcePathURL; const contentType = 'text/css'; - const headers = new Headers({ 'Content-Type': contentType }); + const headers = new Headers({ 'Content-Type': contentType, 'Accept': contentType }); const request = new Request(url, { headers }); const initResponse = new Response(contents, { headers }); let response = await resourcePlugins.reduce(async (responsePromise, plugin) => { + const intermediateResponse = await responsePromise; + const shouldPreIntercept = plugin.shouldPreIntercept && await plugin.shouldPreIntercept(url, request, intermediateResponse.clone()); + + if (shouldPreIntercept) { + const currentResponse = await plugin.preIntercept(url, request, intermediateResponse.clone()); + const mergedResponse = mergeResponse(intermediateResponse.clone(), currentResponse.clone()); + + if (mergedResponse.headers.get('Content-Type').indexOf(contentType) >= 0) { + return Promise.resolve(mergedResponse.clone()); + } + } + + return Promise.resolve(responsePromise); + }, Promise.resolve(initResponse)); + + response = await resourcePlugins.reduce(async (responsePromise, plugin) => { const intermediateResponse = await responsePromise; const shouldIntercept = plugin.shouldIntercept && await plugin.shouldIntercept(url, request, intermediateResponse.clone()); @@ -136,7 +189,7 @@ async function bundleStyleResources(compilation, resourcePlugins) { } return Promise.resolve(responsePromise); - }, Promise.resolve(initResponse)); + }, Promise.resolve(response.clone())); response = await resourcePlugins.reduce(async (responsePromise, plugin) => { const intermediateResponse = await responsePromise; @@ -163,95 +216,121 @@ async function bundleStyleResources(compilation, resourcePlugins) { async function bundleApiRoutes(compilation) { // https://rollupjs.org/guide/en/#differences-to-the-javascript-api - const [rollupConfig] = await getRollupConfigForApis(compilation); + const apiConfigs = await getRollupConfigForApiRoutes(compilation); - if (rollupConfig.input.length !== 0) { - const bundle = await rollup(rollupConfig); - await bundle.write(rollupConfig.output); + if (apiConfigs.length > 0 && apiConfigs[0].input.length !== 0) { + for (const configIndex in apiConfigs) { + const rollupConfig = apiConfigs[configIndex]; + const bundle = await rollup(rollupConfig); + await bundle.write(rollupConfig.output); + + } } } -async function bundleSsrPages(compilation) { - // https://rollupjs.org/guide/en/#differences-to-the-javascript-api - // TODO context plugins for SSR ? - // const contextPlugins = compilation.config.plugins.filter((plugin) => { - // return plugin.type === 'context'; - // }).map((plugin) => { - // return plugin.provider(compilation); - // }); - const hasSSRPages = compilation.graph.filter(page => page.isSSR).length > 0; +async function bundleSsrPages(compilation, optimizePlugins) { + const { context, config } = compilation; + const ssrPages = compilation.graph.filter(page => page.isSSR && !page.prerender); + const ssrPrerenderPagesRouteMapper = {}; const input = []; - if (!compilation.config.prerender && hasSSRPages) { - const htmlOptimizer = compilation.config.plugins.find(plugin => plugin.name === 'plugin-standard-html').provider(compilation); - const { executeModuleUrl } = compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider(); + if (!config.prerender && ssrPages.length > 0) { + const { executeModuleUrl } = config.plugins.find(plugin => plugin.type === 'renderer').provider(); const { executeRouteModule } = await import(executeModuleUrl); - const { pagesDir, scratchDir } = compilation.context; - - for (const page of compilation.graph) { - if (page.isSSR && !page.prerender) { - const { imports, route, template, title, relativeWorkspacePagePath } = page; - const entryFileUrl = new URL(`./_${relativeWorkspacePagePath.replace('/', '')}`, scratchDir); - const moduleUrl = new URL(`./${relativeWorkspacePagePath.replace('/', '')}`, pagesDir); - const outputPathRootUrl = new URL(`file://${path.dirname(entryFileUrl.pathname)}`); - const request = new Request(moduleUrl); // TODO not really sure how to best no-op this? - // TODO getTemplate has to be static (for now?) - // https://github.com/ProjectEvergreen/greenwood/issues/955 - const data = await executeRouteModule({ moduleUrl, compilation, page, prerender: false, htmlContents: null, scripts: [], request }); - 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 - - if (!await checkResourceExists(outputPathRootUrl)) { - await fs.mkdir(outputPathRootUrl, { - recursive: true - }); - } + const { pagesDir, scratchDir } = context; + + // one pass to generate initial static HTML and to track all combined static resources across layouts + // and before we optimize so that all bundled assets can tracked up front + // would be nice to see if this can be done in a single pass though... + for (const page of ssrPages) { + const { imports, route, layout, pageHref } = page; + const moduleUrl = new URL(pageHref); + const request = new Request(moduleUrl); + const data = await executeRouteModule({ moduleUrl, compilation, page, prerender: false, htmlContents: null, scripts: [], request }); + let staticHtml = ''; + + staticHtml = data.layout ? data.layout : await getPageLayout(pageHref, compilation, layout); + staticHtml = await getAppLayout(staticHtml, compilation, imports, page); + staticHtml = await getUserScripts(staticHtml, compilation); + staticHtml = await (await interceptPage(new URL(`http://localhost:8080${route}`), new Request(new URL(`http://localhost:8080${route}`)), getPluginInstances(compilation), staticHtml)).text(); + + await trackResourcesForRoute(staticHtml, compilation, route); + + ssrPrerenderPagesRouteMapper[route] = staticHtml; + } + + // technically this happens in the start of bundleCompilation once + // so might be nice to detect those static assets to see if they have be "de-duped" from bundling here + await bundleScriptResources(compilation); + await bundleStyleResources(compilation, optimizePlugins); + + // second pass to link all bundled assets to their resources before optimizing and generating SSR bundles + for (const page of ssrPages) { + const { id, route, pageHref } = page; + const pagePath = new URL(pageHref).pathname.replace(pagesDir.pathname, './'); + const entryFileUrl = new URL(pageHref); + const entryFileOutputUrl = new URL(`file://${entryFileUrl.pathname.replace(pagesDir.pathname, scratchDir.pathname)}`); + const outputPathRootUrl = new URL(`file://${path.dirname(entryFileOutputUrl.pathname)}/`); + const htmlOptimizer = config.plugins.find(plugin => plugin.name === 'plugin-standard-html').provider(compilation); + const pagesPathDiff = context.pagesDir.pathname.replace(context.projectDirectory.pathname, ''); + const relativeDepth = '../'.repeat(pagePath.split('/').length - 1); + + let staticHtml = ssrPrerenderPagesRouteMapper[route]; + staticHtml = await (await htmlOptimizer.optimize(new URL(`http://localhost:8080${route}`), new Response(staticHtml))).text(); + staticHtml = staticHtml.replace(/[`\\$]/g, '\\$&'); // https://stackoverflow.com/a/75688937/417806 - // better way to write out this inline code? - await fs.writeFile(entryFileUrl, ` - import { executeRouteModule } from '${normalizePathnameForWindows(executeModuleUrl)}'; + if (!await checkResourceExists(outputPathRootUrl)) { + await fs.mkdir(outputPathRootUrl, { + recursive: true + }); + } - export async function handler(request) { - const compilation = JSON.parse('${JSON.stringify(compilation)}'); - const page = JSON.parse('${JSON.stringify(page)}'); - const moduleUrl = '___GWD_ENTRY_FILE_URL=${relativeWorkspacePagePath.replace('/', '')}___'; - const data = await executeRouteModule({ moduleUrl, compilation, page, request }); - let staticHtml = \`${staticHtml}\`; + // better way to write out this inline code? + await fs.writeFile(entryFileOutputUrl, ` + import { executeRouteModule } from '${normalizePathnameForWindows(executeModuleUrl)}'; - if (data.body) { - staticHtml = staticHtml.replace(\/\(.*)<\\/content-outlet>\/s, data.body); - } + const moduleUrl = new URL('${relativeDepth}${pagesPathDiff}${pagePath.replace('./', '')}', import.meta.url); + + export async function handler(request) { + const compilation = JSON.parse('${JSON.stringify(compilation)}'); + const page = JSON.parse('${JSON.stringify(page)}'); + const data = await executeRouteModule({ moduleUrl, compilation, page, request }); + let staticHtml = \`${staticHtml}\`; - return new Response(staticHtml, { - headers: { - 'Content-Type': 'text/html' - } - }); + if (data.body) { + staticHtml = staticHtml.replace(\/\(.*)<\\/content-outlet>\/s, data.body); } - `); - input.push(normalizePathnameForWindows(moduleUrl)); - input.push(normalizePathnameForWindows(entryFileUrl)); - } + return new Response(staticHtml, { + headers: { + 'Content-Type': 'text/html' + } + }); + } + `); + + input.push({ + id, + inputPath: normalizePathnameForWindows(entryFileOutputUrl) + }); } - const [rollupConfig] = await getRollupConfigForSsr(compilation, input); + const ssrConfigs = await getRollupConfigForSsrPages(compilation, input); - if (rollupConfig.input.length > 0) { - const bundle = await rollup(rollupConfig); - await bundle.write(rollupConfig.output); + if (ssrConfigs.length > 0 && ssrConfigs[0].input !== '') { + console.info('bundling dynamic pages...'); + for (const configIndex in ssrConfigs) { + const rollupConfig = ssrConfigs[configIndex]; + const bundle = await rollup(rollupConfig); + await bundle.write(rollupConfig.output); + } } } } async function bundleScriptResources(compilation) { // https://rollupjs.org/guide/en/#differences-to-the-javascript-api - const [rollupConfig] = await getRollupConfigForScriptResources(compilation); + const [rollupConfig] = await getRollupConfigForBrowserScripts(compilation); if (rollupConfig.input.length !== 0) { const bundle = await rollup(rollupConfig); @@ -269,19 +348,22 @@ const bundleCompilation = async (compilation) => { return plugin.provider(compilation); }).filter((provider) => { return provider.shouldIntercept && provider.intercept + || provider.shouldPreIntercept && provider.preIntercept || provider.shouldOptimize && provider.optimize; }); console.info('bundling static assets...'); + // need styles bundled first for usage with import attributes syncing in Rollup + await bundleStyleResources(compilation, optimizeResourcePlugins); + await Promise.all([ await bundleApiRoutes(compilation), - await bundleScriptResources(compilation), - await bundleStyleResources(compilation, optimizeResourcePlugins) + await bundleScriptResources(compilation) ]); // bundleSsrPages depends on bundleScriptResources having run first - await bundleSsrPages(compilation); + await bundleSsrPages(compilation, optimizeResourcePlugins); console.info('optimizing static pages....'); await optimizeStaticPages(compilation, optimizeResourcePlugins); diff --git a/packages/cli/src/lifecycles/compile.js b/packages/cli/src/lifecycles/compile.js index 8b09af2a7..68ce2d0dc 100644 --- a/packages/cli/src/lifecycles/compile.js +++ b/packages/cli/src/lifecycles/compile.js @@ -1,8 +1,8 @@ import { checkResourceExists } from '../lib/resource-utils.js'; import { generateGraph } from './graph.js'; import { initContext } from './context.js'; +import { readAndMergeConfig } from './config.js'; import fs from 'fs/promises'; -import { readAndMergeConfig as initConfig } from './config.js'; const generateCompilation = () => { return new Promise(async (resolve, reject) => { @@ -20,9 +20,9 @@ const generateCompilation = () => { }; console.info('Initializing project config'); - compilation.config = await initConfig(); + compilation.config = await readAndMergeConfig(); - // determine whether to use default template or user detected workspace + // determine whether to use default layout or user detected workspace console.info('Initializing project workspace contexts'); compilation.context = await initContext(compilation); diff --git a/packages/cli/src/lifecycles/config.js b/packages/cli/src/lifecycles/config.js index e9b9dd400..37b0621a8 100644 --- a/packages/cli/src/lifecycles/config.js +++ b/packages/cli/src/lifecycles/config.js @@ -46,12 +46,17 @@ const defaultConfig = { port: 8080, basePath: '', optimization: optimizations[0], - interpolateFrontmatter: false, + activeContent: false, plugins: greenwoodPlugins, markdown: { plugins: [], settings: {} }, prerender: false, + isolation: false, pagesDirectory: 'pages', - templatesDirectory: 'templates' + layoutsDirectory: 'layouts', + polyfills: { + importAttributes: null, // or ['css', 'json'] + importMaps: false + } }; const readAndMergeConfig = async() => { @@ -76,7 +81,8 @@ const readAndMergeConfig = async() => { if (hasConfigFile) { const userCfgFile = (await import(configUrl)).default; - const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, templatesDirectory, interpolateFrontmatter } = userCfgFile; + // eslint-disable-next-line max-len + const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, layoutsDirectory, activeContent, isolation, polyfills } = userCfgFile; // workspace validation if (workspace) { @@ -97,11 +103,11 @@ const readAndMergeConfig = async() => { reject(`Error: provided optimization "${optimization}" is not supported. Please use one of: ${optimizations.join(', ')}.`); } - if (interpolateFrontmatter) { - if (typeof interpolateFrontmatter !== 'boolean') { - reject('Error: greenwood.config.js interpolateFrontmatter must be a boolean'); + if (activeContent) { + if (typeof activeContent !== 'boolean') { + reject('Error: greenwood.config.js activeContent must be a boolean'); } - customConfig.interpolateFrontmatter = interpolateFrontmatter; + customConfig.activeContent = activeContent; } if (plugins && plugins.length > 0) { @@ -204,10 +210,10 @@ const readAndMergeConfig = async() => { reject(`Error: provided pagesDirectory "${pagesDirectory}" is not supported. Please make sure to pass something like 'docs/'`); } - if (templatesDirectory && typeof templatesDirectory === 'string') { - customConfig.templatesDirectory = templatesDirectory; - } else if (templatesDirectory) { - reject(`Error: provided templatesDirectory "${templatesDirectory}" is not supported. Please make sure to pass something like 'layouts/'`); + if (layoutsDirectory && typeof layoutsDirectory === 'string') { + customConfig.layoutsDirectory = layoutsDirectory; + } else if (layoutsDirectory) { + reject(`Error: provided layoutsDirectory "${layoutsDirectory}" is not supported. Please make sure to pass something like 'layouts/'`); } if (prerender !== undefined) { @@ -223,6 +229,14 @@ const readAndMergeConfig = async() => { customConfig.prerender = false; } + if (isolation !== undefined) { + if (typeof isolation === 'boolean') { + customConfig.isolation = isolation; + } else { + reject(`Error: greenwood.config.js isolation must be a boolean; true or false. Passed value was typeof: ${typeof staticRouter}`); + } + } + if (staticRouter !== undefined) { if (typeof staticRouter === 'boolean') { customConfig.staticRouter = staticRouter; @@ -230,6 +244,28 @@ const readAndMergeConfig = async() => { reject(`Error: greenwood.config.js staticRouter must be a boolean; true or false. Passed value was typeof: ${typeof staticRouter}`); } } + + if (polyfills !== undefined) { + const { importMaps, importAttributes } = polyfills; + + customConfig.polyfills = {}; + + if (importMaps) { + if (typeof importMaps === 'boolean') { + customConfig.polyfills.importMaps = true; + } else { + reject(`Error: greenwood.config.js polyfills.importMaps must be a boolean; true or false. Passed value was typeof: ${typeof importMaps}`); + } + } + + if (importAttributes) { + if (Array.isArray(importAttributes)) { + customConfig.polyfills.importAttributes = importAttributes; + } else { + reject(`Error: greenwood.config.js polyfills.importAttributes must be an array of types; ['css', 'json']. Passed value was typeof: ${typeof importAttributes}`); + } + } + } } else { // SPA should _not_ prerender unless if user has specified prerender should be true if (isSPA) { diff --git a/packages/cli/src/lifecycles/context.js b/packages/cli/src/lifecycles/context.js index d49ae226b..e4f45b564 100644 --- a/packages/cli/src/lifecycles/context.js +++ b/packages/cli/src/lifecycles/context.js @@ -5,17 +5,17 @@ const initContext = async({ config }) => { return new Promise(async (resolve, reject) => { try { - const { workspace, pagesDirectory, templatesDirectory } = config; + const { workspace, pagesDirectory, layoutsDirectory } = config; const projectDirectory = new URL(`file://${process.cwd()}/`); const scratchDir = new URL('./.greenwood/', projectDirectory); const outputDir = new URL('./public/', projectDirectory); const dataDir = new URL('../data/', import.meta.url); - const templatesDir = new URL('../templates/', import.meta.url); + const layoutsDir = new URL('../layouts/', import.meta.url); const userWorkspace = workspace; - const apisDir = new URL('./api/', userWorkspace); const pagesDir = new URL(`./${pagesDirectory}/`, userWorkspace); - const userTemplatesDir = new URL(`./${templatesDirectory}/`, userWorkspace); + const apisDir = new URL('./api/', pagesDir); + const userLayoutsDir = new URL(`./${layoutsDirectory}/`, userWorkspace); const context = { dataDir, @@ -23,10 +23,10 @@ const initContext = async({ config }) => { userWorkspace, apisDir, pagesDir, - userTemplatesDir, + userLayoutsDir, scratchDir, projectDirectory, - templatesDir + layoutsDir }; if (!await checkResourceExists(scratchDir)) { diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js index 40b95f7a9..456b832fd 100644 --- a/packages/cli/src/lifecycles/graph.js +++ b/packages/cli/src/lifecycles/graph.js @@ -2,31 +2,62 @@ import fs from 'fs/promises'; import fm from 'front-matter'; import { checkResourceExists, requestAsObject } from '../lib/resource-utils.js'; +import { activeFrontmatterKeys } from '../lib/content-utils.js'; import toc from 'markdown-toc'; import { Worker } from 'worker_threads'; +function getLabelFromRoute(_route) { + let route = _route; + + if (route === '/index/') { + return 'Home'; + } else if (route.endsWith('/index/')) { + route = route.replace('index/', ''); + } + + return route + .split('/') + .filter(part => part !== '') + .pop() + .split('-') + .map((routePart) => { + return `${routePart.charAt(0).toUpperCase()}${routePart.substring(1)}`; + }) + .join(' '); +} + +function getIdFromRelativePathPath(relativePathPath, extension) { + return relativePathPath.replace(extension, '').replace('./', '').replace(/\//g, '-'); +} + const generateGraph = async (compilation) => { return new Promise(async (resolve, reject) => { try { const { context, config } = compilation; const { basePath } = config; - const { apisDir, pagesDir, projectDirectory, userWorkspace } = context; + const { pagesDir, userWorkspace, outputDir } = context; + const collections = {}; + const customPageFormatPlugins = config.plugins + .filter(plugin => plugin.type === 'resource' && !plugin.isGreenwoodDefaultPlugin) + .map(plugin => plugin.provider(compilation)); + + let apis = new Map(); let graph = [{ - outputPath: '/index.html', - filename: 'index.html', - path: '/', - route: `${basePath}/`, id: 'index', - label: 'Index', + outputHref: new URL('./index.html', outputDir).href, + route: `${basePath}/`, + label: 'Home', + title: null, data: {}, imports: [], resources: [], - prerender: true + prerender: true, + isolation: false }]; - const walkDirectoryForPages = async function(directory, pages = []) { - const files = await fs.readdir(directory); + const walkDirectoryForPages = async function(directory, pages = [], apiRoutes = new Map()) { + const files = (await fs.readdir(directory)).filter(file => !file.startsWith('.')); for (const filename of files) { const filenameUrl = new URL(`./${filename}`, directory); @@ -34,239 +65,240 @@ const generateGraph = async (compilation) => { const isDirectory = await checkResourceExists(filenameUrlAsDir) && (await fs.stat(filenameUrlAsDir)).isDirectory(); if (isDirectory) { - pages = await walkDirectoryForPages(filenameUrlAsDir, pages); + const nextPages = await walkDirectoryForPages(filenameUrlAsDir, pages, apiRoutes); + + pages = nextPages.pages; + apiRoutes = nextPages.apiRoutes; } else { const extension = `.${filenameUrl.pathname.split('.').pop()}`; - const isStatic = extension === '.md' || extension === '.html'; - const isDynamic = extension === '.js'; - const relativePagePath = filenameUrl.pathname.replace(pagesDir.pathname, '/'); - const relativeWorkspacePath = directory.pathname.replace(projectDirectory.pathname, ''); - let route = relativePagePath.replace(extension, ''); - let id = filename.split('/')[filename.split('/').length - 1].replace(extension, ''); - let template = 'page'; - let title = null; - let imports = []; - let customData = {}; - let filePath; - let prerender = true; - - /* - * check if additional nested directories exist to correctly determine route (minus filename) - * examples: - * - pages/index.{html,md,js} -> / - * - pages/about.{html,md,js} -> /about/ - * - pages/blog/index.{html,md,js} -> /blog/ - * - pages/blog/some-post.{html,md,js} -> /blog/some-post/ - */ - if (relativePagePath.lastIndexOf('/') > 0) { - // https://github.com/ProjectEvergreen/greenwood/issues/455 - route = id === 'index' || route.replace('/index', '') === `/${id}` - ? route.replace('index', '') - : `${route}/`; - } else { - route = route === '/index' - ? '/' - : `${route}/`; + const relativePagePath = filenameUrl.pathname.replace(pagesDir.pathname, './'); + const isApiRoute = relativePagePath.startsWith('./api'); + const req = isApiRoute + ? new Request(filenameUrl) + : new Request(filenameUrl, { headers: { 'Accept': 'text/html' } }); + let isCustom = null; + + for (const plugin of customPageFormatPlugins) { + if (plugin.shouldServe && await plugin.shouldServe(filenameUrl, req)) { + isCustom = plugin.servePage; + break; + } } - if (isStatic) { - const fileContents = await fs.readFile(filenameUrl, 'utf8'); - const { attributes } = fm(fileContents); + const isStatic = isCustom === 'static' || extension === '.md' || extension === '.html'; + const isDynamic = isCustom === 'dynamic' || extension === '.js'; + const isPage = isStatic || isDynamic; + let route = `${relativePagePath.replace('.', '').replace(`${extension}`, '')}`; + let fileContents; - template = attributes.template || 'page'; - title = attributes.title || title; - id = attributes.label || id; - imports = attributes.imports || []; - filePath = `${relativeWorkspacePath}${filename}`; + if (isApiRoute) { + const extension = filenameUrl.pathname.split('.').pop(); - // prune "reserved" attributes that are supported by Greenwood - // https://www.greenwoodjs.io/docs/front-matter - customData = attributes; - - delete customData.label; - delete customData.imports; - delete customData.title; - delete customData.template; - - /* Menu Query - * Custom front matter - Variable Definitions - * -------------------------------------------------- - * menu: the name of the menu in which this item can be listed and queried - * index: the index of this list item within a menu - * linkheadings: flag to tell us where to add page's table of contents as menu items - * tableOfContents: json object containing page's table of contents(list of headings) + if (extension !== 'js' && !isCustom) { + console.warn(`${filenameUrl} is not a supported API file extension, skipping...`); + return; + } + + // should this be run in isolation like SSR pages? + // https://github.com/ProjectEvergreen/greenwood/issues/991 + const { isolation } = await import(filenameUrl).then(module => module); + + /* + * API Properties (per route) + *---------------------- + * id: unique hyphen delimited string of the filename, relative to the page/api directory + * pageHref: href to the page's filesystem file + * outputHref: href of the filename to write to when generating a build + * route: URL route for a given page on outputFilePath + * isolation: if this should be run in isolated mode */ - // set specific menu to place this page - customData.menu = customData.menu || ''; + apiRoutes.set(`${basePath}${route}`, { + id: getIdFromRelativePathPath(relativePagePath, `.${extension}`).replace('api-', ''), + pageHref: new URL(relativePagePath, pagesDir).href, + outputHref: new URL(relativePagePath, outputDir).href, + route: `${basePath}${route}`, + isolation + }); + } else if (isPage) { + let root = filename.split('/')[filename.split('/').length - 1].replace(extension, ''); + let layout = extension === '.html' ? null : 'page'; + let title = null; + let label = getLabelFromRoute(`${route}/`); + let imports = []; + let customData = {}; + let prerender = true; + let isolation = false; + let hydration = false; - // set specific index list priority of this item within a menu - customData.index = customData.index || ''; + /* + * check if additional nested directories exist to correctly determine route (minus filename) + * examples: + * - pages/index.{html,md,js} -> / + * - pages/about.{html,md,js} -> /about/ + * - pages/blog/index.{html,md,js} -> /blog/ + * - pages/blog/some-post.{html,md,js} -> /blog/some-post/ + */ + if (relativePagePath.lastIndexOf('/index') > 0) { + // https://github.com/ProjectEvergreen/greenwood/issues/455 + route = root === 'index' || route.replace('/index', '') === `/${root}` + ? route.replace('index', '') + : `${route}/`; + } else { + route = route === '/index' + ? '/' + : `${route}/`; + } + + if (isStatic) { + fileContents = await fs.readFile(filenameUrl, 'utf8'); + const { attributes } = fm(fileContents); + + layout = attributes.layout || layout; + title = attributes.title || title; + label = attributes.label || label; + imports = attributes.imports || []; + + customData = attributes; + } else if (isDynamic) { + const routeWorkerUrl = compilation.config.plugins.filter(plugin => plugin.type === 'renderer')[0].provider(compilation).executeModuleUrl; + let ssrFrontmatter; + + await new Promise(async (resolve, reject) => { + const worker = new Worker(new URL('../lib/ssr-route-worker.js', import.meta.url)); + const request = await requestAsObject(new Request(filenameUrl)); + + worker.on('message', async (result) => { + prerender = result.prerender ?? false; + isolation = result.isolation ?? isolation; + hydration = result.hydration ?? hydration; + + if (result.frontmatter) { + result.frontmatter.imports = result.frontmatter.imports || []; + ssrFrontmatter = result.frontmatter; + } + + resolve(); + }); + worker.on('error', reject); + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker stopped with exit code ${code}`)); + } + }); + + worker.postMessage({ + executeModuleUrl: routeWorkerUrl.href, + moduleUrl: filenameUrl.href, + compilation: JSON.stringify(compilation), + page: JSON.stringify({ + servePage: isCustom, + route, + root, + label + }), + request + }); + }); + + if (ssrFrontmatter) { + layout = ssrFrontmatter.layout || layout; + title = ssrFrontmatter.title || title; + imports = ssrFrontmatter.imports || imports; + label = ssrFrontmatter.label || label; + customData = ssrFrontmatter || customData; + } + } + + /* + * Custom front matter - Variable Definitions + * -------------------------------------------------- + * collection: the name of the collection for the page + * order: the order of this item within the collection + * tocHeading: heading size to use a Table of Contents for a page + * tableOfContents: json object containing page's table of contents (list of headings) + */ + + // prune "reserved" attributes that are supported by Greenwood + [...activeFrontmatterKeys, 'layout'].forEach((key) => { + delete customData[key]; + }); // set flag whether to gather a list of headings on a page as menu items - customData.linkheadings = customData.linkheadings || 0; + customData.tocHeading = customData.tocHeading || 0; customData.tableOfContents = []; - if (customData.linkheadings > 0) { + if (fileContents && customData.tocHeading > 0 && customData.tocHeading <= 6) { // parse markdown for table of contents and output to json customData.tableOfContents = toc(fileContents).json; customData.tableOfContents.shift(); // parse table of contents for only the pages user wants linked - if (customData.tableOfContents.length > 0 && customData.linkheadings > 0) { + if (customData.tableOfContents.length > 0 && customData.tocHeading > 0) { customData.tableOfContents = customData.tableOfContents - .filter((item) => item.lvl === customData.linkheadings); + .filter((item) => item.lvl === customData.tocHeading); } } - /* ---------End Menu Query-------------------- */ - } else if (isDynamic) { - const routeWorkerUrl = compilation.config.plugins.filter(plugin => plugin.type === 'renderer')[0].provider(compilation).executeModuleUrl; - let ssrFrontmatter; - - filePath = route; - - await new Promise(async (resolve, reject) => { - const worker = new Worker(new URL('../lib/ssr-route-worker.js', import.meta.url)); - // TODO "faux" new Request here, a better way? - const request = await requestAsObject(new Request(filenameUrl)); - worker.on('message', async (result) => { - prerender = result.prerender; - - if (result.frontmatter) { - result.frontmatter.imports = result.frontmatter.imports || []; - ssrFrontmatter = result.frontmatter; - } - - resolve(); - }); - worker.on('error', reject); - worker.on('exit', (code) => { - if (code !== 0) { - reject(new Error(`Worker stopped with exit code ${code}`)); - } - }); - - worker.postMessage({ - executeModuleUrl: routeWorkerUrl.href, - moduleUrl: filenameUrl.href, - compilation: JSON.stringify(compilation), - // TODO need to get as many of these params as possible - // or ignore completely? - page: JSON.stringify({ - route, - id, - label: id.split('-') - .map((idPart) => { - return `${idPart.charAt(0).toUpperCase()}${idPart.substring(1)}`; - }).join(' ') - }), - request - }); - }); + /* + * Page Properties + *---------------------- + * id: unique hyphen delimited string of the filename, relative to the pages directory + * label: Display text for the page inferred, by default is the value of title + * title: used to customize the tag of the page, inferred from the filename + * route: URL for accessing the page from the browser + * layout: the custom layout of the page + * data: custom page frontmatter + * imports: per page JS or CSS file imports specified from frontmatter + * resources: all script, style, etc resources for the entire page as URLs + * outputHref: href to the file in the output folder + * pageHref: href to the page's filesystem file + * isSSR: if this is a server side route + * prerender: if this page should be statically exported + * isolation: if this page should be run in isolated mode + * hydration: if this page needs hydration support + * servePage: signal that this is a custom page file type (static | dynamic) + */ + const page = { + id: getIdFromRelativePathPath(relativePagePath, extension), + label, + title, + route: `${basePath}${route}`, + layout, + data: customData || {}, + imports, + resources: [], + pageHref: new URL(relativePagePath, pagesDir).href, + outputHref: route === '/404/' + ? new URL('./404.html', outputDir).href + : new URL(`.${route}index.html`, outputDir).href, + isSSR: !isStatic, + prerender, + isolation, + hydration, + servePage: isCustom + }; + + pages.push(page); + + // handle collections + const pageCollection = customData.collection; + + if (pageCollection) { + if (!collections[pageCollection]) { + collections[pageCollection] = []; + } - if (ssrFrontmatter) { - template = ssrFrontmatter.template || template; - title = ssrFrontmatter.title || title; - imports = ssrFrontmatter.imports || imports; - customData = ssrFrontmatter.data || customData; - - /* Menu Query - * Custom front matter - Variable Definitions - * -------------------------------------------------- - * menu: the name of the menu in which this item can be listed and queried - * index: the index of this list item within a menu - * linkheadings: flag to tell us where to add page's table of contents as menu items - * tableOfContents: json object containing page's table of contents(list of headings) - */ - customData.menu = ssrFrontmatter.menu || ''; - customData.index = ssrFrontmatter.index || ''; + collections[pageCollection].push(page); } - } else { - console.debug(`Unhandled extension (.${extension}) for route => ${route}`); - } - - /* - * Graph Properties (per page) - *---------------------- - * data: custom page frontmatter - * filename: base filename of the page - * id: filename without the extension - * relativeWorkspacePagePath: the file path relative to the user's workspace directory - * label: "pretty" text representation of the filename - * imports: per page JS or CSS file imports to be included in HTML output from frontmatter - * resources: sum of all resources for the entire page - * outputPath: the filename to write to when generating static HTML - * path: path to the file relative to the workspace - * route: URL route for a given page on outputFilePath - * template: page template to use as a base for a generated component - * title: a default value that can be used for - * isSSR: if this is a server side route - * prerednder: if this should be statically exported - */ - pages.push({ - data: customData || {}, - filename, - id, - relativeWorkspacePagePath: relativePagePath, - label: id.split('-') - .map((idPart) => { - return `${idPart.charAt(0).toUpperCase()}${idPart.substring(1)}`; - }).join(' '), - imports, - resources: [], - outputPath: route === '/404/' - ? '/404.html' - : `${route}index.html`, - path: filePath, - route: `${basePath}${route}`, - template, - title, - isSSR: !isStatic, - prerender - }); - } - } - - return pages; - }; - - const walkDirectoryForApis = async function(directory, apis = new Map()) { - const files = await fs.readdir(directory); - - for (const filename of files) { - const filenameUrl = new URL(`./${filename}`, directory); - const filenameUrlAsDir = new URL(`./${filename}/`, directory); - const isDirectory = await checkResourceExists(filenameUrlAsDir) && (await fs.stat(filenameUrlAsDir)).isDirectory(); - if (isDirectory) { - apis = await walkDirectoryForApis(filenameUrlAsDir, apis); - } else { - const extension = filenameUrl.pathname.split('.').pop(); - const relativeApiPath = filenameUrl.pathname.replace(userWorkspace.pathname, '/'); - const route = `${basePath}${relativeApiPath.replace(`.${extension}`, '')}`; - - if (extension !== 'js') { - console.warn(`${filenameUrl} is not a JavaScript file, skipping...`); + compilation.collections = collections; } else { - /* - * API Properties (per route) - *---------------------- - * filename: base filename of the page - * outputPath: the filename to write to when generating a build - * path: path to the file relative to the workspace - * route: URL route for a given page on outputFilePath - */ - apis.set(route, { - filename: filename, - outputPath: `/api/${filename}`, - path: relativeApiPath, - route - }); + console.warn(`Unsupported format detected for page => ${filename}`); } } } - return apis; + return { pages, apiRoutes }; }; console.debug('building from local sources...'); @@ -275,13 +307,15 @@ const generateGraph = async (compilation) => { if (await checkResourceExists(new URL('./index.html', userWorkspace))) { graph = [{ ...graph[0], - path: `${userWorkspace.pathname}index.html`, + pageHref: new URL('./index.html', userWorkspace).href, isSPA: true }]; } else { const oldGraph = graph[0]; + const pages = await checkResourceExists(pagesDir) ? await walkDirectoryForPages(pagesDir) : { pages: graph, apiRoutes: apis }; - graph = await checkResourceExists(pagesDir) ? await walkDirectoryForPages(pagesDir) : graph; + graph = pages.pages; + apis = pages.apiRoutes; const has404Page = graph.find(page => page.route.endsWith('/404/')); @@ -296,12 +330,13 @@ const generateGraph = async (compilation) => { ...graph, { ...oldGraph, - outputPath: '/404.html', - filename: '404.html', + id: '404', + outputHref: new URL('./404.html', outputDir).href, + pageHref: new URL('./404.html', pagesDir).href, route: `${basePath}/404/`, path: '404.html', - id: '404', - label: 'Not Found' + label: 'Not Found', + title: 'Page Not Found' } ]; } @@ -323,12 +358,11 @@ const generateGraph = async (compilation) => { } graph.push({ - filename: null, - path: null, + pageHref: null, data: {}, imports: [], resources: [], - outputPath: `${node.route}index.html`, + outputHref: new URL(`.${node.route}index.html`, outputDir).href, ...node, external: true }); @@ -337,12 +371,7 @@ const generateGraph = async (compilation) => { } compilation.graph = graph; - - if (await checkResourceExists(apisDir)) { - const apis = await walkDirectoryForApis(apisDir); - - compilation.manifest = { apis }; - } + compilation.manifest = { apis }; resolve(compilation); } catch (err) { diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index 6eb1a3438..785c95d61 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -1,10 +1,8 @@ import fs from 'fs/promises'; -import { checkResourceExists, trackResourcesForRoute } from '../lib/resource-utils.js'; +import { checkResourceExists, trackResourcesForRoute, mergeResponse } from '../lib/resource-utils.js'; import os from 'os'; import { WorkerPool } from '../lib/threadpool.js'; -// TODO a lot of these are duplicated in the build lifecycle too -// would be good to refactor async function createOutputDirectory(route, outputDir) { if (!route.endsWith('/404/') && !await checkResourceExists(outputDir)) { await fs.mkdir(outputDir, { @@ -32,8 +30,12 @@ async function interceptPage(url, request, plugins, body) { }); for (const plugin of plugins) { - if (plugin.shouldIntercept && await plugin.shouldIntercept(url, request, response)) { - response = await plugin.intercept(url, request, response); + if (plugin.shouldPreIntercept && await plugin.shouldPreIntercept(url, request, response.clone())) { + response = mergeResponse(response, await plugin.preIntercept(url, request, response.clone())); + } + + if (plugin.shouldIntercept && await plugin.shouldIntercept(url, request, response.clone())) { + response = mergeResponse(response, await plugin.intercept(url, request, response.clone())); } } @@ -48,9 +50,15 @@ function getPluginInstances (compilation) { }); } +function toScratchUrl(outputHref, context) { + const { outputDir, scratchDir } = context; + + return new URL(`./${outputHref.replace(outputDir.href, '')}`, scratchDir); +} + async function preRenderCompilationWorker(compilation, workerPrerender) { const pages = compilation.graph.filter(page => !page.isSSR || (page.isSSR && page.prerender) || (page.isSSR && compilation.config.prerender)); - const { scratchDir } = compilation.context; + const { context, config } = compilation; const plugins = getPluginInstances(compilation); console.info('pages to generate', `\n ${pages.map(page => page.route).join('\n ')}`); @@ -58,18 +66,32 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { const pool = new WorkerPool(os.cpus().length, new URL('../lib/ssr-route-worker.js', import.meta.url)); for (const page of pages) { - const { route, outputPath, resources } = page; - const outputPathUrl = new URL(`.${outputPath}`, scratchDir); - const url = new URL(`http://localhost:${compilation.config.port}${route}`); + const { route, outputHref } = page; + const scratchUrl = toScratchUrl(outputHref, context); + const url = new URL(`http://localhost:${config.port}${route}`); const request = new Request(url); + let ssrContents; + // do we negate the worker pool by also running this, outside the pool? let body = await (await servePage(url, request, plugins)).text(); body = await (await interceptPage(url, request, plugins, body)).text(); - await createOutputDirectory(route, new URL(outputPathUrl.href.replace('index.html', ''))); + // hack to avoid over-rendering SSR content + // https://github.com/ProjectEvergreen/greenwood/issues/1044 + // https://github.com/ProjectEvergreen/greenwood/issues/988#issuecomment-1288168858 + if (page.isSSR) { + const ssrContentsMatch = /(.*.)/s; + + ssrContents = body.match(ssrContentsMatch)[0]; + body = body.replace(ssrContents, ''); + ssrContents = ssrContents + .replace('', '') + .replace('', ''); + } + + const resources = await trackResourcesForRoute(body, compilation, route); const scripts = resources - .map(resource => compilation.resources.get(resource)) .filter(resource => resource.type === 'script') .map(resource => resource.sourcePathURL.href); @@ -91,57 +113,67 @@ async function preRenderCompilationWorker(compilation, workerPrerender) { }); }); - await fs.writeFile(outputPathUrl, body); + if (page.isSSR) { + body = body.replace('', ssrContents); + } + + await createOutputDirectory(route, new URL(scratchUrl.href.replace('index.html', ''))); + await fs.writeFile(scratchUrl, body); console.info('generated page...', route); } } async function preRenderCompilationCustom(compilation, customPrerender) { - const { scratchDir } = compilation.context; + const { config, context } = compilation; const renderer = (await import(customPrerender.customUrl)).default; + const { importMaps } = config.polyfills; console.info('pages to generate', `\n ${compilation.graph.map(page => page.route).join('\n ')}`); await renderer(compilation, async (page, body) => { - const { route, outputPath } = page; - const outputPathUrl = new URL(`.${outputPath}`, scratchDir); + const { route, outputHref } = page; + const scratchUrl = toScratchUrl(outputHref, context); // clean up special Greenwood dev only assets that would come through if prerendering with a headless browser - body = body.replace(/ + `); + + // Greenwood active frontmatter keys + for (const key of activeFrontmatterKeys) { + const interpolatedFrontmatter = '\\$\\{globalThis.page.' + key + '\\}'; + const needle = key === 'title' && !matchingRoute.title + ? matchingRoute.label + : matchingRoute[key]; + + newBody = newBody.replace(new RegExp(interpolatedFrontmatter, 'g'), needle); + } + + // custom user frontmatter data + for (const fm in matchingRoute.data) { + const interpolatedFrontmatter = '\\$\\{globalThis.page.data.' + fm + '\\}'; + const needle = typeof matchingRoute.data[fm] === 'string' ? matchingRoute.data[fm] : JSON.stringify(matchingRoute.data[fm]).replace(/"/g, '"'); + + newBody = newBody.replace(new RegExp(interpolatedFrontmatter, 'g'), needle); + } + + // collections + for (const collection in this.compilation.collections) { + const interpolatedFrontmatter = '\\$\\{globalThis.collection.' + collection + '\\}'; + const cleanedCollections = cleanContentCollection(this.compilation.collections[collection]); + + newBody = newBody.replace(new RegExp(interpolatedFrontmatter, 'g'), JSON.stringify(cleanedCollections).replace(/"/g, '"')); + } + + return new Response(newBody); + } + + async shouldOptimize(url, response) { + const { activeContent } = this.compilation.config; + + return response.headers.get('Content-Type').indexOf(this.contentType[0]) >= 0 && activeContent; + } + + async optimize(url, response) { + let body = await response.text(); + + body = body.replace('', ` + + + `); + + return new Response(body); + } +} + +const greenwoodPluginContentAsData = { + type: 'resource', + name: 'plugin-active-content', + provider: (compilation) => new ContentAsDataResource(compilation) +}; + +export { greenwoodPluginContentAsData }; \ No newline at end of file diff --git a/packages/cli/src/plugins/resource/plugin-api-routes.js b/packages/cli/src/plugins/resource/plugin-api-routes.js index 6228f3e97..6db1656cd 100644 --- a/packages/cli/src/plugins/resource/plugin-api-routes.js +++ b/packages/cli/src/plugins/resource/plugin-api-routes.js @@ -20,7 +20,7 @@ class ApiRoutesResource extends ResourceInterface { async serve(url, request) { const api = this.compilation.manifest.apis.get(url.pathname); - const apiUrl = new URL(`.${api.path}`, this.compilation.context.userWorkspace); + const apiUrl = new URL(api.pageHref); const href = apiUrl.href; if (process.env.__GWD_COMMAND__ === 'develop') { // eslint-disable-line no-underscore-dangle diff --git a/packages/cli/src/plugins/resource/plugin-node-modules.js b/packages/cli/src/plugins/resource/plugin-node-modules.js index 475efbf18..76faabfef 100644 --- a/packages/cli/src/plugins/resource/plugin-node-modules.js +++ b/packages/cli/src/plugins/resource/plugin-node-modules.js @@ -10,7 +10,7 @@ import replace from '@rollup/plugin-replace'; import { getNodeModulesLocationForPackage, getPackageJson, getPackageNameFromUrl } from '../../lib/node-modules-utils.js'; import { resolveForRelativeUrl } from '../../lib/resource-utils.js'; import { ResourceInterface } from '../../lib/resource-interface.js'; -import { walkPackageJson } from '../../lib/walker-package-ranger.js'; +import { walkPackageJson, mergeImportMap } from '../../lib/walker-package-ranger.js'; let importMap; @@ -29,7 +29,7 @@ class NodeModulesResource extends ResourceInterface { // https://github.com/ProjectEvergreen/greenwood/issues/953v async resolve(url) { const { projectDirectory } = this.compilation.context; - const { pathname } = url; + const { pathname, searchParams } = url; const packageName = getPackageNameFromUrl(pathname); const absoluteNodeModulesLocation = await getNodeModulesLocationForPackage(packageName); const packagePathPieces = pathname.split('node_modules/')[1].split('/'); // double split to handle node_modules within nested paths @@ -37,8 +37,11 @@ class NodeModulesResource extends ResourceInterface { const absoluteNodeModulesPathname = absoluteNodeModulesLocation ? `${absoluteNodeModulesLocation}${packagePathPieces.join('/').replace(packageName, '')}` : (await resolveForRelativeUrl(url, projectDirectory)).pathname; + const params = searchParams.size > 0 + ? `?${searchParams.toString()}` + : ''; - return new Request(`file://${absoluteNodeModulesPathname}`); + return new Request(`file://${absoluteNodeModulesPathname}${params}`); } async shouldServe(url) { @@ -70,11 +73,13 @@ class NodeModulesResource extends ResourceInterface { } async intercept(url, request, response) { - const { context } = this.compilation; + const { context, config } = this.compilation; + const { importMaps } = config.polyfills; + const importMapShimScript = importMaps ? '' : ''; let body = await response.text(); const hasHead = body.match(/\(.*)<\/head>/s); - if (hasHead && hasHead.length > 0) { + if (importMaps && hasHead && hasHead.length > 0) { const contents = hasHead[0].replace(/type="module"/g, 'type="module-shim"'); body = body.replace(/\(.*)<\/head>/s, contents.replace(/\$/g, '$$$')); // https://github.com/ProjectEvergreen/greenwood/issues/656); @@ -91,15 +96,10 @@ class NodeModulesResource extends ResourceInterface { ? await walkPackageJson(userPackageJson) : importMap || {}; - // apply import map and shim for users + body = mergeImportMap(body, importMap, importMaps); body = body.replace('', ` - - + ${importMapShimScript} `); return new Response(body); diff --git a/packages/cli/src/plugins/resource/plugin-standard-css.js b/packages/cli/src/plugins/resource/plugin-standard-css.js index d52c723e2..c4c0ed9af 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-css.js +++ b/packages/cli/src/plugins/resource/plugin-standard-css.js @@ -5,10 +5,13 @@ * */ import fs from 'fs'; +import path from 'path'; import { parse, walk } from 'css-tree'; import { ResourceInterface } from '../../lib/resource-interface.js'; +import { hashString } from '../../lib/hashing-utils.js'; -function bundleCss(body, url, projectDirectory) { +function bundleCss(body, url, compilation) { + const { projectDirectory, outputDir, userWorkspace } = compilation.context; const ast = parse(body, { onParseError(error) { console.log(error.formattedMessage); @@ -18,7 +21,7 @@ function bundleCss(body, url, projectDirectory) { walk(ast, { enter: function (node, item) { // eslint-disable-line complexity - const { type, name, value } = node; + const { type, name, value, children } = node; if ((type === 'String' || type === 'Url') && this.atrulePrelude && this.atrule.name === 'import') { const { value } = node; @@ -29,10 +32,49 @@ function bundleCss(body, url, projectDirectory) { : new URL(value, url); const importContents = fs.readFileSync(resolvedUrl, 'utf-8'); - optimizedCss += bundleCss(importContents, url, projectDirectory); + optimizedCss += bundleCss(importContents, url, compilation); } else { optimizedCss += `@import url('${value}');`; } + } else if (type === 'Url' && this.atrule?.name !== 'import') { + if (value.startsWith('http') || value.startsWith('//') || value.startsWith('data:')) { + optimizedCss += `url('${value}')`; + return; + } + + const basePath = compilation.config.basePath === '' ? '/' : `${compilation.config.basePath}/`; + let barePath = value.replace(/\.\.\//g, '').replace('./', ''); + + if (barePath.startsWith('/')) { + barePath = barePath.replace('/', ''); + } + + const locationUrl = barePath.indexOf('node_modules/') >= 0 + ? new URL(`./${barePath}`, projectDirectory) + : new URL(`./${barePath}`, userWorkspace); + + if (fs.existsSync(locationUrl)) { + const isDev = process.env.__GWD_COMMAND__ === 'develop'; // eslint-disable-line no-underscore-dangle + const hash = hashString(fs.readFileSync(locationUrl, 'utf-8')); + const ext = barePath.split('.').pop(); + const hashedRoot = isDev ? barePath : barePath.replace(`.${ext}`, `.${hash}.${ext}`); + + if (!isDev) { + fs.mkdirSync(new URL(`./${path.dirname(hashedRoot)}/`, outputDir), { + recursive: true + }); + + fs.promises.copyFile( + locationUrl, + new URL(`./${hashedRoot}`, outputDir) + ); + } + + optimizedCss += `url('${basePath}${hashedRoot}')`; + } else { + console.warn(`Unable to locate ${value}. You may need to manually copy this file from its source location to the build output directory.`); + optimizedCss += `url('${value}')`; + } } else if (type === 'Atrule' && name !== 'import') { optimizedCss += `@${name} `; } else if (type === 'TypeSelector') { @@ -41,19 +83,40 @@ function bundleCss(body, url, projectDirectory) { optimizedCss += `#${name}`; } else if (type === 'ClassSelector') { optimizedCss += `.${name}`; + } else if (type === 'NestingSelector') { + optimizedCss += '&'; } else if (type === 'PseudoClassSelector') { optimizedCss += `:${name}`; + if (children) { + switch (name) { + + case 'dir': + case 'host': + case 'is': + case 'has': + case 'lang': + case 'not': + case 'nth-child': + case 'nth-last-child': + case 'nth-of-type': + case 'nth-last-of-type': + case 'where': + optimizedCss += '('; + break; + default: + break; + + } + } + } else if (type === 'PseudoElementSelector') { + optimizedCss += `::${name}`; + switch (name) { - case 'is': - case 'has': - case 'lang': - case 'not': - case 'nth-child': - case 'nth-last-child': - case 'nth-of-type': - case 'nth-last-of-type': + case 'highlight': + case 'part': + case 'slotted': optimizedCss += '('; break; default: @@ -62,16 +125,29 @@ function bundleCss(body, url, projectDirectory) { } } else if (type === 'Function') { /* ex: border-left: 3px solid var(--color-secondary); */ - if (this.declaration && item.prev && item.prev.data.type === 'Identifier') { + if (this.declaration && item.prev && (item.prev.data.type !== 'Operator' && item.prev.data.type !== 'Url')) { optimizedCss += ' '; } optimizedCss += `${name}(`; - } else if (type === 'MediaFeature') { + } else if (type === 'Feature') { optimizedCss += ` (${name}:`; - } else if (type === 'Parentheses') { + } else if (type === 'Parentheses' || type === 'SupportsDeclaration') { optimizedCss += '('; } else if (type === 'PseudoElementSelector') { optimizedCss += `::${name}`; + } else if (type === 'MediaQuery') { + // https://github.com/csstree/csstree/issues/285#issuecomment-2350230333 + const { mediaType, modifier } = node; + const type = mediaType !== null + ? mediaType + : ''; + const operator = mediaType && node.condition + ? ' and' + : modifier !== null + ? ` ${modifier}` + : ''; + + optimizedCss += `${type}${operator}`; } else if (type === 'Block') { optimizedCss += '{'; } else if (type === 'AttributeSelector') { @@ -148,21 +224,39 @@ function bundleCss(body, url, projectDirectory) { optimizedCss += '}'; break; case 'Function': - case 'MediaFeature': case 'Parentheses': + case 'SupportsDeclaration': optimizedCss += ')'; break; case 'PseudoClassSelector': + if (node.children) { + switch (node.name) { + + case 'dir': + case 'host': + case 'is': + case 'has': + case 'lang': + case 'not': + case 'nth-child': + case 'nth-last-child': + case 'nth-last-of-type': + case 'nth-of-type': + case 'where': + optimizedCss += ')'; + break; + default: + break; + + } + } + break; + case 'PseudoElementSelector': switch (node.name) { - case 'is': - case 'has': - case 'lang': - case 'not': - case 'nth-child': - case 'nth-last-child': - case 'nth-last-of-type': - case 'nth-of-type': + case 'highlight': + case 'part': + case 'slotted': optimizedCss += ')'; break; default: @@ -175,7 +269,7 @@ function bundleCss(body, url, projectDirectory) { optimizedCss += '!important'; } - if (item.next || (item.prev && !item.next)) { + if (item?.next || (item?.prev && !item?.next)) { optimizedCss += ';'; } @@ -196,6 +290,9 @@ function bundleCss(body, url, projectDirectory) { } optimizedCss += ']'; break; + case 'MediaQuery': + optimizedCss += ')'; + break; default: break; @@ -227,20 +324,27 @@ class StandardCssResource extends ResourceInterface { }); } - async shouldOptimize(url, response) { - const { protocol, pathname } = url; - const isValidCss = pathname.split('.').pop() === this.extensions[0] - && protocol === 'file:' - && response.headers.get('Content-Type').indexOf(this.contentType) >= 0; + async shouldIntercept(url, request, response) { + const { pathname } = url; + const ext = pathname.split('.').pop(); - return this.compilation.config.optimization !== 'none' && isValidCss; + return url.protocol === 'file:' + && ext === this.extensions[0] + && (response.headers.get('Content-Type')?.indexOf('text/css') >= 0 || request.headers.get('Accept')?.indexOf('text/javascript') >= 0) || url.searchParams?.get('polyfill') === 'type-css'; } - async optimize(url, response) { - const body = await response.text(); - const optimizedBody = bundleCss(body, url, this.compilation.context.projectDirectory); + async intercept(url, request, response) { + let body = bundleCss(await response.text(), url, this.compilation); + let headers = {}; + + if ((request.headers.get('Accept')?.indexOf('text/javascript') >= 0 || url.searchParams?.get('polyfill') === 'type-css') && !url.searchParams.has('type')) { + const contents = body.replace(/\r?\n|\r/g, ' ').replace(/\\/g, '\\\\'); + + body = `const sheet = new CSSStyleSheet();sheet.replaceSync(\`${contents}\`);export default sheet;`; + headers['Content-Type'] = 'text/javascript'; + } - return new Response(optimizedBody); + return new Response(body, { headers }); } } diff --git a/packages/cli/src/plugins/resource/plugin-standard-font.js b/packages/cli/src/plugins/resource/plugin-standard-font.js index 70d927b9c..80e7e071c 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-font.js +++ b/packages/cli/src/plugins/resource/plugin-standard-font.js @@ -16,7 +16,9 @@ class StandardFontResource extends ResourceInterface { } async shouldServe(url) { - return this.extensions.indexOf(url.pathname.split('.').pop()) >= 0; + const { pathname, protocol } = url; + + return this.extensions.indexOf(pathname.split('.').pop()) >= 0 && protocol === 'file:'; } async serve(url) { diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js index b64fcee64..199379075 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-html.js +++ b/packages/cli/src/plugins/resource/plugin-standard-html.js @@ -5,7 +5,6 @@ * This is a Greenwood default plugin. * */ -import frontmatter from 'front-matter'; import fs from 'fs/promises'; import rehypeStringify from 'rehype-stringify'; import rehypeRaw from 'rehype-raw'; @@ -13,10 +12,11 @@ import remarkFrontmatter from 'remark-frontmatter'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import { ResourceInterface } from '../../lib/resource-interface.js'; -import { getUserScripts, getPageTemplate, getAppTemplate } from '../../lib/templating-utils.js'; +import { getUserScripts, getPageLayout, getAppLayout } from '../../lib/layout-utils.js'; import { requestAsObject } from '../../lib/resource-utils.js'; import unified from 'unified'; import { Worker } from 'worker_threads'; +import htmlparser from 'node-html-parser'; class StandardHtmlResource extends ResourceInterface { constructor(compilation, options) { @@ -26,40 +26,36 @@ class StandardHtmlResource extends ResourceInterface { this.contentType = 'text/html'; } - async shouldServe(url) { + async shouldServe(url, request) { const { protocol, pathname } = url; const hasMatchingPageRoute = this.compilation.graph.find(node => node.route === pathname); const isSPA = this.compilation.graph.find(node => node.isSPA) && pathname.indexOf('.') < 0; - return protocol.startsWith('http') && (hasMatchingPageRoute || isSPA); + return protocol.startsWith('http') && (hasMatchingPageRoute || (isSPA && request.headers.get('Accept').indexOf('text/html') >= 0)); } async serve(url, request) { const { config, context } = this.compilation; - const { pagesDir, userWorkspace } = context; - const { interpolateFrontmatter } = config; + const { userWorkspace } = context; const { pathname } = url; const isSpaRoute = this.compilation.graph.find(node => node.isSPA); const matchingRoute = this.compilation.graph.find((node) => node.route === pathname) || {}; - const filePath = !matchingRoute.external ? matchingRoute.path : ''; - const isMarkdownContent = (matchingRoute?.filename || '').split('.').pop() === 'md'; - - let customImports = []; + const { pageHref } = matchingRoute; + const filePath = !matchingRoute.external && pageHref ? new URL(pageHref).pathname.replace(userWorkspace.pathname, './') : ''; + const isMarkdownContent = (filePath || '').split('.').pop() === 'md'; let body = ''; - let title = null; - let template = null; - let frontMatter = {}; + let layout = matchingRoute.layout || null; + let customImports = matchingRoute.imports || []; let ssrBody; - let ssrTemplate; - let ssrFrontmatter; + let ssrLayout; let processedMarkdown = null; if (matchingRoute.external) { - template = matchingRoute.template || template; + layout = matchingRoute.layout || layout; } if (isMarkdownContent) { - const markdownContents = await fs.readFile(filePath, 'utf-8'); + const markdownContents = await fs.readFile(new URL(pageHref), 'utf-8'); const rehypePlugins = []; const remarkPlugins = []; @@ -74,7 +70,6 @@ class StandardHtmlResource extends ResourceInterface { } const settings = config.markdown.settings || {}; - const fm = frontmatter(markdownContents); processedMarkdown = await unified() .use(remarkParse, settings) // parse markdown into AST @@ -85,55 +80,23 @@ class StandardHtmlResource extends ResourceInterface { .use(rehypePlugins) // apply userland rehype plugins .use(rehypeStringify) // convert AST to HTML string .process(markdownContents); - - // configure via frontmatter - if (fm.attributes) { - frontMatter = fm.attributes; - - if (frontMatter.title) { - title = frontMatter.title; - } - - if (frontMatter.template) { - template = frontMatter.template; - } - - if (frontMatter.imports) { - customImports = frontMatter.imports; - } - } } if (matchingRoute.isSSR) { - const routeModuleLocationUrl = new URL(`.${matchingRoute.relativeWorkspacePagePath}`, pagesDir); + const routeModuleLocationUrl = new URL(pageHref); const routeWorkerUrl = this.compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider().executeModuleUrl; await new Promise(async (resolve, reject) => { const worker = new Worker(new URL('../../lib/ssr-route-worker.js', import.meta.url)); worker.on('message', (result) => { - if (result.template) { - ssrTemplate = result.template; + if (result.layout) { + ssrLayout = result.layout; } + if (result.body) { ssrBody = result.body; } - if (result.frontmatter) { - ssrFrontmatter = result.frontmatter; - - if (ssrFrontmatter.title) { - title = ssrFrontmatter.title; - frontMatter.title = ssrFrontmatter.title; - } - - if (ssrFrontmatter.template) { - template = ssrFrontmatter.template; - } - - if (ssrFrontmatter.imports) { - customImports = customImports.concat(ssrFrontmatter.imports); - } - } resolve(); }); worker.on('error', reject); @@ -153,20 +116,13 @@ class StandardHtmlResource extends ResourceInterface { }); } - // get context plugins - const contextPlugins = this.compilation.config.plugins.filter((plugin) => { - return plugin.type === 'context'; - }).map((plugin) => { - return plugin.provider(this.compilation); - }); - if (isSpaRoute) { - body = await fs.readFile(new URL(`./${isSpaRoute.filename}`, userWorkspace), 'utf-8'); + body = await fs.readFile(new URL(isSpaRoute.pageHref), 'utf-8'); } else { - body = ssrTemplate ? ssrTemplate : await getPageTemplate(filePath, context, template, contextPlugins); + body = ssrLayout ? ssrLayout : await getPageLayout(pageHref, this.compilation, layout); } - body = await getAppTemplate(body, context, customImports, contextPlugins, config.devServer.hud, title); + body = await getAppLayout(body, this.compilation, customImports, matchingRoute); body = await getUserScripts(body, this.compilation); if (processedMarkdown) { @@ -190,22 +146,12 @@ class StandardHtmlResource extends ResourceInterface { } else if (matchingRoute.external) { body = body.replace(/\(.*)<\/content-outlet>/s, matchingRoute.body); } else if (ssrBody) { - body = body.replace(/\(.*)<\/content-outlet>/s, ssrBody); - } - - if (interpolateFrontmatter) { - for (const fm in frontMatter) { - const interpolatedFrontmatter = '\\$\\{globalThis.page.' + fm + '\\}'; - - body = body.replace(new RegExp(interpolatedFrontmatter, 'g'), frontMatter[fm]); - } + body = body.replace(/\(.*)<\/content-outlet>/s, `${ssrBody.replace(/\$/g, '$$$')}`); } - // give the user something to see so they know it works, if they have no content + // clean up any empty placeholder content-outlet if (body.indexOf('') > 0) { - body = body.replace('', ` -

Welcome to Greenwood!

- `); + body = body.replace('', ''); } return new Response(body, { @@ -216,21 +162,28 @@ class StandardHtmlResource extends ResourceInterface { } async shouldOptimize(url, response) { - return response.headers.get('Content-Type').indexOf(this.contentType) >= 0; + return response.headers.get('Content-Type')?.indexOf(this.contentType) >= 0; } async optimize(url, response) { const { optimization, basePath } = this.compilation.config; const { pathname } = url; - const pageResources = this.compilation.graph.find(page => page.outputPath === pathname || page.route === pathname).resources; + const pageResources = this.compilation.graph.find(page => page.route === pathname).resources; let body = await response.text(); + const root = htmlparser.parse(body, { + script: true, + style: true + }); + for (const pageResource of pageResources) { const keyedResource = this.compilation.resources.get(pageResource); - const { contents, src, type, optimizationAttr, optimizedFileContents, optimizedFileName, rawAttributes } = keyedResource; + const { contents, src, type, optimizationAttr, optimizedFileContents, optimizedFileName } = keyedResource; if (src) { if (type === 'script') { + const tag = root.querySelectorAll('script').find(script => script.getAttribute('src') === src); + if (!optimizationAttr && optimization === 'default') { const optimizedFilePath = `${basePath}/${optimizedFileName}`; @@ -240,17 +193,19 @@ class StandardHtmlResource extends ResourceInterface { `); } else if (optimizationAttr === 'inline' || optimization === 'inline') { - const isModule = rawAttributes.indexOf('type="module') >= 0 ? ' type="module"' : ''; + const isModule = tag.rawAttrs.indexOf('type="module') >= 0 ? ' type="module"' : ''; - body = body.replace(``, ` + body = body.replace(``, ` `); } else if (optimizationAttr === 'static' || optimization === 'static') { - body = body.replace(``, ''); + body = body.replace(``, ''); } } else if (type === 'link') { + const tag = root.querySelectorAll('link').find(link => link.getAttribute('href') === src); + if (!optimizationAttr && (optimization !== 'none' && optimization !== 'inline')) { const optimizedFilePath = `${basePath}/${optimizedFileName}`; @@ -264,11 +219,11 @@ class StandardHtmlResource extends ResourceInterface { // when pre-rendering, puppeteer normalizes everything to // but if not using pre-rendering, then it could come out as // not great, but best we can do for now until #742 - body = body.replace(``, ` + body = body.replace(``, ` - `).replace(``, ` + `).replace(``, ` @@ -277,8 +232,10 @@ class StandardHtmlResource extends ResourceInterface { } } else { if (type === 'script') { + const tag = root.querySelectorAll('script').find(script => script.innerHTML === contents); + if (optimizationAttr === 'static' || optimization === 'static') { - body = body.replace(``, ''); + body = body.replace(``, ''); } else if (optimizationAttr === 'none') { body = body.replace(contents, contents.replace(/\.\//g, `${basePath}/`).replace(/\$/g, '$$$')); } else { @@ -290,10 +247,6 @@ class StandardHtmlResource extends ResourceInterface { } } - // TODO clean up lit-polyfill - // https://github.com/ProjectEvergreen/greenwood/issues/728 - body = body.replace(/ `) diff --git a/packages/cli/src/plugins/server/plugin-livereload.js b/packages/cli/src/plugins/server/plugin-livereload.js index e3303e616..af371bfd4 100644 --- a/packages/cli/src/plugins/server/plugin-livereload.js +++ b/packages/cli/src/plugins/server/plugin-livereload.js @@ -9,7 +9,7 @@ class LiveReloadServer extends ServerInterface { } async start() { - const { userWorkspace } = this.compilation.context; + const { userWorkspace, projectDirectory } = this.compilation.context; const standardPluginsDirectoryPath = new URL('../resource/', import.meta.url); const standardPluginsNames = (await fs.readdir(standardPluginsDirectoryPath)) .filter(filename => filename.indexOf('plugin-standard') === 0); @@ -34,18 +34,21 @@ class LiveReloadServer extends ServerInterface { ...customPluginsExtensions, ...this.compilation.config.devServer.extensions ] - .filter((ext) => ext !== '*' || ext !== '') - .map((ext) => ext.replace('.', '')); + .filter((ext) => ext !== '*' || ext !== '') // basic filter for false positives + .filter((ext, idx, array) => array.indexOf(ext) === idx) // dedupe + .map((ext) => ext.startsWith('.') ? ext.replace('.', '') : ext); // trim . from all entries const liveReloadServer = livereload.createServer({ - exts: allExtensions.filter((ext, idx) => idx === allExtensions.indexOf(ext)), - applyCSSLive: false // https://github.com/napcs/node-livereload/issues/33#issuecomment-693707006 - }); + exts: allExtensions, + applyCSSLive: false, // https://github.com/napcs/node-livereload/issues/33#issuecomment-693707006 + applyImgLive: false // https://github.com/ProjectEvergreen/greenwood/issues/1263 + }, () => { + const abridgedWorkspacePath = userWorkspace.pathname.replace(projectDirectory.pathname, '').replace('/', ''); - liveReloadServer.watch(userWorkspace.pathname, () => { - console.info(`Now watching directory "${userWorkspace}" for changes.`); - return Promise.resolve(true); + console.info(`Now watching workspace directory (./${abridgedWorkspacePath}) for changes...`); }); + + liveReloadServer.watch(userWorkspace.pathname); } } diff --git a/packages/cli/test/cases/build.config.active-frontmatter/build.config.active-frontmatter.spec.js b/packages/cli/test/cases/build.config.active-frontmatter/build.config.active-frontmatter.spec.js new file mode 100644 index 000000000..b01574947 --- /dev/null +++ b/packages/cli/test/cases/build.config.active-frontmatter/build.config.active-frontmatter.spec.js @@ -0,0 +1,139 @@ +/* + * Use Case + * Run Greenwood with activeContent configuration enabled for validating active frontmatter. + * + * User Result + * Should generate a bare bones Greenwood build with correctly interpolated frontmatter variables in markdown and HTML. + * + * User Command + * greenwood build + * + * User Config + * { + * activeContent: true + * } + * + * User Workspace + * Greenwood default + * src/ + * pages/ + * blog/ + * first-post.md + * second-post.md + * layouts/ + * blog.html + */ +import { JSDOM } from 'jsdom'; +import path from 'path'; +import chai from 'chai'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Active Frontmatter'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + + before(function() { + runner.setup(outputPath, getSetupFiles(outputPath)); + runner.runCommand(cliPath, 'build'); + }); + + describe('Default Greenwood frontmatter should be interpolated in the correct places for the home page', function() { + let dom; + + before(async function() { + dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html')); + }); + + it('should have the correct value for the tag in the <head> for the home page', function() { + const title = dom.window.document.querySelector('head title').textContent; + + expect(title).to.be.equal('Home'); + }); + + it('should have the correct graph value for id in a <span> tag', function() { + const id = dom.window.document.querySelector('body span.id').textContent; + + expect(id).to.be.equal('index'); + }); + + it('should have the correct graph value for route in a <span> tag', function() { + const route = dom.window.document.querySelector('body span.route').textContent; + + expect(route).to.be.equal('/'); + }); + + it('should have the correct graph value for label in a <span> tag', function() { + const label = dom.window.document.querySelector('body span.label').textContent; + + expect(label).to.be.equal('Home'); + }); + }); + + describe('Simple frontmatter should be interpolated in the correct places', function() { + let dom; + + before(async function() { + dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './blog/first-post/index.html')); + }); + + it('should have the correct value for author <meta> tag in the <head>', function() { + const authorMeta = dom.window.document.querySelector('head meta[name=author]').getAttribute('content'); + + expect(authorMeta).to.be.equal('Owen Buckley'); + }); + + it('should have the correct value for published in the <h3> tag', function() { + const heading = dom.window.document.querySelector('body h3').textContent; + + expect(heading).to.be.equal('Published: 11/11/2022'); + }); + + it('should have the correct value for author in the <h4> tag', function() { + const heading = dom.window.document.querySelector('body h4').textContent; + + expect(heading).to.be.equal('Author: Owen Buckley'); + }); + }); + + describe('Rich frontmatter should be interpolated in the correct places', function() { + let dom; + + before(async function() { + dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './blog/second-post/index.html')); + }); + + it('should have the correct songs frontmatter data in the page output', function() { + const contents = dom.window.document.querySelector('body span').innerHTML; + const songs = JSON.parse(contents); + + expect(songs.length).to.equal(2); + + songs.forEach((song, idx) => { + const num = idx += 1; + + expect(song.title).to.equal(`Song ${num}`); + expect(song.url).to.equal(`song${num}.mp3`); + }); + }); + }); + }); + + after(function() { + runner.teardown(getOutputTeardownFiles(outputPath)); + }); +}); \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.active-frontmatter/greenwood.config.js b/packages/cli/test/cases/build.config.active-frontmatter/greenwood.config.js new file mode 100644 index 000000000..99fe04d09 --- /dev/null +++ b/packages/cli/test/cases/build.config.active-frontmatter/greenwood.config.js @@ -0,0 +1,3 @@ +export default { + activeContent: true +}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.interpolate-frontmatter/src/templates/blog.html b/packages/cli/test/cases/build.config.active-frontmatter/src/layouts/blog.html similarity index 69% rename from packages/cli/test/cases/build.config.interpolate-frontmatter/src/templates/blog.html rename to packages/cli/test/cases/build.config.active-frontmatter/src/layouts/blog.html index 58ce8c069..3377e2722 100644 --- a/packages/cli/test/cases/build.config.interpolate-frontmatter/src/templates/blog.html +++ b/packages/cli/test/cases/build.config.active-frontmatter/src/layouts/blog.html @@ -2,7 +2,7 @@ <html lang="en" prefix="og:http://ogp.me/ns#"> <head> - <meta name="author" content="${globalThis.page.author}"> + <meta name="author" content="${globalThis.page.data.author}"> </head> <body> diff --git a/packages/cli/test/cases/build.config.active-frontmatter/src/pages/blog/first-post.md b/packages/cli/test/cases/build.config.active-frontmatter/src/pages/blog/first-post.md new file mode 100644 index 000000000..5f953abf6 --- /dev/null +++ b/packages/cli/test/cases/build.config.active-frontmatter/src/pages/blog/first-post.md @@ -0,0 +1,13 @@ +--- +title: Ny First Post +layout: blog +published: 11/11/2022 +author: Owen Buckley +--- + +# My First Post + +### Published: ${globalThis.page.data.published} +#### Author: ${globalThis.page.data.author} + +Lorum Ipsum. \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.active-frontmatter/src/pages/blog/second-post.md b/packages/cli/test/cases/build.config.active-frontmatter/src/pages/blog/second-post.md new file mode 100644 index 000000000..b80a11dea --- /dev/null +++ b/packages/cli/test/cases/build.config.active-frontmatter/src/pages/blog/second-post.md @@ -0,0 +1,17 @@ +--- +title: Ny First Post +layout: blog +published: 11/11/2022 +author: Owen Buckley +songs: + - title: Song 1 + url: song1.mp3 + - title: Song 2 + url: song2.mp3 +--- + +# My Second Post + +## Playlist + +<span>${globalThis.page.data.songs}</span> \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.active-frontmatter/src/pages/index.html b/packages/cli/test/cases/build.config.active-frontmatter/src/pages/index.html new file mode 100644 index 000000000..2eddebcd2 --- /dev/null +++ b/packages/cli/test/cases/build.config.active-frontmatter/src/pages/index.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html lang="en" prefix="og:http://ogp.me/ns#"> + + <head> + <title>${globalThis.page.title} + + + + ${globalThis.page.id} + ${globalThis.page.label} + ${globalThis.page.route} + + + \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.error-templates-directory/build.config.error-templates-directory.spec.js b/packages/cli/test/cases/build.config.error-isolation/build.config.error-isolation.spec.js similarity index 63% rename from packages/cli/test/cases/build.config.error-templates-directory/build.config.error-templates-directory.spec.js rename to packages/cli/test/cases/build.config.error-isolation/build.config.error-isolation.spec.js index 8b481fd00..b262cc677 100644 --- a/packages/cli/test/cases/build.config.error-templates-directory/build.config.error-templates-directory.spec.js +++ b/packages/cli/test/cases/build.config.error-isolation/build.config.error-isolation.spec.js @@ -1,6 +1,6 @@ /* * Use Case - * Run Greenwood build command with a bad value for templatesDirectory in a custom config. + * Run Greenwood build command with a bad value for isolation mode in a custom config. * * User Result * Should throw an error. @@ -10,7 +10,7 @@ * * User Config * { - * templatesDirectory: {} + * isolation: {} * } * * User Workspace @@ -35,13 +35,13 @@ describe('Build Greenwood With: ', function() { runner = new Runner(); }); - describe('Custom Configuration with a bad value for templatesDirectory', function() { - it('should throw an error that templatesDirectory must be a string', function() { + describe('Custom Configuration with a bad value for Isolation', function() { + it('should throw an error that isolation must be a boolean', function() { try { runner.setup(outputPath); runner.runCommand(cliPath, 'build'); } catch (err) { - expect(err).to.contain('Error: provided templatesDirectory "[object Object]" is not supported. Please make sure to pass something like \'layouts/\''); + expect(err).to.contain('Error: greenwood.config.js isolation must be a boolean; true or false. Passed value was typeof: object'); } }); }); diff --git a/packages/cli/test/cases/build.config.error-isolation/greenwood.config.js b/packages/cli/test/cases/build.config.error-isolation/greenwood.config.js new file mode 100644 index 000000000..211201604 --- /dev/null +++ b/packages/cli/test/cases/build.config.error-isolation/greenwood.config.js @@ -0,0 +1,3 @@ +export default { + isolation: {} +}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.error-layouts-directory/build.config.error-layouts-directory.spec.js b/packages/cli/test/cases/build.config.error-layouts-directory/build.config.error-layouts-directory.spec.js new file mode 100644 index 000000000..e48c2130a --- /dev/null +++ b/packages/cli/test/cases/build.config.error-layouts-directory/build.config.error-layouts-directory.spec.js @@ -0,0 +1,49 @@ +/* + * Use Case + * Run Greenwood build command with a bad value for layoutsDirectory in a custom config. + * + * User Result + * Should throw an error. + * + * User Command + * greenwood build + * + * User Config + * { + * layoutsDirectory: {} + * } + * + * User Workspace + * Greenwood default + */ +import chai from 'chai'; +import path from 'path'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe('Custom Configuration with a bad value for layoutsDirectory', function() { + it('should throw an error that layoutsDirectory must be a string', async function() { + try { + runner.setup(outputPath); + runner.runCommand(cliPath, 'build'); + } catch (err) { + expect(err).to.contain('Error: provided layoutsDirectory "[object Object]" is not supported. Please make sure to pass something like \'layouts/\''); + } + }); + }); + +}); \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.error-layouts-directory/greenwood.config.js b/packages/cli/test/cases/build.config.error-layouts-directory/greenwood.config.js new file mode 100644 index 000000000..376ab3249 --- /dev/null +++ b/packages/cli/test/cases/build.config.error-layouts-directory/greenwood.config.js @@ -0,0 +1,3 @@ +export default { + layoutsDirectory: {} +}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.error-polyfill-import-attributes/build.config.error-polyfill-import-attributes.spec.js b/packages/cli/test/cases/build.config.error-polyfill-import-attributes/build.config.error-polyfill-import-attributes.spec.js new file mode 100644 index 000000000..8bd1197d8 --- /dev/null +++ b/packages/cli/test/cases/build.config.error-polyfill-import-attributes/build.config.error-polyfill-import-attributes.spec.js @@ -0,0 +1,51 @@ +/* + * Use Case + * Run Greenwood build command with a bad value for polyfill.importAttributes in a custom config. + * + * User Result + * Should throw an error. + * + * User Command + * greenwood build + * + * User Config + * { + * polyfills: { + * importAttributes: {} + * } + * } + * + * User Workspace + * Greenwood default + */ +import chai from 'chai'; +import path from 'path'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe('Custom Configuration with a bad value for Polyfills w/ Import Attributes', function() { + it('should throw an error that polyfills.importAttributes must be an array of types; [\'css\', \'json\']', function() { + try { + runner.setup(outputPath); + runner.runCommand(cliPath, 'build'); + } catch (err) { + expect(err).to.contain('Error: greenwood.config.js polyfill.importAttributes must be a an array of types; [\'css\', \'json\']. Passed value was typeof: object'); + } + }); + }); + +}); \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.error-polyfill-import-attributes/greenwood.config.js b/packages/cli/test/cases/build.config.error-polyfill-import-attributes/greenwood.config.js new file mode 100644 index 000000000..6bdd19bc5 --- /dev/null +++ b/packages/cli/test/cases/build.config.error-polyfill-import-attributes/greenwood.config.js @@ -0,0 +1,5 @@ +export default { + polyfills: { + importAttributes: null + } +}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.error-polyfill-import-maps/build.config.error-polyfill-import-maps.spec.js b/packages/cli/test/cases/build.config.error-polyfill-import-maps/build.config.error-polyfill-import-maps.spec.js new file mode 100644 index 000000000..e9c9cbfb7 --- /dev/null +++ b/packages/cli/test/cases/build.config.error-polyfill-import-maps/build.config.error-polyfill-import-maps.spec.js @@ -0,0 +1,51 @@ +/* + * Use Case + * Run Greenwood build command with a bad value for polyfill.importMaps in a custom config. + * + * User Result + * Should throw an error. + * + * User Command + * greenwood build + * + * User Config + * { + * polyfills: { + * importMaps: {} + * } + * } + * + * User Workspace + * Greenwood default + */ +import chai from 'chai'; +import path from 'path'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe('Custom Configuration with a bad value for Polyfills w/ Import Maps', function() { + it('should throw an error that polyfill.importMaps must be a boolean', function() { + try { + runner.setup(outputPath); + runner.runCommand(cliPath, 'build'); + } catch (err) { + expect(err).to.contain('Error: greenwood.config.js polyfill.importMaps must be a boolean; true or false. Passed value was typeof: object'); + } + }); + }); + +}); \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.error-polyfill-import-maps/greenwood.config.js b/packages/cli/test/cases/build.config.error-polyfill-import-maps/greenwood.config.js new file mode 100644 index 000000000..8eb55f402 --- /dev/null +++ b/packages/cli/test/cases/build.config.error-polyfill-import-maps/greenwood.config.js @@ -0,0 +1,5 @@ +export default { + polyfills: { + importMaps: {} + } +}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.error-templates-directory/greenwood.config.js b/packages/cli/test/cases/build.config.error-templates-directory/greenwood.config.js deleted file mode 100644 index b8e6daaba..000000000 --- a/packages/cli/test/cases/build.config.error-templates-directory/greenwood.config.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - templatesDirectory: {} -}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.interpolate-frontmatter/build.config.interpolate-frontmatter.spec.js b/packages/cli/test/cases/build.config.interpolate-frontmatter/build.config.interpolate-frontmatter.spec.js deleted file mode 100644 index 62b30ae9c..000000000 --- a/packages/cli/test/cases/build.config.interpolate-frontmatter/build.config.interpolate-frontmatter.spec.js +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Use Case - * Run Greenwood with interpolateFrontmatter configuration enabled. - * - * User Result - * Should generate a bare bones Greenwood build with correctly interpolated frontmatter variables in markdown and HTML. - * - * User Command - * greenwood build - * - * User Config - * { - * interpolateFrontmatter: true - * } - * - * User Workspace - * Greenwood default - * src/ - * pages/ - * blog/ - * first-post.md - * templates/ - * blog.html - */ -import { JSDOM } from 'jsdom'; -import path from 'path'; -import chai from 'chai'; -import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; -import { Runner } from 'gallinago'; -import { fileURLToPath, URL } from 'url'; - -const expect = chai.expect; - -describe('Build Greenwood With: ', function() { - const LABEL = 'Frontmatter Interpolation'; - const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); - const outputPath = fileURLToPath(new URL('.', import.meta.url)); - let runner; - - before(function() { - this.context = { - publicDir: path.join(outputPath, 'public') - }; - runner = new Runner(); - }); - - describe(LABEL, function() { - - before(function() { - runner.setup(outputPath, getSetupFiles(outputPath)); - runner.runCommand(cliPath, 'build'); - }); - - describe('Frontmatter should be interpolated in the correct places', function() { - let dom; - - before(async function() { - dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './blog/first-post/index.html')); - }); - - it('should have the correct value for author tag in the ', function() { - const authorMeta = dom.window.document.querySelector('head meta[name=author]').getAttribute('content'); - - expect(authorMeta).to.be.equal('Owen Buckley'); - }); - - it('should have the correct value for published in the

tag', function() { - const heading = dom.window.document.querySelector('body h3').textContent; - - expect(heading).to.be.equal('Published: 11/11/2022'); - }); - - it('should have the correct value for author in the

tag', function() { - const heading = dom.window.document.querySelector('body h4').textContent; - - expect(heading).to.be.equal('Author: Owen Buckley'); - }); - }); - }); - - after(function() { - runner.teardown(getOutputTeardownFiles(outputPath)); - }); -}); \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.interpolate-frontmatter/greenwood.config.js b/packages/cli/test/cases/build.config.interpolate-frontmatter/greenwood.config.js deleted file mode 100644 index 41bc6bc88..000000000 --- a/packages/cli/test/cases/build.config.interpolate-frontmatter/greenwood.config.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - interpolateFrontmatter: true -}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.interpolate-frontmatter/src/pages/blog/first-post.md b/packages/cli/test/cases/build.config.interpolate-frontmatter/src/pages/blog/first-post.md deleted file mode 100644 index deae1bb80..000000000 --- a/packages/cli/test/cases/build.config.interpolate-frontmatter/src/pages/blog/first-post.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Ny First Post -template: blog -published: 11/11/2022 -author: Owen Buckley ---- - -# My First Post - -### Published: ${globalThis.page.published} -#### Author: ${globalThis.page.author} - -Lorum Ipsum. \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.templates-directory/build.config.templates-directory.spec.js b/packages/cli/test/cases/build.config.layouts-directory/build.config.layouts-directory.spec.js similarity index 89% rename from packages/cli/test/cases/build.config.templates-directory/build.config.templates-directory.spec.js rename to packages/cli/test/cases/build.config.layouts-directory/build.config.layouts-directory.spec.js index baac1c79b..f206cf923 100644 --- a/packages/cli/test/cases/build.config.templates-directory/build.config.templates-directory.spec.js +++ b/packages/cli/test/cases/build.config.layouts-directory/build.config.layouts-directory.spec.js @@ -1,6 +1,6 @@ /* * Use Case - * Run Greenwood with a custom name for templates directory. + * Run Greenwood with a custom name for layouts directory. * * User Result * Should generate a bare bones Greenwood build. (same as build.default.spec.js) with custom title in header @@ -10,7 +10,7 @@ * * User Config * { - * templatesDirectory: 'layouts' + * layoutsDirectory: 'layouts' * } * * User Workspace @@ -68,7 +68,7 @@ describe('Build Greenwood With: ', function() { it('should have the correct page heading', function() { const heading = dom.window.document.querySelectorAll('head title')[0].textContent; - expect(heading).to.be.equal('Custom Layout Page Template'); + expect(heading).to.be.equal('Custom Layout Page Layout'); }); it('should have the correct page heading', function() { @@ -80,7 +80,7 @@ describe('Build Greenwood With: ', function() { it('should have the correct page heading', function() { const paragraph = dom.window.document.querySelectorAll('body p')[0].textContent; - expect(paragraph).to.be.equal('A page using a page template from a custom layout directory.'); + expect(paragraph).to.be.equal('A page using a page layout from a custom layout directory.'); }); }); }); diff --git a/packages/cli/test/cases/build.config.layouts-directory/greenwood.config.js b/packages/cli/test/cases/build.config.layouts-directory/greenwood.config.js new file mode 100644 index 000000000..04634f17c --- /dev/null +++ b/packages/cli/test/cases/build.config.layouts-directory/greenwood.config.js @@ -0,0 +1,3 @@ +export default { + layoutsDirectory: 'my-layouts' +}; \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.templates-directory/src/layouts/page.html b/packages/cli/test/cases/build.config.layouts-directory/src/my-layouts/page.html similarity index 66% rename from packages/cli/test/cases/build.config.templates-directory/src/layouts/page.html rename to packages/cli/test/cases/build.config.layouts-directory/src/my-layouts/page.html index b7fe92d12..0fc6a16f9 100644 --- a/packages/cli/test/cases/build.config.templates-directory/src/layouts/page.html +++ b/packages/cli/test/cases/build.config.layouts-directory/src/my-layouts/page.html @@ -1,6 +1,6 @@ - Custom Layout Page Template + Custom Layout Page Layout diff --git a/packages/cli/test/cases/build.config.layouts-directory/src/pages/index.md b/packages/cli/test/cases/build.config.layouts-directory/src/pages/index.md new file mode 100644 index 000000000..3332d3216 --- /dev/null +++ b/packages/cli/test/cases/build.config.layouts-directory/src/pages/index.md @@ -0,0 +1,3 @@ +# Home Page + +A page using a page layout from a custom layout directory. \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js b/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js index 0b0c9131f..8edcd7de7 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 @@ -14,10 +14,15 @@ * src/ * components/ * header.js + * images/ + * webcomponents.jpg * pages/ * index.html * styles/ + * main.css * theme.css + * system + * variables.css */ import chai from 'chai'; import fs from 'fs'; @@ -52,9 +57,15 @@ describe('Build Greenwood With: ', function() { `${outputPath}/node_modules/prismjs/themes/` ); + const geistFont = await getDependencyFiles( + `${process.cwd()}/node_modules/geist/dist/fonts/geist-sans/*`, + `${outputPath}/node_modules/geist/dist/fonts/geist-sans/` + ); + runner.setup(outputPath, [ ...getSetupFiles(outputPath), - ...prismCss + ...prismCss, + ...geistFont ]); runner.runCommand(cliPath, 'build'); }); @@ -72,7 +83,7 @@ describe('Build Greenwood With: ', function() { }); it('should have the expected - + + + diff --git a/packages/cli/test/cases/build.config.optimization-static/build.config-optimization-static.spec.js b/packages/cli/test/cases/build.config.optimization-static/build.config-optimization-static.spec.js index a9ede0c53..8d18417c3 100644 --- a/packages/cli/test/cases/build.config.optimization-static/build.config-optimization-static.spec.js +++ b/packages/cli/test/cases/build.config.optimization-static/build.config-optimization-static.spec.js @@ -70,7 +70,7 @@ describe('Build Greenwood With: ', function() { }); it('should have no + + + +

Blog Posts

+ + + \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.prerender-collections/src/pages/blog/second-post.md b/packages/cli/test/cases/build.config.prerender-collections/src/pages/blog/second-post.md new file mode 100644 index 000000000..ba4dc1e04 --- /dev/null +++ b/packages/cli/test/cases/build.config.prerender-collections/src/pages/blog/second-post.md @@ -0,0 +1,3 @@ +# Second Post + +Lorum Ipsum \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.prerender-collections/src/pages/index.html b/packages/cli/test/cases/build.config.prerender-collections/src/pages/index.html new file mode 100644 index 000000000..68a7cf2b4 --- /dev/null +++ b/packages/cli/test/cases/build.config.prerender-collections/src/pages/index.html @@ -0,0 +1,15 @@ +--- +collection: nav +order: 1 +--- + + + + + + + + ${globalThis.collection.nav} +

Home Page

+ + \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.prerender-collections/src/pages/toc.html b/packages/cli/test/cases/build.config.prerender-collections/src/pages/toc.html new file mode 100644 index 000000000..dea911cd1 --- /dev/null +++ b/packages/cli/test/cases/build.config.prerender-collections/src/pages/toc.html @@ -0,0 +1,15 @@ +--- +collection: nav +order: 3 +label: Table of Contents +--- + + + + + + +

Table of Contents Page

+ + + \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.prerender-html-web-components/build.config.prerender-html-web-components.spec.js b/packages/cli/test/cases/build.config.prerender-html-web-components/build.config.prerender-html-web-components.spec.js new file mode 100644 index 000000000..54ce76e2c --- /dev/null +++ b/packages/cli/test/cases/build.config.prerender-html-web-components/build.config.prerender-html-web-components.spec.js @@ -0,0 +1,123 @@ +/* + * Use Case + * Run Greenwood build command with prerender config set to true and using HTML (Light DOM) Web Components. + * + * User Result + * Should generate a Greenwood build with the expected generated output using custom elements. + * + * User Command + * greenwood build + * + * User Config + * { + * prerender: true + * } + * + * User Workspace + * src/ + * components/ + * caption.js + * picture-frame.js + * pages/ + * index.html + */ +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { runSmokeTest } from '../../../../../test/smoke-test.js'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; +import fs from 'fs/promises'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Prerender Configuration and HTML (Light DOM) Web Components'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + + before(function() { + runner.setup(outputPath, getSetupFiles(outputPath)); + runner.runCommand(cliPath, 'build'); + }); + + runSmokeTest(['public', 'index'], LABEL); + + describe('Prerendered output for index.html', function() { + let dom; + let pictureFrame; + let expectedHtml; + let actualHtml; + + before(async function() { + actualHtml = await fs.readFile(new URL('./public/index.html', import.meta.url), 'utf-8'); + dom = new JSDOM(actualHtml); + pictureFrame = dom.window.document.querySelectorAll('wcc-picture-frame'); + expectedHtml = await fs.readFile(new URL('./expected.html', import.meta.url), 'utf-8'); + }); + + describe(LABEL, function() { + it('should not have any