Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhancement/issue 946 decouple build and serve commands from workspace #1079

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ad4baad
track APIs as part of compilation.manifest and leverage in API routes…
thescientist13 Feb 25, 2023
0992906
API routes bundling
thescientist13 Feb 25, 2023
a5873cd
pull API routes from manifest for serve command
thescientist13 Feb 25, 2023
0a1266a
bundle SSR routes
thescientist13 Feb 26, 2023
4853b6e
WCC bunding in API routes
thescientist13 Feb 26, 2023
563f746
create copy plugin for manifest.json
thescientist13 Mar 5, 2023
99c2e70
refactor SSR route bundling
thescientist13 Mar 7, 2023
1705fed
most templating functions working for SSR templating
thescientist13 Mar 19, 2023
3c5124c
fill static and SSR templating working with optimizations
thescientist13 Mar 19, 2023
4b35858
fix SSR title rendering
thescientist13 Mar 19, 2023
a9d680f
add support for getUserScripts in SSR
thescientist13 Mar 19, 2023
d718af0
hacky work around to get static router + SSR spec working
thescientist13 Mar 24, 2023
1d785e4
enable spec
thescientist13 Mar 24, 2023
7e2a217
decouple build command from serve command
thescientist13 Mar 26, 2023
11c2a38
add build output as pre-requisite to runngin serve command
thescientist13 Mar 26, 2023
b7b268c
document new greenwood serve behavior
thescientist13 Mar 26, 2023
cb2e04c
refactor SSR compilation context bundle output
thescientist13 Mar 26, 2023
4360764
refactor plugin-standard-html to use template utils
thescientist13 Mar 26, 2023
fd677c6
create custom json transformation transformer to handle escodegen
thescientist13 Mar 27, 2023
6426872
create custom json transformation transformer to handle escodegen
thescientist13 Mar 27, 2023
a55942a
support context plugins in SSR templating
thescientist13 Mar 27, 2023
0646e7a
TODO tracking and console log cleanup
thescientist13 Apr 1, 2023
b784db1
upgrade latest wcc with HTML wrapping tag disabled
thescientist13 Apr 1, 2023
7ce5097
fix linting
thescientist13 Apr 1, 2023
902addb
fix path references
thescientist13 Apr 1, 2023
cc9f250
normalize SSR static router hack pathnames for windows
thescientist13 Apr 1, 2023
06b0142
normalize SSR static router hack pathnames for windows
thescientist13 Apr 1, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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