diff --git a/.eslintignore b/.eslintignore index 7862cff23..8e786af06 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,5 @@ **/node_modules/** !.eslintrc.cjs !.mocharc.js -packages/plugin-babel/test/cases/**/*main.js \ No newline at end of file +packages/plugin-babel/test/cases/**/*main.js +TODO.md \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json index ec716fca9..7ac5e3ee9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,7 +34,6 @@ "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-replace": "^2.3.4", "@rollup/plugin-terser": "^0.1.0", - "@web/rollup-plugin-import-meta-assets": "^1.0.0", "acorn": "^8.0.1", "acorn-walk": "^8.0.0", "commander": "^2.20.0", diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index df59ef61f..4c7156860 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -1,20 +1,27 @@ -import fs from 'fs/promises'; +import fs from 'fs'; +import path from 'path'; 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'; +import * as walk from 'acorn-walk'; -// specifically to handle escodegen using require for package.json +// https://github.com/rollup/rollup/issues/2121 +function cleanRollupId(id) { + return id.replace('\x00', ''); +} + +// 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 extension = id.split('.').pop(); + const idUrl = new URL(`file://${cleanRollupId(id)}`); + const extension = idUrl.pathname.split('.').pop(); if (extension === 'json') { - const url = new URL(`file://${id}`); - const json = JSON.parse(await fs.readFile(url, 'utf-8')); + const json = JSON.parse(await fs.promises.readFile(idUrl, 'utf-8')); const contents = `export default ${JSON.stringify(json)}`; return contents; @@ -33,11 +40,11 @@ function greenwoodResourceLoader (compilation) { return { name: 'greenwood-resource-loader', async resolveId(id) { - const normalizedId = id.replace(/\?type=(.*)/, ''); + const normalizedId = cleanRollupId(id); // idUrl.pathname; const { projectDirectory, userWorkspace } = compilation.context; - if (id.startsWith('.') && !id.startsWith(projectDirectory.pathname)) { - const prefix = id.startsWith('..') ? './' : ''; + if (normalizedId.startsWith('.') && !normalizedId.startsWith(projectDirectory.pathname)) { + const prefix = normalizedId.startsWith('..') ? './' : ''; const userWorkspaceUrl = new URL(`${prefix}${normalizedId.replace(/\.\.\//g, '')}`, userWorkspace); if (await checkResourceExists(userWorkspaceUrl)) { @@ -46,11 +53,13 @@ function greenwoodResourceLoader (compilation) { } }, async load(id) { - const pathname = id.indexOf('?') >= 0 ? id.slice(0, id.indexOf('?')) : id; + const idUrl = new URL(`file://${cleanRollupId(id)}`); + const { pathname } = idUrl; const extension = pathname.split('.').pop(); - if (extension !== '' && extension !== 'js') { - const url = new URL(`file://${pathname}?type=${extension}`); + // filter first for any bare specifiers + if (await checkResourceExists(idUrl) && extension !== '' && extension !== 'js') { + const url = new URL(`${idUrl.href}?type=${extension}`); const request = new Request(url.href); let response = new Response(''); @@ -116,12 +125,12 @@ function greenwoodSyncPageResourceBundlesPlugin(compilation) { compilation.resources.set(resource.sourcePathURL.pathname, { ...compilation.resources.get(resource.sourcePathURL.pathname), optimizedFileName: fileName, - optimizedFileContents: await fs.readFile(outputPath, 'utf-8'), + optimizedFileContents: await fs.promises.readFile(outputPath, 'utf-8'), contents }); if (noop) { - await fs.writeFile(outputPath, contents); + await fs.promises.writeFile(outputPath, contents); } } } @@ -130,6 +139,138 @@ function greenwoodSyncPageResourceBundlesPlugin(compilation) { }; } +function getMetaImportPath(node) { + return node.arguments[0].value.split('/').join(path.sep); +} + +function isNewUrlImportMetaUrl(node) { + return ( + node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'URL' && + node.arguments.length === 2 && + node.arguments[0].type === 'Literal' && + typeof getMetaImportPath(node) === 'string' && + node.arguments[1].type === 'MemberExpression' && + node.arguments[1].object.type === 'MetaProperty' && + node.arguments[1].property.type === 'Identifier' && + node.arguments[1].property.name === 'url' + ); +} + +// adapted from, and with credit to @web/rollup-plugin-import-meta-assets +// https://modern-web.dev/docs/building/rollup-plugin-import-meta-assets/ +function greenwoodImportMetaUrl(compilation) { + + return { + name: 'greenwood-import-meta-url', + + async transform(code, id) { + const resourcePlugins = compilation.config.plugins.filter((plugin) => { + return plugin.type === 'resource'; + }).map((plugin) => { + return plugin.provider(compilation); + }); + const customResourcePlugins = compilation.config.plugins.filter((plugin) => { + return plugin.type === 'resource' && !plugin.isGreenwoodDefaultPlugin; + }).map((plugin) => { + return plugin.provider(compilation); + }); + const idUrl = new URL(`file://${cleanRollupId(id)}`); + const { pathname } = idUrl; + const extension = pathname.split('.').pop(); + const urlWithType = new URL(`${idUrl.href}?type=${extension}`); + const request = new Request(urlWithType.href); + let canTransform = false; + let response = new Response(code); + + // handle any custom imports or pre-processing needed before passing to Rollup this.parse + if (await checkResourceExists(idUrl) && extension !== '' && extension !== 'json') { + for (const plugin of resourcePlugins) { + if (plugin.shouldServe && await plugin.shouldServe(urlWithType, request)) { + response = await plugin.serve(urlWithType, request); + canTransform = true; + } + } + + for (const plugin of resourcePlugins) { + if (plugin.shouldIntercept && await plugin.shouldIntercept(urlWithType, request, response.clone())) { + response = await plugin.intercept(urlWithType, request, response.clone()); + canTransform = true; + } + } + } + + if (!canTransform) { + return null; + } + + const ast = this.parse(await response.text()); + const assetUrls = []; + let modifiedCode = false; + + // aggregate all references of new URL + import.meta.url + walk.simple(ast, { + NewExpression(node) { + if (isNewUrlImportMetaUrl(node)) { + const absoluteScriptDir = path.dirname(id); + const relativeAssetPath = getMetaImportPath(node); + const absoluteAssetPath = path.resolve(absoluteScriptDir, relativeAssetPath); + const assetName = path.basename(absoluteAssetPath); + const assetExtension = assetName.split('.').pop(); + + assetUrls.push({ + url: new URL(`file://${absoluteAssetPath}?type=${assetExtension}`), + relativeAssetPath + }); + } + } + }); + + for (const assetUrl of assetUrls) { + const { url } = assetUrl; + const { pathname } = url; + const { relativeAssetPath } = assetUrl; + const assetName = path.basename(pathname); + const assetExtension = assetName.split('.').pop(); + const assetContents = await fs.promises.readFile(url, 'utf-8'); + const name = assetName.replace(`.${assetExtension}`, ''); + let bundleExtensions = ['js']; + + for (const plugin of customResourcePlugins) { + if (plugin.shouldServe && await plugin.shouldServe(url)) { + const response = await plugin.serve(url); + + if (response?.headers?.get('content-type') || ''.indexOf('text/javascript') >= 0) { + bundleExtensions = [...bundleExtensions, ...plugin.extensions]; + } + } + } + + const type = bundleExtensions.indexOf(assetExtension) >= 0 + ? 'chunk' + : 'asset'; + const emitConfig = type === 'chunk' + ? { type, id: normalizePathnameForWindows(url), name } + : { type, name: assetName, source: assetContents }; + const ref = this.emitFile(emitConfig); + // handle Windows style paths + const normalizedRelativeAssetPath = relativeAssetPath.replace(/\\/g, '/'); + const importRef = `import.meta.ROLLUP_FILE_URL_${ref}`; + + modifiedCode = code + .replace(`'${normalizedRelativeAssetPath}'`, importRef) + .replace(`"${normalizedRelativeAssetPath}"`, importRef); + } + + return { + code: modifiedCode ? modifiedCode : code, + map: null + }; + } + }; +} + // 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 @@ -177,6 +318,7 @@ const getRollupConfigForScriptResources = async (compilation) => { plugins: [ greenwoodResourceLoader(compilation), greenwoodSyncPageResourceBundlesPlugin(compilation), + greenwoodImportMetaUrl(compilation), ...customRollupPlugins ], context: 'window', @@ -216,6 +358,11 @@ const getRollupConfigForApis = async (compilation) => { const input = [...compilation.manifest.apis.values()] .map(api => normalizePathnameForWindows(new URL(`.${api.path}`, userWorkspace))); + // why is this needed? + await fs.promises.mkdir(new URL('./api/assets/', outputDir), { + recursive: true + }); + // TODO should routes and APIs have chunks? // https://github.com/ProjectEvergreen/greenwood/issues/1118 return [{ @@ -227,9 +374,10 @@ const getRollupConfigForApis = async (compilation) => { }, plugins: [ greenwoodJsonLoader(), + greenwoodResourceLoader(compilation), nodeResolve(), commonjs(), - importMetaAssets() + greenwoodImportMetaUrl(compilation) ] }]; }; @@ -248,6 +396,7 @@ const getRollupConfigForSsr = async (compilation, input) => { }, plugins: [ greenwoodJsonLoader(), + greenwoodResourceLoader(compilation), // TODO let this through for lit to enable nodeResolve({ preferBuiltins: true }) // https://github.com/lit/lit/issues/449 // https://github.com/ProjectEvergreen/greenwood/issues/1118 @@ -255,7 +404,7 @@ const getRollupConfigForSsr = async (compilation, input) => { preferBuiltins: true }), commonjs(), - importMetaAssets(), + greenwoodImportMetaUrl(compilation), greenwoodPatchSsrPagesEntryPointRuntimeImport() // TODO a little hacky but works for now ], onwarn: (errorObj) => { 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 6dde6c7fa..bf60ae24c 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 @@ -18,6 +18,8 @@ * components/ * card.js * counter.js + * images/ + * logo.svg * pages/ * about.md * artists.js @@ -300,6 +302,60 @@ describe('Serve Greenwood With: ', function() { }); }); + describe('Bundled image using new URL and import.meta.url', function() { + const bundledName = 'assets/logo-abb2e884.svg'; + let bundledImageResponse = {}; + let usersResponse = {}; + + before(async function() { + await new Promise((resolve, reject) => { + request.get(`${hostname}/${bundledName}`, (err, res, body) => { + if (err) { + reject(); + } + + bundledImageResponse = res; + bundledImageResponse.body = body; + + resolve(); + }); + }); + + await new Promise((resolve, reject) => { + request.get(`${hostname}/_users.js`, (err, res, body) => { + if (err) { + reject(); + } + + usersResponse = res; + usersResponse.body = body; + + resolve(); + }); + }); + }); + + it('should return a 200 status for the image', function(done) { + expect(bundledImageResponse.statusCode).to.equal(200); + done(); + }); + + it('should return the expected content-type for the image', function(done) { + expect(bundledImageResponse.headers['content-type']).to.equal('image/svg+xml'); + done(); + }); + + it('should return the expected body for the image', function(done) { + expect(bundledImageResponse.body.startsWith('= 0).to.equal(true); + done(); + }); + }); + describe('Serve command with 404 not found behavior', function() { let response = {}; diff --git a/packages/cli/test/cases/serve.default.ssr/src/components/card.js b/packages/cli/test/cases/serve.default.ssr/src/components/card.js index 21456531a..e6579a592 100644 --- a/packages/cli/test/cases/serve.default.ssr/src/components/card.js +++ b/packages/cli/test/cases/serve.default.ssr/src/components/card.js @@ -1,3 +1,4 @@ +const logo = new URL('../images/logo.svg', import.meta.url); const template = document.createElement('template'); template.innerHTML = ` @@ -23,6 +24,7 @@ template.innerHTML = `
+ logo My default title
diff --git a/packages/cli/test/cases/serve.default.ssr/src/images/logo.svg b/packages/cli/test/cases/serve.default.ssr/src/images/logo.svg new file mode 100644 index 000000000..75abb3ff2 --- /dev/null +++ b/packages/cli/test/cases/serve.default.ssr/src/images/logo.svg @@ -0,0 +1,45 @@ + + + + + + + + + + diff --git a/packages/plugin-import-css/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js b/packages/plugin-import-css/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js new file mode 100644 index 000000000..3a2b29f72 --- /dev/null +++ b/packages/plugin-import-css/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js @@ -0,0 +1,178 @@ +/* + * Use Case + * Run Greenwood with an API and SSR routes that import CSS. + * + * User Result + * Should generate a Greenwood build that correctly builds and bundles all assets. + * + * User Command + * greenwood build + * + * User Config + * { + * plugins: [ + * greenwoodPluginImportCss() + * ] + * } + * + * User Workspace + * src/ + * api/ + * fragment.js + * components/ + * card.js + * card.css + * pages/ + * products.js + * services/ + * products.js + * styles/ + * some.css + */ +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import request from 'request'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; + +const expect = chai.expect; + +describe('Serve Greenwood With: ', function() { + const LABEL = 'A Server Rendered Application (SSR) with API Routes importing CSS'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const hostname = 'http://127.0.0.1:8080'; + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public'), + hostname + }; + runner = new Runner(false, true); + }); + + describe(LABEL, function() { + + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + + return new Promise(async (resolve) => { + setTimeout(() => { + resolve(); + }, 10000); + + await runner.runCommand(cliPath, 'serve'); + }); + }); + + describe('Serve command with HTML route response for products page', function() { + let response = {}; + let productsPageDom; + + before(async function() { + return new Promise((resolve, reject) => { + request.get(`${hostname}/products/`, (err, res, body) => { + if (err) { + reject(); + } + + response = res; + response.body = body; + productsPageDom = new JSDOM(body); + + resolve(); + }); + }); + }); + + it('should return a 200 status', function(done) { + expect(response.statusCode).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers['content-type']).to.equal('text/html'); + done(); + }); + + it('should return a response body', function(done) { + expect(response.body).to.not.be.undefined; + done(); + }); + + it('should have the expected import CSS in the page in the response body', function(done) { + const styleTag = productsPageDom.window.document.querySelectorAll('body > style'); + + expect(styleTag.length).to.equal(1); + expect(styleTag[0].textContent.replace(/ /g, '').replace(/\n/, '')).contain('h1{color:red;}'); + done(); + }); + + it('should make sure to have the expected CSS inlined into the page for each ', function(done) { + const cardComponents = productsPageDom.window.document.querySelectorAll('body app-card'); + + expect(cardComponents.length).to.equal(2); + Array.from(cardComponents).forEach((card) => { + expect(card.innerHTML).contain('display: flex;'); + }); + done(); + }); + }); + + describe('Serve command with API specific behaviors for an HTML ("fragment") API', function() { + let response = {}; + let fragmentsApiDom; + + before(async function() { + return new Promise((resolve, reject) => { + request.get(`${hostname}/api/fragment`, (err, res, body) => { + if (err) { + reject(); + } + + response = res; + response.body = body; + fragmentsApiDom = new JSDOM(body); + + resolve(); + }); + }); + }); + + it('should return a 200 status', function(done) { + expect(response.statusCode).to.equal(200); + done(); + }); + + it('should return a custom status message', function(done) { + expect(response.statusMessage).to.equal('OK'); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers['content-type']).to.equal('text/html'); + done(); + }); + + it('should make sure to have the expected CSS inlined into the page for each ', function(done) { + const cardComponents = fragmentsApiDom.window.document.querySelectorAll('body > app-card'); + + expect(cardComponents.length).to.equal(2); + Array.from(cardComponents).forEach((card) => { + expect(card.innerHTML).contain('display: flex;'); + }); + done(); + }); + }); + }); + + after(function() { + runner.teardown(getOutputTeardownFiles(outputPath)); + runner.stopCommand(); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-import-css/test/cases/exp-serve.ssr/greenwood.config.js b/packages/plugin-import-css/test/cases/exp-serve.ssr/greenwood.config.js new file mode 100644 index 000000000..9c52e8e22 --- /dev/null +++ b/packages/plugin-import-css/test/cases/exp-serve.ssr/greenwood.config.js @@ -0,0 +1,7 @@ +import { greenwoodPluginImportCss } from '../../../src/index.js'; + +export default { + plugins: [ + ...greenwoodPluginImportCss() + ] +}; \ No newline at end of file diff --git a/packages/plugin-import-css/test/cases/exp-serve.ssr/package.json b/packages/plugin-import-css/test/cases/exp-serve.ssr/package.json new file mode 100644 index 000000000..96fd81923 --- /dev/null +++ b/packages/plugin-import-css/test/cases/exp-serve.ssr/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-plugin-import-css-serve-ssr", + "type": "module" +} \ No newline at end of file diff --git a/packages/plugin-import-css/test/cases/exp-serve.ssr/src/api/fragment.js b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/api/fragment.js new file mode 100644 index 000000000..af4ced829 --- /dev/null +++ b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/api/fragment.js @@ -0,0 +1,28 @@ +import { renderFromHTML } from 'wc-compiler'; +import { getProducts } from '../services/products.js'; + +export async function handler() { + const products = await getProducts(); + const { html } = await renderFromHTML(` + ${ + products.map((product) => { + const { name, thumbnail } = product; + + return ` + + `; + }).join('') + } + `, [ + new URL('../components/card.js', import.meta.url) + ]); + + return new Response(html, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-import-css/test/cases/exp-serve.ssr/src/components/card.css b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/components/card.css new file mode 100644 index 000000000..db18c7c4a --- /dev/null +++ b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/components/card.css @@ -0,0 +1,44 @@ +div { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + border: 1px solid #818181; + width: fit-content; + border-radius: 10px; + padding: 2rem 1rem; + height: 680px; + justify-content: space-between; + background-color: #fff; + overflow-x: hidden; +} + +button { + background: var(--color-accent); + color: var(--color-white); + padding: 1rem 2rem; + border: 0; + font-size: 1rem; + border-radius: 5px; + cursor: pointer; +} + +img { + max-width: 500px; + min-width: 500px; + width: 100%; +} + +h3 { + font-size: 1.85rem; +} + +@media(max-width: 768px) { + img { + max-width: 300px; + min-width: 300px; + } + div { + height: 500px; + } +} \ No newline at end of file diff --git a/packages/plugin-import-css/test/cases/exp-serve.ssr/src/components/card.js b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/components/card.js new file mode 100644 index 000000000..faf3e725e --- /dev/null +++ b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/components/card.js @@ -0,0 +1,31 @@ +import styles from './card.css'; + +export default class Card extends HTMLElement { + + selectItem() { + alert(`selected item is => ${this.getAttribute('title')}!`); + } + + connectedCallback() { + if (!this.shadowRoot) { + const thumbnail = this.getAttribute('thumbnail'); + const title = this.getAttribute('title'); + const template = document.createElement('template'); + + template.innerHTML = ` + +
+

${title}

+ ${title} + +
+ `; + this.attachShadow({ mode: 'open' }); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + } + } +} + +customElements.define('app-card', Card); \ No newline at end of file diff --git a/packages/plugin-import-css/test/cases/exp-serve.ssr/src/pages/products.js b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/pages/products.js new file mode 100644 index 000000000..e5bf7b5bb --- /dev/null +++ b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/pages/products.js @@ -0,0 +1,31 @@ +import '../components/card.js'; +import { getProducts } from '../services/products.js'; +import styles from '../styles/some.css'; + +export default class ProductsPage extends HTMLElement { + async connectedCallback() { + const products = await getProducts(); + const html = products.map(product => { + const { name, thumbnail } = product; + + return ` + + + `; + }).join(''); + + this.innerHTML = ` +

SSR Page (w/ WCC)

+

List of Products: ${products.length}

+ +
+ ${html} +
+ `; + } +} \ No newline at end of file diff --git a/packages/plugin-import-css/test/cases/exp-serve.ssr/src/services/products.js b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/services/products.js new file mode 100644 index 000000000..96c999dac --- /dev/null +++ b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/services/products.js @@ -0,0 +1,11 @@ +async function getProducts() { + return [{ + name: 'iPhone 12', + thumbnail: 'iphone-12.png' + }, { + name: 'Samsung Galaxy', + thumbnail: 'samsung-galaxy.png' + }]; +} + +export { getProducts }; \ No newline at end of file diff --git a/packages/plugin-import-css/test/cases/exp-serve.ssr/src/styles/some.css b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/styles/some.css new file mode 100644 index 000000000..9054080ff --- /dev/null +++ b/packages/plugin-import-css/test/cases/exp-serve.ssr/src/styles/some.css @@ -0,0 +1,3 @@ +h1 { + color: red; +} \ No newline at end of file diff --git a/packages/plugin-import-json/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js b/packages/plugin-import-json/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js new file mode 100644 index 000000000..53035bf97 --- /dev/null +++ b/packages/plugin-import-json/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js @@ -0,0 +1,161 @@ +/* + * Use Case + * Run Greenwood with an API and SSR routes that import JSON. + * + * User Result + * Should generate a Greenwood build that correctly builds and bundles all assets. + * + * User Command + * greenwood build + * + * User Config + * { + * plugins: [ + * greenwoodPluginImportCss() + * ] + * } + * + * User Workspace + * src/ + * api/ + * fragment.js + * components/ + * card.js + * data/ + * products.json + * pages/ + * products.js + */ +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import request from 'request'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; + +const expect = chai.expect; + +describe('Serve Greenwood With: ', function() { + const LABEL = 'A Server Rendered Application (SSR) with API Routes importing JSON'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const hostname = 'http://127.0.0.1:8080'; + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public'), + hostname + }; + runner = new Runner(false, true); + }); + + describe(LABEL, function() { + + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + + return new Promise(async (resolve) => { + setTimeout(() => { + resolve(); + }, 10000); + + await runner.runCommand(cliPath, 'serve'); + }); + }); + + describe('Serve command with HTML route response for products page', function() { + let response = {}; + let productsPageDom; + + before(async function() { + return new Promise((resolve, reject) => { + request.get(`${hostname}/products/`, (err, res, body) => { + if (err) { + reject(); + } + + response = res; + response.body = body; + productsPageDom = new JSDOM(body); + + resolve(); + }); + }); + }); + + it('should return a 200 status', function(done) { + expect(response.statusCode).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers['content-type']).to.equal('text/html'); + done(); + }); + + it('should return a response body', function(done) { + expect(response.body).to.not.be.undefined; + done(); + }); + + it('should make sure to have the expected number of components on the page', function(done) { + const cardComponents = productsPageDom.window.document.querySelectorAll('body app-card'); + + expect(cardComponents.length).to.equal(2); + done(); + }); + }); + + describe('Serve command with API specific behaviors for an HTML ("fragment") API', function() { + let response = {}; + let fragmentsApiDom; + + before(async function() { + return new Promise((resolve, reject) => { + request.get(`${hostname}/api/fragment`, (err, res, body) => { + if (err) { + reject(); + } + + response = res; + response.body = body; + fragmentsApiDom = new JSDOM(body); + + resolve(); + }); + }); + }); + + it('should return a 200 status', function(done) { + expect(response.statusCode).to.equal(200); + done(); + }); + + it('should return a custom status message', function(done) { + expect(response.statusMessage).to.equal('OK'); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers['content-type']).to.equal('text/html'); + done(); + }); + + it('should make sure to have the expected number of components in the fragment', function(done) { + const cardComponents = fragmentsApiDom.window.document.querySelectorAll('body > app-card'); + + expect(cardComponents.length).to.equal(2); + done(); + }); + }); + }); + + after(function() { + runner.teardown(getOutputTeardownFiles(outputPath)); + runner.stopCommand(); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-import-json/test/cases/exp-serve.ssr/greenwood.config.js b/packages/plugin-import-json/test/cases/exp-serve.ssr/greenwood.config.js new file mode 100644 index 000000000..f1d82b86b --- /dev/null +++ b/packages/plugin-import-json/test/cases/exp-serve.ssr/greenwood.config.js @@ -0,0 +1,7 @@ +import { greenwoodPluginImportJson } from '../../../src/index.js'; + +export default { + plugins: [ + ...greenwoodPluginImportJson() + ] +}; \ No newline at end of file diff --git a/packages/plugin-import-json/test/cases/exp-serve.ssr/package.json b/packages/plugin-import-json/test/cases/exp-serve.ssr/package.json new file mode 100644 index 000000000..e8ffeb80f --- /dev/null +++ b/packages/plugin-import-json/test/cases/exp-serve.ssr/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-plugin-import-json-serve-ssr", + "type": "module" +} \ No newline at end of file diff --git a/packages/plugin-import-json/test/cases/exp-serve.ssr/src/api/fragment.js b/packages/plugin-import-json/test/cases/exp-serve.ssr/src/api/fragment.js new file mode 100644 index 000000000..5c87399f3 --- /dev/null +++ b/packages/plugin-import-json/test/cases/exp-serve.ssr/src/api/fragment.js @@ -0,0 +1,27 @@ +import { renderFromHTML } from 'wc-compiler'; +import products from '../data/products.json'; + +export async function handler() { + const { html } = await renderFromHTML(` + ${ + products.map((product) => { + const { name, thumbnail } = product; + + return ` + + `; + }).join('') + } + `, [ + new URL('../components/card.js', import.meta.url) + ]); + + return new Response(html, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-import-json/test/cases/exp-serve.ssr/src/components/card.js b/packages/plugin-import-json/test/cases/exp-serve.ssr/src/components/card.js new file mode 100644 index 000000000..f87ff6dd8 --- /dev/null +++ b/packages/plugin-import-json/test/cases/exp-serve.ssr/src/components/card.js @@ -0,0 +1,26 @@ +export default class Card extends HTMLElement { + + selectItem() { + alert(`selected item is => ${this.getAttribute('title')}!`); + } + + connectedCallback() { + if (!this.shadowRoot) { + const thumbnail = this.getAttribute('thumbnail'); + const title = this.getAttribute('title'); + const template = document.createElement('template'); + + template.innerHTML = ` +
+

${title}

+ ${title} + +
+ `; + this.attachShadow({ mode: 'open' }); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + } + } +} + +customElements.define('app-card', Card); \ No newline at end of file diff --git a/packages/plugin-import-json/test/cases/exp-serve.ssr/src/data/products.json b/packages/plugin-import-json/test/cases/exp-serve.ssr/src/data/products.json new file mode 100644 index 000000000..8cc5afbec --- /dev/null +++ b/packages/plugin-import-json/test/cases/exp-serve.ssr/src/data/products.json @@ -0,0 +1,7 @@ +[{ + "name": "iPhone 12", + "thumbnail": "iphone-12.png" +}, { + "name": "Samsung Galaxy", + "thumbnail": "samsung-galaxy.png" +}] \ No newline at end of file diff --git a/packages/plugin-import-json/test/cases/exp-serve.ssr/src/pages/products.js b/packages/plugin-import-json/test/cases/exp-serve.ssr/src/pages/products.js new file mode 100644 index 000000000..1358f8a57 --- /dev/null +++ b/packages/plugin-import-json/test/cases/exp-serve.ssr/src/pages/products.js @@ -0,0 +1,26 @@ +import '../components/card.js'; +import products from '../data/products.json'; + +export default class ProductsPage extends HTMLElement { + async connectedCallback() { + const html = products.map(product => { + const { name, thumbnail } = product; + + return ` + + + `; + }).join(''); + + this.innerHTML = ` +

SSR Page (w/ WCC)

+

List of Products: ${products.length}

+
+ ${html} +
+ `; + } +} \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js b/packages/plugin-typescript/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js new file mode 100644 index 000000000..05af075d6 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/exp-serve.ssr.spec.js @@ -0,0 +1,118 @@ +/* + * Use Case + * Run Greenwood with an API and SSR routes that import TypeScript. + * + * User Result + * Should generate a Greenwood build that correctly builds and bundles all assets. + * + * User Command + * greenwood build + * + * User Config + * { + * plugins: [ + * greenwoodPluginTypeScript() + * ] + * } + * + * User Workspace + * src/ + * api/ + * fragment.js + * components/ + * card.ts + */ +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import request from 'request'; +import { Runner } from 'gallinago'; +import { fileURLToPath } from 'url'; + +const expect = chai.expect; + +describe('Serve Greenwood With: ', function() { + const LABEL = 'A Server Rendered Application (SSR) with API Routes importing TypeScript'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const hostname = 'http://127.0.0.1:8080'; + let runner; + + before(async function() { + this.context = { + publicDir: path.join(outputPath, 'public'), + hostname + }; + runner = new Runner(false, true); + }); + + describe(LABEL, function() { + + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + await runner.runCommand(cliPath, 'build'); + + return new Promise(async (resolve) => { + setTimeout(() => { + resolve(); + }, 10000); + + await runner.runCommand(cliPath, 'serve'); + }); + }); + + describe('Serve command with API specific behaviors for an HTML ("fragment") API', function() { + let response = {}; + let fragmentsApiDom; + + before(async function() { + return new Promise((resolve, reject) => { + request.get(`${hostname}/api/fragment`, (err, res, body) => { + if (err) { + reject(); + } + + console.log({ body }); + response = res; + response.body = body; + fragmentsApiDom = new JSDOM(body); + + resolve(); + }); + }); + }); + + it('should return a 200 status', function(done) { + expect(response.statusCode).to.equal(200); + done(); + }); + + it('should return a custom status message', function(done) { + expect(response.statusMessage).to.equal('OK'); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers['content-type']).to.equal('text/html'); + done(); + }); + + it('should make sure to have the expected CSS inlined into the page for each ', function(done) { + const cardComponents = fragmentsApiDom.window.document.querySelectorAll('body > app-card'); + + expect(cardComponents.length).to.equal(2); + Array.from(cardComponents).forEach((card) => { + expect(card.innerHTML).contain('font-size: 1.85rem'); + }); + done(); + }); + }); + }); + + after(function() { + runner.teardown(getOutputTeardownFiles(outputPath)); + runner.stopCommand(); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/greenwood.config.js b/packages/plugin-typescript/test/cases/exp-serve.ssr/greenwood.config.js new file mode 100644 index 000000000..e0f06cfb5 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/greenwood.config.js @@ -0,0 +1,7 @@ +import { greenwoodPluginTypeScript } from '../../../src/index.js'; + +export default { + plugins: [ + ...greenwoodPluginTypeScript() + ] +}; \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/package.json b/packages/plugin-typescript/test/cases/exp-serve.ssr/package.json new file mode 100644 index 000000000..4929e3519 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-plugin-import-ts-serve-ssr", + "type": "module" +} \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/api/fragment.js b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/api/fragment.js new file mode 100644 index 000000000..d03a8f3e6 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/api/fragment.js @@ -0,0 +1,33 @@ +import { renderFromHTML } from 'wc-compiler'; + +export async function handler() { + const products = [{ + name: 'iPhone 12', + thumbnail: 'iphone-12.png' + }, { + name: 'Samsung Galaxy', + thumbnail: 'samsung-galaxy.png' + }]; + const { html } = await renderFromHTML(` + ${ + products.map((product) => { + const { name, thumbnail } = product; + + return ` + + `; + }).join('') + } + `, [ + new URL('../components/card/card.ts', import.meta.url) + ]); + + return new Response(html, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/card.ts b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/card.ts new file mode 100644 index 000000000..2cad71359 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/card.ts @@ -0,0 +1,33 @@ +import { styles } from './styles.ts'; + +const fallbackImage = new URL('./logo.png', import.meta.url); + +export default class Card extends HTMLElement { + + selectItem() { + alert(`selected item is => ${this.getAttribute('title')}!`); + } + + connectedCallback() { + if (!this.shadowRoot) { + const thumbnail: String = this.getAttribute('thumbnail') || fallbackImage.href; + const title: String = this.getAttribute('title'); + const template: any = document.createElement('template'); + + template.innerHTML = ` + +
+

${title}

+ ${title} + +
+ `; + this.attachShadow({ mode: 'open' }); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + } + } +} + +customElements.define('app-card', Card); \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/logo.png b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/logo.png new file mode 100644 index 000000000..786360832 Binary files /dev/null and b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/logo.png differ diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/styles.ts b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/styles.ts new file mode 100644 index 000000000..210e82df1 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/components/card/styles.ts @@ -0,0 +1,7 @@ +const styles: string = ` + h3 { + font-size: 1.85rem; + } +`; + +export { styles }; \ No newline at end of file diff --git a/packages/plugin-typescript/test/cases/exp-serve.ssr/src/pages/index.html b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/pages/index.html new file mode 100644 index 000000000..555a68287 --- /dev/null +++ b/packages/plugin-typescript/test/cases/exp-serve.ssr/src/pages/index.html @@ -0,0 +1,16 @@ + + + + + + + + +

Hello World!

+ + + + \ No newline at end of file diff --git a/www/pages/docs/css-and-images.md b/www/pages/docs/css-and-images.md index 657f048ee..893c01557 100644 --- a/www/pages/docs/css-and-images.md +++ b/www/pages/docs/css-and-images.md @@ -41,7 +41,7 @@ Styles can be done in any standards compliant way that will work in a browser. ### Assets -For convenience, **Greenwood** does support an "assets" directory wherein anything copied into that will be present in the build output directory. This is the recommended location for all your local images, fonts, etc. Effectively anything that is not part of an `import`, `@import`, `