Skip to content

Commit

Permalink
Enhancement/issue 946 decouple build and serve commands from workspace (
Browse files Browse the repository at this point in the history
#1079)

* track APIs as part of compilation.manifest and leverage in API routes resource plugin

* API routes bundling

* pull API routes from manifest for serve command

* bundle SSR routes

* WCC bunding in API routes

* create copy plugin for manifest.json

* refactor SSR route bundling

* most templating functions working for SSR templating

* fill static and SSR templating working with optimizations

* fix SSR title rendering

* add support for getUserScripts in SSR

* hacky work around to get static router + SSR spec working

* enable spec

* decouple build command from serve command

* add build output as pre-requisite to runngin serve command

* document new greenwood serve behavior

* refactor SSR compilation context bundle output

* refactor plugin-standard-html to use template utils

* create custom json transformation transformer to handle escodegen

* create custom json transformation transformer to handle escodegen

* support context plugins in SSR templating

* TODO tracking and console log cleanup

* upgrade latest wcc with HTML wrapping tag disabled

* fix linting

* fix path references

* normalize SSR static router hack pathnames for windows

* normalize SSR static router hack pathnames for windows
  • Loading branch information
thescientist13 committed Apr 9, 2023
1 parent bfdb16a commit 571e8d5
Show file tree
Hide file tree
Showing 36 changed files with 786 additions and 269 deletions.
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"access": "public"
},
"dependencies": {
"@web/rollup-plugin-import-meta-assets": "^1.0.0",
"@rollup/plugin-commonjs": "^21.0.0",
"@rollup/plugin-node-resolve": "^13.0.0",
"@rollup/plugin-replace": "^2.3.4",
"@rollup/plugin-terser": "^0.1.0",
Expand All @@ -45,7 +47,7 @@
"remark-rehype": "^7.0.0",
"rollup": "^2.58.0",
"unified": "^9.2.0",
"wc-compiler": "~0.7.0"
"wc-compiler": "~0.8.0"
},
"devDependencies": {
"@babel/runtime": "^7.10.4",
Expand Down
74 changes: 72 additions & 2 deletions packages/cli/src/config/rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import fs from 'fs/promises';
import { checkResourceExists, normalizePathnameForWindows } from '../lib/resource-utils.js';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { importMetaAssets } from '@web/rollup-plugin-import-meta-assets';

// specifically to handle escodegen using require for package.json
// https://github.com/estools/escodegen/issues/455
function greenwoodJsonLoader() {
return {
name: 'greenwood-json-loader',
async load(id) {
const extension = id.split('.').pop();

if (extension === 'json') {
const url = new URL(`file://${id}`);
const json = JSON.parse(await fs.readFile(url, 'utf-8'));
const contents = `export default ${JSON.stringify(json)}`;

return contents;
}
}
};
}

function greenwoodResourceLoader (compilation) {
const resourcePlugins = compilation.config.plugins.filter((plugin) => {
Expand Down Expand Up @@ -108,7 +130,7 @@ function greenwoodSyncPageResourceBundlesPlugin(compilation) {
};
}

const getRollupConfig = async (compilation) => {
const getRollupConfigForScriptResources = async (compilation) => {
const { outputDir } = compilation.context;
const input = [...compilation.resources.values()]
.filter(resource => resource.type === 'script')
Expand Down Expand Up @@ -165,4 +187,52 @@ const getRollupConfig = async (compilation) => {
}];
};

export { getRollupConfig };
const getRollupConfigForApis = async (compilation) => {
const { outputDir, userWorkspace } = compilation.context;
const input = [...compilation.manifest.apis.values()]
.map(api => normalizePathnameForWindows(new URL(`.${api.path}`, userWorkspace)));

// TODO should routes and APIs have chunks?
// https://github.com/ProjectEvergreen/greenwood/issues/1008
return [{
input,
output: {
dir: `${normalizePathnameForWindows(outputDir)}/api`,
entryFileNames: '[name].js',
chunkFileNames: '[name].[hash].js'
},
plugins: [
greenwoodJsonLoader(),
nodeResolve(),
commonjs(),
importMetaAssets()
]
}];
};

const getRollupConfigForSsr = async (compilation, input) => {
const { outputDir } = compilation.context;

// TODO should routes and APIs have chunks?
// https://github.com/ProjectEvergreen/greenwood/issues/1008
return [{
input,
output: {
dir: normalizePathnameForWindows(outputDir),
entryFileNames: '_[name].js',
chunkFileNames: '[name].[hash].js'
},
plugins: [
greenwoodJsonLoader(),
nodeResolve(),
commonjs(),
importMetaAssets()
]
}];
};

export {
getRollupConfigForApis,
getRollupConfigForScriptResources,
getRollupConfigForSsr
};
6 changes: 1 addition & 5 deletions packages/cli/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import { generateCompilation } from './lifecycles/compile.js';
import fs from 'fs/promises';
import program from 'commander';
import { URL } from 'url';

const greenwoodPackageJson = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf-8'));
let cmdOption = {};
Expand Down Expand Up @@ -56,11 +55,11 @@ if (program.parse.length === 0) {
}

const run = async() => {
process.env.__GWD_COMMAND__ = command;
const compilation = await generateCompilation();

try {
console.info(`Running Greenwood with the ${command} command.`);
process.env.__GWD_COMMAND__ = command;

switch (command) {

Expand All @@ -73,9 +72,6 @@ const run = async() => {

break;
case 'serve':
process.env.__GWD_COMMAND__ = 'build';

await (await import('./commands/build.js')).runProductionBuild(compilation);
await (await import('./commands/serve.js')).runProdServer(compilation);

break;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/node-modules-utils.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// TODO convert this to use / return URLs
// https://github.com/ProjectEvergreen/greenwood/issues/953
import { createRequire } from 'module'; // https://stackoverflow.com/a/62499498/417806
import { checkResourceExists } from '../lib/resource-utils.js';
import { checkResourceExists } from './resource-utils.js';
import fs from 'fs/promises';

// defer to NodeJS to find where on disk a package is located using import.meta.resolve
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lib/resource-utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from 'fs/promises';
import { hashString } from '../lib/hashing-utils.js';
import { hashString } from './hashing-utils.js';

async function modelResource(context, type, src = undefined, contents = undefined, optimizationAttr = undefined, rawAttributes = undefined) {
const { projectDirectory, scratchDir, userWorkspace } = context;
Expand Down Expand Up @@ -54,7 +54,7 @@ function mergeResponse(destination, source) {
}

// On Windows, a URL with a drive letter like C:/ thinks it is a protocol and so prepends a /, e.g. /C:/
// This is fine with never fs methods that Greenwood uses, but tools like Rollupand PostCSS will need this handled manually
// This is fine with never fs methods that Greenwood uses, but tools like Rollup and PostCSS will need this handled manually
// https://github.com/rollup/rollup/issues/3779
function normalizePathnameForWindows(url) {
const windowsDriveRegex = /\/[a-zA-Z]{1}:\//;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/ssr-route-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ async function executeRouteModule({ moduleUrl, compilation, route, label, id, pr
const { getTemplate = null, getBody = null, getFrontmatter = null } = module;

if (module.default) {
const { html } = await renderToString(new URL(moduleUrl));
const { html } = await renderToString(new URL(moduleUrl), false);

data.body = html;
} else {
Expand Down
201 changes: 201 additions & 0 deletions packages/cli/src/lib/templating-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import fs from 'fs/promises';
import htmlparser from 'node-html-parser';
import { checkResourceExists } from './resource-utils.js';
import { getPackageJson } from './node-modules-utils.js';

async function getCustomPageTemplatesFromPlugins(contextPlugins, templateName) {
const customTemplateLocations = [];
const templateDir = contextPlugins
.map(plugin => plugin.templates)
.flat();

for (const templateDirUrl of templateDir) {
if (templateName) {
const templateUrl = new URL(`./${templateName}.html`, templateDirUrl);

if (await checkResourceExists(templateUrl)) {
customTemplateLocations.push(templateUrl);
}
}
}

return customTemplateLocations;
}

async function getPageTemplate(filePath, context, template, contextPlugins = []) {
const { templatesDir, userTemplatesDir, pagesDir, projectDirectory } = context;
const customPluginDefaultPageTemplates = await getCustomPageTemplatesFromPlugins(contextPlugins, 'page');
const customPluginPageTemplates = await getCustomPageTemplatesFromPlugins(contextPlugins, template);
const extension = filePath.split('.').pop();
const is404Page = filePath.startsWith('404') && extension === 'html';
const hasCustomTemplate = await checkResourceExists(new URL(`./${template}.html`, userTemplatesDir));
const hasPageTemplate = await checkResourceExists(new URL('./page.html', userTemplatesDir));
const hasCustom404Page = await checkResourceExists(new URL('./404.html', pagesDir));
const isHtmlPage = extension === 'html' && await checkResourceExists(new URL(`./${filePath}`, projectDirectory));
let contents;

if (template && (customPluginPageTemplates.length > 0 || hasCustomTemplate)) {
// use a custom template, usually from markdown frontmatter
contents = customPluginPageTemplates.length > 0
? await fs.readFile(new URL(`./${template}.html`, customPluginPageTemplates[0]), 'utf-8')
: await fs.readFile(new URL(`./${template}.html`, userTemplatesDir), 'utf-8');
} else if (isHtmlPage) {
// if the page is already HTML, use that as the template, NOT accounting for 404 pages
contents = await fs.readFile(new URL(`./${filePath}`, projectDirectory), 'utf-8');
} else if (customPluginDefaultPageTemplates.length > 0 || (!is404Page && hasPageTemplate)) {
// else look for default page template from the user
// and 404 pages should be their own "top level" template
contents = customPluginDefaultPageTemplates.length > 0
? await fs.readFile(new URL('./page.html', customPluginDefaultPageTemplates[0]), 'utf-8')
: await fs.readFile(new URL('./page.html', userTemplatesDir), 'utf-8');
} else if (is404Page && !hasCustom404Page) {
contents = await fs.readFile(new URL('./404.html', templatesDir), 'utf-8');
} else {
// fallback to using Greenwood's stock page template
contents = await fs.readFile(new URL('./page.html', templatesDir), 'utf-8');
}

return contents;
}

/* eslint-disable-next-line complexity */
async function getAppTemplate(pageTemplateContents, context, customImports = [], contextPlugins, enableHud, frontmatterTitle) {
const { templatesDir, userTemplatesDir } = context;
const userAppTemplateUrl = new URL('./app.html', userTemplatesDir);
const customAppTemplatesFromPlugins = await getCustomPageTemplatesFromPlugins(contextPlugins, 'app');
const hasCustomUserAppTemplate = await checkResourceExists(userAppTemplateUrl);
let appTemplateContents = customAppTemplatesFromPlugins.length > 0
? await fs.readFile(new URL('./app.html', customAppTemplatesFromPlugins[0]))
: hasCustomUserAppTemplate
? await fs.readFile(userAppTemplateUrl, 'utf-8')
: await fs.readFile(new URL('./app.html', templatesDir), 'utf-8');
let mergedTemplateContents = '';

const pageRoot = pageTemplateContents && htmlparser.parse(pageTemplateContents, {
script: true,
style: true,
noscript: true,
pre: true
});
const appRoot = htmlparser.parse(appTemplateContents, {
script: true,
style: true
});

if ((pageTemplateContents && !pageRoot.valid) || !appRoot.valid) {
console.debug('ERROR: Invalid HTML detected');
const invalidContents = !pageRoot.valid
? pageTemplateContents
: appTemplateContents;

if (enableHud) {
appTemplateContents = appTemplateContents.replace('<body>', `
<body>
<div style="position: absolute; width: auto; border: dotted 3px red; background-color: white; opacity: 0.75; padding: 1% 1% 0">
<p>Malformed HTML detected, please check your closing tags or an <a href="https://www.google.com/search?q=html+formatter" target="_blank" rel="noreferrer">HTML formatter</a>.</p>
<details>
<pre>
${invalidContents.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')}
</pre>
</details>
</div>
`);
}

mergedTemplateContents = appTemplateContents.replace(/<page-outlet><\/page-outlet>/, '');
} else {
const appTitle = appRoot ? appRoot.querySelector('head title') : null;
const appBody = appRoot.querySelector('body') ? appRoot.querySelector('body').innerHTML : '';
const pageBody = pageRoot && pageRoot.querySelector('body') ? pageRoot.querySelector('body').innerHTML : '';
const pageTitle = pageRoot && pageRoot.querySelector('head title');
const hasInterpolatedFrontmatter = pageTitle && pageTitle.rawText.indexOf('${globalThis.page.title}') >= 0
|| appTitle && appTitle.rawText.indexOf('${globalThis.page.title}') >= 0;

const title = hasInterpolatedFrontmatter // favor frontmatter interpolation first
? pageTitle && pageTitle.rawText
? pageTitle.rawText
: appTitle.rawText
: frontmatterTitle // otherwise, work in order of specificity from page -> page template -> app template
? frontmatterTitle
: pageTitle && pageTitle.rawText
? pageTitle.rawText
: appTitle && appTitle.rawText
? appTitle.rawText
: 'My App';

const mergedHtml = pageRoot && pageRoot.querySelector('html').rawAttrs !== ''
? `<html ${pageRoot.querySelector('html').rawAttrs}>`
: appRoot.querySelector('html').rawAttrs !== ''
? `<html ${appRoot.querySelector('html').rawAttrs}>`
: '<html>';

const mergedMeta = [
...appRoot.querySelectorAll('head meta'),
...[...(pageRoot && pageRoot.querySelectorAll('head meta')) || []]
].join('\n');

const mergedLinks = [
...appRoot.querySelectorAll('head link'),
...[...(pageRoot && pageRoot.querySelectorAll('head link')) || []]
].join('\n');

const mergedStyles = [
...appRoot.querySelectorAll('head style'),
...[...(pageRoot && pageRoot.querySelectorAll('head style')) || []],
...customImports.filter(resource => resource.split('.').pop() === 'css')
.map(resource => `<link rel="stylesheet" href="${resource}"></link>`)
].join('\n');

const mergedScripts = [
...appRoot.querySelectorAll('head script'),
...[...(pageRoot && pageRoot.querySelectorAll('head script')) || []],
...customImports.filter(resource => resource.split('.').pop() === 'js')
.map(resource => `<script src="${resource}" type="module"></script>`)
].join('\n');

const finalBody = pageTemplateContents
? appBody.replace(/<page-outlet><\/page-outlet>/, pageBody)
: appBody;

mergedTemplateContents = `<!DOCTYPE html>
${mergedHtml}
<head>
<title>${title}</title>
${mergedMeta}
${mergedLinks}
${mergedStyles}
${mergedScripts}
</head>
<body>
${finalBody}
</body>
</html>
`;
}

return mergedTemplateContents;
}

async function getUserScripts (contents, context) {
// https://lit.dev/docs/tools/requirements/#polyfills
if (process.env.__GWD_COMMAND__ === 'build') { // eslint-disable-line no-underscore-dangle
const userPackageJson = await getPackageJson(context);
const dependencies = userPackageJson?.dependencies || {};
const litPolyfill = dependencies && dependencies.lit
? '<script src="/node_modules/lit/polyfill-support.js"></script>\n'
: '';

contents = contents.replace('<head>', `
<head>
${litPolyfill}
`);
}

return contents;
}

export {
getAppTemplate,
getPageTemplate,
getUserScripts
};
Loading

0 comments on commit 571e8d5

Please sign in to comment.