@greenwood/cli
Advanced tools
Comparing version 0.28.1 to 0.28.2
{ | ||
"name": "@greenwood/cli", | ||
"version": "0.28.1", | ||
"version": "0.28.2", | ||
"description": "Greenwood CLI.", | ||
@@ -76,3 +76,3 @@ "type": "module", | ||
}, | ||
"gitHead": "9686fc1c6a742d14e05c08a7a2bbd70d4a5f8a1b" | ||
"gitHead": "65dce26d99422cf7451a5a8b0d1d97dad5e15c6b" | ||
} |
import { bundleCompilation } from '../lifecycles/bundle.js'; | ||
import { checkResourceExists } from '../lib/resource-utils.js'; | ||
import { checkResourceExists, trackResourcesForRoute } from '../lib/resource-utils.js'; | ||
import { copyAssets } from '../lifecycles/copy.js'; | ||
@@ -8,2 +8,58 @@ import fs from 'fs/promises'; | ||
// 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) => { | ||
@@ -47,2 +103,3 @@ | ||
if (prerenderPlugin.workerUrl) { | ||
await trackResourcesForRoutes(compilation); | ||
await preRenderCompilationWorker(compilation, prerenderPlugin); | ||
@@ -53,2 +110,3 @@ } else { | ||
} else { | ||
await trackResourcesForRoutes(compilation); | ||
await staticRenderCompilation(compilation); | ||
@@ -55,0 +113,0 @@ } |
import fs from 'fs/promises'; | ||
import { hashString } from './hashing-utils.js'; | ||
import htmlparser from 'node-html-parser'; | ||
@@ -108,2 +109,65 @@ async function modelResource(context, type, src = undefined, contents = undefined, optimizationAttr = undefined, rawAttributes = undefined) { | ||
// 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 <script> / <style> / <link> tags to bundle | ||
async function trackResourcesForRoute(html, compilation, route) { | ||
const { context } = compilation; | ||
const root = htmlparser.parse(html, { | ||
script: true, | ||
style: true | ||
}); | ||
// intentionally support <script> tags from the <head> or <body> | ||
const scripts = await Promise.all(root.querySelectorAll('script') | ||
.filter(script => ( | ||
isLocalLink(script.getAttribute('src')) || script.rawText) | ||
&& script.rawAttrs.indexOf('importmap') < 0) | ||
.map(async(script) => { | ||
const src = script.getAttribute('src'); | ||
const optimizationAttr = script.getAttribute('data-gwd-opt'); | ||
const { rawAttrs } = script; | ||
if (src) { | ||
// <script src="...."></script> | ||
return await modelResource(context, 'script', src, null, optimizationAttr, rawAttrs); | ||
} else if (script.rawText) { | ||
// <script>...</script> | ||
return await modelResource(context, 'script', null, script.rawText, optimizationAttr, rawAttrs); | ||
} | ||
})); | ||
const styles = await Promise.all(root.querySelectorAll('style') | ||
.filter(style => !(/\$/).test(style.rawText) && !(/<!-- Shady DOM styles for -->/).test(style.rawText)) // filter out Shady DOM <style> tags that happen when using puppeteer | ||
.map(async(style) => await modelResource(context, 'style', null, style.rawText, null, style.getAttribute('data-gwd-opt')))); | ||
const links = await Promise.all(root.querySelectorAll('head link') | ||
.filter(link => { | ||
// <link rel="stylesheet" href="..."></link> | ||
return link.getAttribute('rel') === 'stylesheet' | ||
&& link.getAttribute('href') && isLocalLink(link.getAttribute('href')); | ||
}).map(async(link) => { | ||
return modelResource(context, 'link', link.getAttribute('href'), null, link.getAttribute('data-gwd-opt'), link.rawAttrs); | ||
})); | ||
const resources = [ | ||
...scripts, | ||
...styles, | ||
...links | ||
]; | ||
resources.forEach(resource => { | ||
compilation.resources.set(resource.sourcePathURL.pathname, resource); | ||
}); | ||
compilation.graph.find(page => page.route === route).resources = resources.map(resource => resource.sourcePathURL.pathname); | ||
return resources; | ||
} | ||
function isLocalLink(url = '') { | ||
return url !== '' && (url.indexOf('http') !== 0 && url.indexOf('//') !== 0); | ||
} | ||
export { | ||
@@ -114,3 +178,4 @@ checkResourceExists, | ||
normalizePathnameForWindows, | ||
resolveForRelativeUrl | ||
resolveForRelativeUrl, | ||
trackResourcesForRoute | ||
}; |
@@ -11,3 +11,3 @@ /* eslint-disable max-depth, max-len */ | ||
const { outputDir } = compilation.context; | ||
const { resources } = compilation; | ||
const { resources, graph } = compilation; | ||
@@ -27,2 +27,4 @@ // https://stackoverflow.com/a/56150320/417806 | ||
})); | ||
await fs.writeFile(new URL('./graph.json', outputDir), JSON.stringify(graph)); | ||
} | ||
@@ -187,12 +189,3 @@ | ||
const input = []; | ||
// TODO ideally be able to serialize entire graph (or only an explicit subset?) | ||
// right now page.imports is breaking JSON.stringify | ||
// https://github.com/ProjectEvergreen/greenwood/issues/1008 | ||
const intermediateGraph = compilation.graph.map(page => { | ||
const p = { ...page }; | ||
delete p.imports; | ||
return p; | ||
}); | ||
if (!compilation.config.prerender) { | ||
@@ -213,3 +206,3 @@ for (const page of compilation.graph) { | ||
const htmlOptimizer = compilation.config.plugins.find(plugin => plugin.name === 'plugin-standard-html').provider(compilation); | ||
let body = 'Hello from the ${page.id} page!'; | ||
let body = ''; | ||
let html = ''; | ||
@@ -262,3 +255,3 @@ let frontmatter; | ||
moduleUrl: routeModuleLocationUrl.href, | ||
compilation: \`${JSON.stringify({ graph: intermediateGraph })}\`, | ||
compilation: \`${JSON.stringify(compilation)}\`, | ||
route: '${pagePath}' | ||
@@ -319,8 +312,2 @@ }); | ||
}); | ||
// centrally register all static resources | ||
compilation.graph.map((page) => { | ||
return page.imports; | ||
}).flat().forEach(resource => { | ||
compilation.resources.set(resource.sourcePathURL.pathname, resource); | ||
}); | ||
@@ -327,0 +314,0 @@ console.info('bundling static assets...'); |
@@ -45,11 +45,2 @@ import { checkResourceExists } from '../lib/resource-utils.js'; | ||
// hydrate URLs | ||
compilation.graph.forEach((page, idx) => { | ||
if (page.imports.length > 0) { | ||
page.imports.forEach((imp, jdx) => { | ||
compilation.graph[idx].imports[jdx].sourcePathURL = new URL(imp.sourcePathURL); | ||
}); | ||
} | ||
}); | ||
if (await checkResourceExists(new URL('./manifest.json', outputDir))) { | ||
@@ -56,0 +47,0 @@ console.info('Loading manifest from build output...'); |
/* eslint-disable complexity, max-depth */ | ||
import fs from 'fs/promises'; | ||
import fm from 'front-matter'; | ||
import { checkResourceExists, modelResource } from '../lib/resource-utils.js'; | ||
import { checkResourceExists } from '../lib/resource-utils.js'; | ||
import toc from 'markdown-toc'; | ||
@@ -22,3 +22,4 @@ import { Worker } from 'worker_threads'; | ||
data: {}, | ||
imports: [] | ||
imports: [], | ||
resources: [] | ||
}]; | ||
@@ -129,9 +130,3 @@ | ||
if (result.frontmatter) { | ||
const resources = await Promise.all((result.frontmatter.imports || []).map(async (resource) => { | ||
const type = resource.split('.').pop() === 'js' ? 'script' : 'link'; | ||
return await modelResource(compilation.context, type, resource); | ||
})); | ||
result.frontmatter.imports = resources; | ||
result.frontmatter.imports = result.frontmatter.imports || []; | ||
ssrFrontmatter = result.frontmatter; | ||
@@ -183,3 +178,4 @@ } | ||
* label: "pretty" text representation of the filename | ||
* imports: per page JS or CSS file imports to be included in HTML output | ||
* 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 | ||
@@ -200,2 +196,3 @@ * path: path to the file relative to the workspace | ||
imports, | ||
resources: [], | ||
outputPath: route === '/404/' | ||
@@ -313,2 +310,3 @@ ? '404.html' | ||
imports: [], | ||
resources: [], | ||
outputPath: `${node.route}index.html`, | ||
@@ -315,0 +313,0 @@ ...node, |
import fs from 'fs/promises'; | ||
import htmlparser from 'node-html-parser'; | ||
import { checkResourceExists, modelResource } from '../lib/resource-utils.js'; | ||
import { checkResourceExists, trackResourcesForRoute } from '../lib/resource-utils.js'; | ||
import os from 'os'; | ||
import { WorkerPool } from '../lib/threadpool.js'; | ||
function isLocalLink(url = '') { | ||
return url !== '' && (url.indexOf('http') !== 0 && url.indexOf('//') !== 0); | ||
} | ||
// TODO a lot of these are duplicated in the build lifecycle too | ||
// would be good to refactor | ||
async function createOutputDirectory(route, outputDir) { | ||
@@ -19,57 +16,2 @@ if (route !== '/404/' && !await checkResourceExists(outputDir)) { | ||
// 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 <script> / <style> / <link> tags to bundle | ||
async function trackResourcesForRoute(html, compilation, route) { | ||
const { context } = compilation; | ||
const root = htmlparser.parse(html, { | ||
script: true, | ||
style: true | ||
}); | ||
// intentionally support <script> tags from the <head> or <body> | ||
const scripts = await Promise.all(root.querySelectorAll('script') | ||
.filter(script => ( | ||
isLocalLink(script.getAttribute('src')) || script.rawText) | ||
&& script.rawAttrs.indexOf('importmap') < 0) | ||
.map(async(script) => { | ||
const src = script.getAttribute('src'); | ||
const optimizationAttr = script.getAttribute('data-gwd-opt'); | ||
const { rawAttrs } = script; | ||
if (src) { | ||
// <script src="...."></script> | ||
return await modelResource(context, 'script', src, null, optimizationAttr, rawAttrs); | ||
} else if (script.rawText) { | ||
// <script>...</script> | ||
return await modelResource(context, 'script', null, script.rawText, optimizationAttr, rawAttrs); | ||
} | ||
})); | ||
const styles = await Promise.all(root.querySelectorAll('style') | ||
.filter(style => !(/\$/).test(style.rawText) && !(/<!-- Shady DOM styles for -->/).test(style.rawText)) // filter out Shady DOM <style> tags that happen when using puppeteer | ||
.map(async(style) => await modelResource(context, 'style', null, style.rawText, null, style.getAttribute('data-gwd-opt')))); | ||
const links = await Promise.all(root.querySelectorAll('head link') | ||
.filter(link => { | ||
// <link rel="stylesheet" href="..."></link> | ||
return link.getAttribute('rel') === 'stylesheet' | ||
&& link.getAttribute('href') && isLocalLink(link.getAttribute('href')); | ||
}).map(async(link) => { | ||
return modelResource(context, 'link', link.getAttribute('href'), null, link.getAttribute('data-gwd-opt'), link.rawAttrs); | ||
})); | ||
const resources = [ | ||
...scripts, | ||
...styles, | ||
...links | ||
]; | ||
compilation.graph.find(page => page.route === route).imports = resources; | ||
return resources; | ||
} | ||
async function servePage(url, request, plugins) { | ||
@@ -120,3 +62,3 @@ let response = new Response(''); | ||
for (const page of pages) { | ||
const { route, outputPath } = page; | ||
const { route, outputPath, resources } = page; | ||
const outputDirUrl = new URL(`./${route}/`, scratchDir); | ||
@@ -132,4 +74,4 @@ const outputPathUrl = new URL(`./${outputPath}`, scratchDir); | ||
const resources = await trackResourcesForRoute(body, compilation, route); | ||
const scripts = resources | ||
.map(resource => compilation.resources.get(resource)) | ||
.filter(resource => resource.type === 'script') | ||
@@ -206,3 +148,2 @@ .map(resource => resource.sourcePathURL.href); | ||
await trackResourcesForRoute(body, compilation, route); | ||
await createOutputDirectory(route, outputDirUrl); | ||
@@ -209,0 +150,0 @@ await fs.writeFile(outputPathUrl, body); |
import fs from 'fs/promises'; | ||
import { hashString } from '../lib/hashing-utils.js'; | ||
import Koa from 'koa'; | ||
import { mergeResponse } from '../lib/resource-utils.js'; | ||
import { checkResourceExists, mergeResponse } from '../lib/resource-utils.js'; | ||
import { Readable } from 'stream'; | ||
@@ -230,30 +230,33 @@ import { ResourceInterface } from '../lib/resource-interface.js'; | ||
const url = new URL(`.${ctx.url}`, outputDir.href); | ||
const resourcePlugins = standardResourcePlugins | ||
.filter((plugin) => plugin.isStandardStaticResource) | ||
.map((plugin) => { | ||
return plugin.provider(compilation); | ||
if (await checkResourceExists(url)) { | ||
const resourcePlugins = standardResourcePlugins | ||
.filter((plugin) => plugin.isStandardStaticResource) | ||
.map((plugin) => { | ||
return plugin.provider(compilation); | ||
}); | ||
const request = new Request(url.href, { | ||
headers: new Headers(ctx.request.header) | ||
}); | ||
const initResponse = new Response(ctx.body, { | ||
status: ctx.response.status, | ||
headers: new Headers(ctx.response.header) | ||
}); | ||
const response = await resourcePlugins.reduce(async (responsePromise, plugin) => { | ||
return plugin.shouldServe && await plugin.shouldServe(url, request) | ||
? Promise.resolve(await plugin.serve(url, request)) | ||
: responsePromise; | ||
}, Promise.resolve(initResponse)); | ||
const request = new Request(url.href, { | ||
headers: new Headers(ctx.request.header) | ||
}); | ||
const initResponse = new Response(ctx.body, { | ||
status: ctx.response.status, | ||
headers: new Headers(ctx.response.header) | ||
}); | ||
const response = await resourcePlugins.reduce(async (responsePromise, plugin) => { | ||
return plugin.shouldServe && await plugin.shouldServe(url, request) | ||
? Promise.resolve(await plugin.serve(url, request)) | ||
: responsePromise; | ||
}, Promise.resolve(initResponse)); | ||
if (response.ok) { | ||
ctx.body = Readable.from(response.body); | ||
ctx.type = response.headers.get('Content-Type'); | ||
ctx.status = response.status; | ||
if (response.ok) { | ||
ctx.body = Readable.from(response.body); | ||
ctx.type = response.headers.get('Content-Type'); | ||
ctx.status = response.status; | ||
// TODO automatically loop and apply all custom headers to Koa response, include Content-Type below | ||
// https://github.com/ProjectEvergreen/greenwood/issues/1048 | ||
if (response.headers.has('Content-Length')) { | ||
ctx.set('Content-Length', response.headers.get('Content-Length')); | ||
// TODO automatically loop and apply all custom headers to Koa response, include Content-Type below | ||
// https://github.com/ProjectEvergreen/greenwood/issues/1048 | ||
if (response.headers.has('Content-Length')) { | ||
ctx.set('Content-Length', response.headers.get('Content-Length')); | ||
} | ||
} | ||
@@ -260,0 +263,0 @@ } |
@@ -7,3 +7,34 @@ /* | ||
import { ResourceInterface } from '../../lib/resource-interface.js'; | ||
import { Worker } from 'worker_threads'; | ||
// https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript | ||
function requestAsObject (request) { | ||
if (!request instanceof Request) { | ||
throw Object.assign( | ||
new Error(), | ||
{ name: 'TypeError', message: 'Argument must be a Request object' } | ||
); | ||
} | ||
request = request.clone(); | ||
function stringifiableObject (obj) { | ||
const filtered = {}; | ||
for (const key in obj) { | ||
if (['boolean', 'number', 'string'].includes(typeof obj[key]) || obj[key] === null) { | ||
filtered[key] = obj[key]; | ||
} | ||
} | ||
return filtered; | ||
} | ||
// TODO handle full response | ||
// https://github.com/ProjectEvergreen/greenwood/issues/1048 | ||
return { | ||
...stringifiableObject(request), | ||
headers: Object.fromEntries(request.headers), | ||
signal: stringifiableObject(request.signal) | ||
// bodyText: await request.text(), // requires function to be async | ||
}; | ||
} | ||
class ApiRoutesResource extends ResourceInterface { | ||
@@ -23,13 +54,36 @@ constructor(compilation, options) { | ||
const apiUrl = new URL(`.${api.path}`, this.compilation.context.userWorkspace); | ||
// https://github.com/nodejs/modules/issues/307#issuecomment-1165387383 | ||
const href = process.env.__GWD_COMMAND__ === 'develop' // eslint-disable-line no-underscore-dangle | ||
? `${apiUrl.href}?t=${Date.now()}` | ||
: apiUrl.href; | ||
const { handler } = await import(href); | ||
const req = new Request(new URL(`${request.url.origin}${url}`), { | ||
const href = apiUrl.href; | ||
const req = new Request(new URL(url), { | ||
...request | ||
}); | ||
return await handler(req); | ||
// TODO does this ever run in anything but development mode? | ||
if (process.env.__GWD_COMMAND__ === 'develop') { // eslint-disable-line no-underscore-dangle | ||
const workerUrl = new URL('../../lib/api-route-worker.js', import.meta.url); | ||
const response = await new Promise((resolve, reject) => { | ||
const worker = new Worker(workerUrl); | ||
const req = requestAsObject(request); | ||
worker.on('message', (result) => { | ||
resolve(result); | ||
}); | ||
worker.on('error', reject); | ||
worker.on('exit', (code) => { | ||
if (code !== 0) { | ||
reject(new Error(`Worker stopped with exit code ${code}`)); | ||
} | ||
}); | ||
worker.postMessage({ href, request: req }); | ||
}); | ||
return new Response(response.body, { | ||
...response | ||
}); | ||
} else { | ||
const { handler } = await import(href); | ||
return await handler(req); | ||
} | ||
} | ||
@@ -36,0 +90,0 @@ } |
@@ -221,7 +221,7 @@ /* eslint-disable complexity, max-depth */ | ||
const { pathname } = url; | ||
const pageResources = this.compilation.graph.find(page => page.outputPath === pathname || page.route === pathname).imports; | ||
const pageResources = this.compilation.graph.find(page => page.outputPath === pathname || page.route === pathname).resources; | ||
let body = await response.text(); | ||
for (const pageResource of pageResources) { | ||
const keyedResource = this.compilation.resources.get(pageResource.sourcePathURL.pathname); | ||
const keyedResource = this.compilation.resources.get(pageResource); | ||
const { contents, src, type, optimizationAttr, optimizedFileContents, optimizedFileName, rawAttributes } = keyedResource; | ||
@@ -228,0 +228,0 @@ |
167838
3942