diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index 8aa6f56c2..dc0d582d6 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -274,20 +274,33 @@ function greenwoodImportMetaUrl(compilation) { // TODO could we use this instead? // https://github.com/rollup/rollup/blob/v2.79.1/docs/05-plugin-development.md#resolveimportmeta // https://github.com/ProjectEvergreen/greenwood/issues/1087 -function greenwoodPatchSsrPagesEntryPointRuntimeImport() { +function greenwoodPatchSsrPagesEntryPointRuntimeImport(compilation) { return { name: 'greenwood-patch-ssr-pages-entry-point-runtime-import', generateBundle(options, bundle) { + const { pagesDir, scratchDir } = compilation.context; + Object.keys(bundle).forEach((key) => { - if (key.startsWith('__')) { - // ___GWD_ENTRY_FILE_URL=${filename}___ + // map rollup bundle names back to original SSR pages for output bundles and paths + if (key.startsWith('_')) { const needle = bundle[key].code.match(/___GWD_ENTRY_FILE_URL=(.*.)___/); - if (needle) { + + // handle windows shenanigans for facadeModuleId and path separators + if (new URL(`file://${bundle[key].facadeModuleId}`).pathname.startsWith(scratchDir.pathname) && needle) { const entryPathMatch = needle[1]; - bundle[key].code = bundle[key].code.replace(/'___GWD_ENTRY_FILE_URL=(.*.)___'/, `new URL('./_${entryPathMatch}', import.meta.url)`); - } else { - console.warn(`Could not find entry path match for bundle => ${key}`); + Object.keys(bundle).forEach((_) => { + // handle windows shenanigans for facadeModuleId and path separators + if (new URL(`file://${bundle[_].facadeModuleId}`).pathname === `${pagesDir.pathname}${entryPathMatch}`) { + bundle[key].code = bundle[key].code.replace(/'___GWD_ENTRY_FILE_URL=(.*.)___'/, `new URL('./${bundle[_].fileName}', import.meta.url)`); + + compilation.graph.forEach((page, idx) => { + if (page.relativeWorkspacePagePath === `/${entryPathMatch}`) { + compilation.graph[idx].outputPath = key; + } + }); + } + }); } } }); @@ -405,7 +418,7 @@ const getRollupConfigForSsr = async (compilation, input) => { }), commonjs(), greenwoodImportMetaUrl(compilation), - greenwoodPatchSsrPagesEntryPointRuntimeImport() // TODO a little hacky but works for now + greenwoodPatchSsrPagesEntryPointRuntimeImport(compilation) // TODO a little hacky but works for now ], onwarn: (errorObj) => { const { code, message } = errorObj; diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index e5a28fdd5..4aad70159 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -190,9 +190,10 @@ async function bundleSsrPages(compilation) { for (const page of compilation.graph) { if (page.isSSR && !page.prerender) { - const { filename, imports, route, template, title } = page; - const entryFileUrl = new URL(`./_${filename}`, scratchDir); - const moduleUrl = new URL(`./${filename}`, pagesDir); + const { 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 @@ -205,6 +206,12 @@ async function bundleSsrPages(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 + }); + } + // better way to write out this inline code? await fs.writeFile(entryFileUrl, ` import { executeRouteModule } from '${normalizePathnameForWindows(executeModuleUrl)}'; @@ -212,7 +219,7 @@ async function bundleSsrPages(compilation) { export async function handler(request) { const compilation = JSON.parse('${JSON.stringify(compilation)}'); const page = JSON.parse('${JSON.stringify(page)}'); - const moduleUrl = '___GWD_ENTRY_FILE_URL=${filename}___'; + const moduleUrl = '___GWD_ENTRY_FILE_URL=${relativeWorkspacePagePath.replace('/', '')}___'; const data = await executeRouteModule({ moduleUrl, compilation, page, request }); let staticHtml = \`${staticHtml}\`; diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js index ae82285e6..40b95f7a9 100644 --- a/packages/cli/src/lifecycles/graph.js +++ b/packages/cli/src/lifecycles/graph.js @@ -191,6 +191,7 @@ const generateGraph = async (compilation) => { * 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 @@ -206,6 +207,7 @@ const generateGraph = async (compilation) => { data: customData || {}, filename, id, + relativeWorkspacePagePath: relativePagePath, label: id.split('-') .map((idPart) => { return `${idPart.charAt(0).toUpperCase()}${idPart.substring(1)}`; diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index 678750374..7c94c29a8 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -294,7 +294,7 @@ async function getHybridServer(compilation) { const request = transformKoaRequestIntoStandardRequest(url, ctx.request); if (!config.prerender && matchingRoute.isSSR && !matchingRoute.prerender) { - const { handler } = await import(new URL(`./__${matchingRoute.filename}`, outputDir)); + const { handler } = await import(new URL(`./${matchingRoute.outputPath}`, outputDir)); const response = await handler(request, compilation); ctx.body = Readable.from(response.body); diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js index 8f0622545..b64fcee64 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-html.js +++ b/packages/cli/src/plugins/resource/plugin-standard-html.js @@ -105,7 +105,7 @@ class StandardHtmlResource extends ResourceInterface { } if (matchingRoute.isSSR) { - const routeModuleLocationUrl = new URL(`./${matchingRoute.filename}`, pagesDir); + const routeModuleLocationUrl = new URL(`.${matchingRoute.relativeWorkspacePagePath}`, pagesDir); const routeWorkerUrl = this.compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider().executeModuleUrl; await new Promise(async (resolve, reject) => { diff --git a/packages/cli/test/cases/build.plugins.context/theme-pack-context-plugin.js b/packages/cli/test/cases/build.plugins.context/theme-pack-context-plugin.js index bff573f54..2227fbad2 100644 --- a/packages/cli/test/cases/build.plugins.context/theme-pack-context-plugin.js +++ b/packages/cli/test/cases/build.plugins.context/theme-pack-context-plugin.js @@ -1,20 +1,15 @@ import fs from 'fs/promises'; -import os from 'os'; -import { spawnSync } from 'child_process'; const packageJson = JSON.parse(await fs.readFile(new URL('./package.json', import.meta.url), 'utf-8')); -const myThemePackPlugin = () => [{ +const myThemePackPlugin = (options = {}) => [{ type: 'context', name: 'my-theme-pack:context', provider: () => { const { name } = packageJson; - const command = os.platform() === 'win32' ? 'npm.cmd' : 'npm'; - const ls = spawnSync(command, ['ls', name]); - const isInstalled = ls.stdout.toString().indexOf('(empty)') < 0; - const templateLocation = isInstalled - ? new URL(`./node_modules/${name}/dist/layouts/`, import.meta.url) - : new URL('./fixtures/layouts/', import.meta.url); + const templateLocation = options.__isDevelopment // eslint-disable-line no-underscore-dangle + ? new URL('./fixtures/layouts/', import.meta.url) + : new URL(`./node_modules/${name}/dist/layouts/`, import.meta.url); return { templates: [ diff --git a/packages/cli/test/cases/develop.plugins.context/greenwood.config.js b/packages/cli/test/cases/develop.plugins.context/greenwood.config.js index 74c311257..60eaf3c94 100644 --- a/packages/cli/test/cases/develop.plugins.context/greenwood.config.js +++ b/packages/cli/test/cases/develop.plugins.context/greenwood.config.js @@ -24,7 +24,9 @@ class MyThemePackDevelopmentResource extends ResourceInterface { export default { plugins: [ - ...myThemePackPlugin(), + ...myThemePackPlugin({ + __isDevelopment: true + }), { type: 'resource', name: 'my-theme-pack:resource', diff --git a/packages/cli/test/cases/develop.ssr/develop.ssr.spec.js b/packages/cli/test/cases/develop.ssr/develop.ssr.spec.js index 4f7919f66..18ecbccbf 100644 --- a/packages/cli/test/cases/develop.ssr/develop.ssr.spec.js +++ b/packages/cli/test/cases/develop.ssr/develop.ssr.spec.js @@ -16,6 +16,9 @@ * components/ * footer.js * pages/ + * blog + * first-post.js + * index.js * artists.js * post.js * templates/ @@ -284,6 +287,94 @@ describe('Develop Greenwood With: ', function() { expect(paragraph[0].textContent).to.not.be.undefined; }); }); + + describe('Develop command with HTML route response using default export and nested SSR Blog Index page', function() { + let response = {}; + let dom = {}; + let body; + + before(async function() { + response = await fetch(`${hostname}/blog/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it('should return a 200 status', function(done) { + expect(response.status).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers.get('content-type')).to.equal('text/html'); + done(); + }); + + it('should return a response body', function(done) { + expect(body).to.not.be.undefined; + done(); + }); + + it('should be valid HTML from JSDOM', function(done) { + expect(dom).to.not.be.undefined; + done(); + }); + + it('should be valid HTML from JSDOM', function(done) { + expect(dom).to.not.be.undefined; + done(); + }); + + it('should have the expected postId as an

tag in the body', function() { + const heading = dom.window.document.querySelectorAll('body > h1'); + + expect(heading.length).to.equal(1); + expect(heading[0].textContent).to.equal('Nested SSR page should work!'); + }); + }); + + describe('Develop command with HTML route response using default export and nested SSR Blog First Post page', function() { + let response = {}; + let dom = {}; + let body; + + before(async function() { + response = await fetch(`${hostname}/blog/first-post/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it('should return a 200 status', function(done) { + expect(response.status).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers.get('content-type')).to.equal('text/html'); + done(); + }); + + it('should return a response body', function(done) { + expect(body).to.not.be.undefined; + done(); + }); + + it('should be valid HTML from JSDOM', function(done) { + expect(dom).to.not.be.undefined; + done(); + }); + + it('should be valid HTML from JSDOM', function(done) { + expect(dom).to.not.be.undefined; + done(); + }); + + it('should have the expected postId as an

tag in the body', function() { + const heading = dom.window.document.querySelectorAll('body > h1'); + + expect(heading.length).to.equal(1); + expect(heading[0].textContent).to.equal('Nested SSR First Post page should work!'); + }); + }); }); after(function() { diff --git a/packages/cli/test/cases/develop.ssr/src/pages/blog/first-post.js b/packages/cli/test/cases/develop.ssr/src/pages/blog/first-post.js new file mode 100644 index 000000000..d65087023 --- /dev/null +++ b/packages/cli/test/cases/develop.ssr/src/pages/blog/first-post.js @@ -0,0 +1,7 @@ +export default class BlogFirstPostPage extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +

Nested SSR First Post page should work!

+ `; + } +} \ No newline at end of file diff --git a/packages/cli/test/cases/develop.ssr/src/pages/blog/index.js b/packages/cli/test/cases/develop.ssr/src/pages/blog/index.js new file mode 100644 index 000000000..331617673 --- /dev/null +++ b/packages/cli/test/cases/develop.ssr/src/pages/blog/index.js @@ -0,0 +1,7 @@ +export default class BlogPage extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +

Nested SSR page should work!

+ `; + } +} \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js b/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js index 7a4b01b35..393ca909a 100644 --- a/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js +++ b/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js @@ -23,6 +23,9 @@ * pages/ * about.md * artists.js + * blog/ + * first-post.js + * index.js * index.js * post.js * users.js @@ -272,6 +275,94 @@ describe('Serve Greenwood With: ', function() { }); }); + describe('Serve command with HTML route response using default export and nested SSR Blog Index page', function() { + let response = {}; + let dom = {}; + let body; + + before(async function() { + response = await fetch(`${hostname}/blog/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it('should return a 200 status', function(done) { + expect(response.status).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers.get('content-type')).to.equal('text/html'); + done(); + }); + + it('should return a response body', function(done) { + expect(body).to.not.be.undefined; + done(); + }); + + it('should be valid HTML from JSDOM', function(done) { + expect(dom).to.not.be.undefined; + done(); + }); + + it('should be valid HTML from JSDOM', function(done) { + expect(dom).to.not.be.undefined; + done(); + }); + + it('should have the expected postId as an

tag in the body', function() { + const heading = dom.window.document.querySelectorAll('body > h1'); + + expect(heading.length).to.equal(1); + expect(heading[0].textContent).to.equal('Nested SSR page should work!'); + }); + }); + + describe('Develop command with HTML route response using default export and nested SSR Blog First Post page', function() { + let response = {}; + let dom = {}; + let body; + + before(async function() { + response = await fetch(`${hostname}/blog/first-post/`); + body = await response.clone().text(); + dom = new JSDOM(body); + }); + + it('should return a 200 status', function(done) { + expect(response.status).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers.get('content-type')).to.equal('text/html'); + done(); + }); + + it('should return a response body', function(done) { + expect(body).to.not.be.undefined; + done(); + }); + + it('should be valid HTML from JSDOM', function(done) { + expect(dom).to.not.be.undefined; + done(); + }); + + it('should be valid HTML from JSDOM', function(done) { + expect(dom).to.not.be.undefined; + done(); + }); + + it('should have the expected postId as an

tag in the body', function() { + const heading = dom.window.document.querySelectorAll('body > h1'); + + expect(heading.length).to.equal(1); + expect(heading[0].textContent).to.equal('Nested SSR First Post page should work!'); + }); + }); + describe('Bundled image using new URL and import.meta.url', function() { const bundledName = 'assets/logo-abb2e884.svg'; let response = {}; diff --git a/packages/cli/test/cases/serve.default.ssr/src/pages/blog/first-post.js b/packages/cli/test/cases/serve.default.ssr/src/pages/blog/first-post.js new file mode 100644 index 000000000..d65087023 --- /dev/null +++ b/packages/cli/test/cases/serve.default.ssr/src/pages/blog/first-post.js @@ -0,0 +1,7 @@ +export default class BlogFirstPostPage extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +

Nested SSR First Post page should work!

+ `; + } +} \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.ssr/src/pages/blog/index.js b/packages/cli/test/cases/serve.default.ssr/src/pages/blog/index.js new file mode 100644 index 000000000..331617673 --- /dev/null +++ b/packages/cli/test/cases/serve.default.ssr/src/pages/blog/index.js @@ -0,0 +1,7 @@ +export default class BlogPage extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +

Nested SSR page should work!

+ `; + } +} \ No newline at end of file diff --git a/www/pages/docs/data.md b/www/pages/docs/data.md index 530afc393..c7e60cfe4 100644 --- a/www/pages/docs/data.md +++ b/www/pages/docs/data.md @@ -62,6 +62,8 @@ graph { outputPath, // (string) the relative path to write to when generating static HTML + relativeWorkspacePagePath, // the file path relative to the user's workspace directory + path, // (string) path to the file route, // (string) A URL, typically derived from the filesystem path, e.g. /blog/2019/first-post/