diff --git a/.changeset/serious-boats-doubt.md b/.changeset/serious-boats-doubt.md new file mode 100644 index 00000000..10dc02c3 --- /dev/null +++ b/.changeset/serious-boats-doubt.md @@ -0,0 +1,5 @@ +--- +'pleasantest': minor +--- + +Add support for static files and css requested directly diff --git a/package-lock.json b/package-lock.json index 48cad46b..7b99f474 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "cjs-module-lexer": "^1.2.1", "es-module-lexer": "^0.6.0", "esbuild": "^0.12.11", + "mime": "^2.5.2", "postcss": "^8.3.5", "puppeteer": "^10.0.0", "rollup": "^2.52.3", @@ -14895,6 +14896,17 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", + "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.48.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.48.0.tgz", @@ -33053,6 +33065,11 @@ "picomatch": "^2.2.3" } }, + "mime": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", + "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==" + }, "mime-db": { "version": "1.48.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.48.0.tgz", diff --git a/package.json b/package.json index 66b36b37..4d958093 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "cjs-module-lexer": "^1.2.1", "es-module-lexer": "^0.6.0", "esbuild": "^0.12.11", + "mime": "^2.5.2", "postcss": "^8.3.5", "puppeteer": "^10.0.0", "rollup": "^2.52.3", diff --git a/rollup.config.js b/rollup.config.js index 11ef6834..42e336b3 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -44,6 +44,7 @@ const mainConfig = { '@rollup/plugin-commonjs', 'esbuild', /postcss/, + /mime/, ], }; diff --git a/src/module-server/index.ts b/src/module-server/index.ts index 476a606d..54ed371e 100644 --- a/src/module-server/index.ts +++ b/src/module-server/index.ts @@ -8,8 +8,10 @@ import { resolveExtensionsPlugin } from './plugins/resolve-extensions-plugin'; import { createServer } from './server'; import type { RollupAliasOptions } from '@rollup/plugin-alias'; import aliasPlugin from '@rollup/plugin-alias'; -import postcssPlugin from 'rollup-plugin-postcss'; import { esbuildPlugin } from './plugins/esbuild-plugin'; +import { cssPlugin } from './plugins/css'; +import { cssMiddleware } from './middleware/css'; +import { staticMiddleware } from './middleware/static'; interface ModuleServerOpts { root?: string; @@ -32,21 +34,14 @@ export const createModuleServer = async ({ processGlobalPlugin({ NODE_ENV: 'development' }), npmPlugin({ root }), esbuildPlugin(), - postcssPlugin({ - inject: (cssVariable) => { - return ` - const style = document.createElement('style') - style.type = 'text/css' - document.head.append(style) - style.appendChild(document.createTextNode(${cssVariable})) - `; - }, - }), + cssPlugin(), ]; const filteredPlugins = plugins.filter(Boolean) as Plugin[]; const middleware: polka.Middleware[] = [ indexHTMLMiddleware, jsMiddleware({ root, plugins: filteredPlugins }), + cssMiddleware({ root }), + staticMiddleware({ root }), ]; return createServer({ middleware }); }; diff --git a/src/module-server/middleware/css.ts b/src/module-server/middleware/css.ts new file mode 100644 index 00000000..66e256c8 --- /dev/null +++ b/src/module-server/middleware/css.ts @@ -0,0 +1,77 @@ +import { posix, relative, resolve, sep } from 'path'; +import type polka from 'polka'; +import { promises as fs } from 'fs'; +import { cssExts, cssPlugin } from '../plugins/css'; +import type { PluginContext, TransformPluginContext } from 'rollup'; + +interface CSSMiddlewareOpts { + root: string; +} + +/** + * This middleware handles _only_ css files that were _not imported from JS_ + * CSS files that were imported from JS have the ?import param and were handled by the JS middleware (transformed to JS) + * This middleware does not transform CSS to JS that can be imported, it just returns CSS + * Use cases: CSS included via tags, CSS included via @import + * TODO: consider using this for loadCSS + */ +export const cssMiddleware = ({ + root, +}: CSSMiddlewareOpts): polka.Middleware => { + const cssPlug = cssPlugin({ returnCSS: true }); + + return async (req, res, next) => { + try { + if (!cssExts.test(req.path)) return next(); + // Normalized path starting with slash + const path = posix.normalize(req.path); + // Remove leading slash, and convert slashes to os-specific slashes + const osPath = path.slice(1).split(posix.sep).join(sep); + // Absolute file path + const file = resolve(root, osPath); + // Rollup-style CWD-relative Unix-normalized path "id": + const id = `./${relative(root, file) + .replace(/^\.\//, '') + .replace(/^\0/, '') + .split(sep) + .join(posix.sep)}`; + + res.setHeader('Content-Type', 'text/css;charset=utf-8'); + let code = await fs.readFile(file, 'utf-8'); + + if (cssPlug.transform) { + const ctx: Partial = { + warn(...args) { + console.log(`[${cssPlug.name}]`, ...args); + }, + error(error) { + if (typeof error === 'string') throw new Error(error); + throw error; + }, + }; + // We need to call the transform hook, but get the CSS out of it before it converts it to JS + const result = await cssPlug.transform.call( + ctx as TransformPluginContext, + code, + id, + ); + if ( + typeof result !== 'object' || + result === null || + result.meta?.css === undefined + ) + return next(); + code = result.meta.css; + } + + if (!code) return next(); + + res.writeHead(200, { + 'Content-Length': Buffer.byteLength(code, 'utf-8'), + }); + res.end(code); + } catch (error) { + next(error); + } + }; +}; diff --git a/src/module-server/middleware/js.ts b/src/module-server/middleware/js.ts index 382ee056..db2b062d 100644 --- a/src/module-server/middleware/js.ts +++ b/src/module-server/middleware/js.ts @@ -10,6 +10,9 @@ interface JSMiddlewareOpts { plugins: Plugin[]; } +// TODO: make this configurable +const jsExts = /\.(?:[jt]sx?|[cm]js)$/; + // Minimal version of https://github.com/preactjs/wmr/blob/main/packages/wmr/src/wmr-middleware.js export const jsMiddleware = ({ @@ -56,6 +59,13 @@ export const jsMiddleware = ({ } if (!code && code !== '') { + // If it doesn't have a js-like extension, + // and none of the rollup plugins provided a load hook for it + // and it doesn't have the ?import param (added for non-JS assets that can be imported into JS, like css) + // Then treat it as a static asset + if (!jsExts.test(resolvedId) && req.query.import === undefined) + return next(); + // Always use the resolved id as the basis for our file let file = resolvedId; file = file.split(posix.sep).join(sep); @@ -91,6 +101,11 @@ export const jsMiddleware = ({ } } + // If it wasn't resovled, and doesn't have a js-like extension + // add the ?import query param so it is clear + // that the request needs to end up as JS that can be imported + if (!jsExts.test(spec)) return `${spec}?import`; + return spec; }, }); diff --git a/src/module-server/middleware/static.ts b/src/module-server/middleware/static.ts new file mode 100644 index 00000000..5a95c519 --- /dev/null +++ b/src/module-server/middleware/static.ts @@ -0,0 +1,34 @@ +import { posix, resolve } from 'path'; +import type polka from 'polka'; +import { promises as fs, createReadStream } from 'fs'; +import mime from 'mime/lite'; + +interface StaticMiddlewareOpts { + root: string; +} + +/** + * This middleware handles static assets that are requested + */ +export const staticMiddleware = ({ + root, +}: StaticMiddlewareOpts): polka.Middleware => { + return async (req, res, next) => { + try { + const absPath = resolve(root, ...req.path.split(posix.sep)); + if (!absPath.startsWith(root)) return next(); + + const stats = await fs.stat(absPath).catch((() => {}) as () => undefined); + if (!stats?.isFile()) return next(); + + const headers = { + 'Content-Type': (mime as any).getType(absPath) || '', + }; + + res.writeHead(200, headers); + createReadStream(absPath).pipe(res); + } catch (error) { + next(error); + } + }; +}; diff --git a/src/module-server/plugins/css.ts b/src/module-server/plugins/css.ts new file mode 100644 index 00000000..0ea8acf5 --- /dev/null +++ b/src/module-server/plugins/css.ts @@ -0,0 +1,59 @@ +import postcssPlugin from 'rollup-plugin-postcss'; +import { transformCssImports } from '../transform-css-imports'; +import { join } from 'path'; + +export const cssExts = /\.(?:css|styl|stylus|s[ac]ss|less)$/; +export const cssPlugin = ({ + returnCSS = false, +}: { returnCSS?: boolean } = {}) => { + const transformedCSS = new Map(); + const plugin = postcssPlugin({ + inject: (cssVariable) => { + return ` + const style = document.createElement('style') + style.type = 'text/css' + const promise = new Promise(r => style.addEventListener('load', r)) + style.appendChild(document.createTextNode(${cssVariable})) + document.head.append(style) + await promise + `; + }, + // They are executed right to left. We want our custom loader to run last + use: ['rewriteImports', 'sass', 'stylus', 'less'], + loaders: [ + { + // Rewrites emitted url(...) and @imports to be relative to the project root. + // Otherwise, relative paths don't work for injected stylesheets + name: 'rewriteImports', + test: /\.(?:css|styl|stylus|s[ac]ss|less)$/, + async process({ code, map }: { code: string; map?: string }) { + code = await transformCssImports(code, this.id, { + resolveId(specifier, id) { + if (!specifier.startsWith('./')) return specifier; + return join(id, '..', specifier); + }, + }); + if (returnCSS) { + transformedCSS.set(this.id, code); + } + + return { code, map }; + }, + }, + ], + }); + // Adds .meta.css to returned object (for use in CSS middleware) + if (returnCSS) { + const originalTranform = plugin.transform!; + plugin.transform = async function (code, id) { + let result = await originalTranform.call(this, code, id); + if (result === null || result === undefined) return result; + if (typeof result === 'string') result = { code: result, map: '' }; + if (!result.meta) result.meta = {}; + result.meta.css = transformedCSS.get(id); + return result; + }; + } + + return plugin; +}; diff --git a/src/module-server/server.ts b/src/module-server/server.ts index cce0f97c..e340dd10 100644 --- a/src/module-server/server.ts +++ b/src/module-server/server.ts @@ -26,7 +26,9 @@ export const createServer = ({ middleware }: ServerOpts) => const code = typeof err.code === 'number' ? err.code : 500; res.statusCode = code; + res.writeHead(code, { 'content-type': 'text/plain' }); + if (code === 404) return res.end('not found'); res.end(err.stack); console.error(err.stack); }, diff --git a/src/module-server/transform-css-imports.ts b/src/module-server/transform-css-imports.ts new file mode 100644 index 00000000..81da9508 --- /dev/null +++ b/src/module-server/transform-css-imports.ts @@ -0,0 +1,64 @@ +// Copied from https://github.com/preactjs/wmr/blob/18b8f00923ddf578f2f747c35bd24a8039eee3ba/packages/wmr/src/lib/transform-css-imports.js + +/* + https://github.com/preactjs/wmr/blob/main/LICENSE + MIT License + Copyright (c) 2020 The Preact Authors + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +/* + Differences from original: + - Types + - ESLint fixes + */ + +type MaybePromise = Promise | T; +type ResolveFn = ( + specifier: string, + id: string, +) => MaybePromise; + +/** + * @param code Module code + * @param id Source module specifier + */ +export const transformCssImports = async ( + code: string, + id: string, + { resolveId }: { resolveId: ResolveFn }, +) => { + const CSS_IMPORTS = /@import\s+["'](.*?)["'];|url\(["']?(.*?)["']?\)/g; + + let out = code; + let offset = 0; + + let match; + while ((match = CSS_IMPORTS.exec(code))) { + const spec = match[1] || match[2]; + const start = match.index + match[0].indexOf(spec) + offset; + const end = start + spec.length; + + const resolved = await resolveId(spec, id); + if (typeof resolved === 'string') { + out = out.slice(0, start) + resolved + out.slice(end); + offset += resolved.length - spec.length; + } + } + + return out; +}; diff --git a/tests/utils/external-with-reference.css b/tests/utils/external-with-reference.css new file mode 100644 index 00000000..a1b0b342 --- /dev/null +++ b/tests/utils/external-with-reference.css @@ -0,0 +1,8 @@ +@import './external.css'; + +div { + background-image: url('./smiley.svg'); + background-size: cover; + width: 500px; + height: 500px; +} diff --git a/tests/utils/loadCSS.test.ts b/tests/utils/loadCSS.test.ts index 832f07ef..227f1b24 100644 --- a/tests/utils/loadCSS.test.ts +++ b/tests/utils/loadCSS.test.ts @@ -50,3 +50,76 @@ test( ); test.todo('throws useful error message when imported file has syntax error'); + +test( + 'imported stylesheet has reference to another stylesheet', + withBrowser(async ({ utils, screen }) => { + await utils.injectHTML(` +

I'm a heading

+ `); + const heading = await screen.getByText(/i'm a heading/i); + await expect(heading).toBeVisible(); + await utils.loadCSS('./external-with-reference.css'); + await expect(heading).not.toBeVisible(); + }), +); + +test( + 'imported stylesheet has reference to static asset', + withBrowser(async ({ utils, page, screen }) => { + await page.setRequestInterception(true); + page.on('request', (interceptedRequest) => interceptedRequest.continue()); + await utils.injectHTML(` +
I have a background image
+ `); + + const timeout = 500; + + const stylesheet1Promise = page.waitForResponse( + (response) => { + const url = new URL(response.url()); + if (url.pathname !== '/tests/utils/external-with-reference.css') + return false; + // This CSS file is loaded via JS import so it needs to have a JS content-type + expect(response.headers()['content-type']).toEqual( + 'application/javascript;charset=utf-8', + ); + return true; + }, + { timeout }, + ); + + const stylesheet2Promise = page.waitForResponse( + (response) => { + const url = new URL(response.url()); + if (url.pathname !== '/tests/utils/external.css') return false; + // This CSS file is loaded via @import so it needs to have a CSS content-type + expect(response.headers()['content-type']).toEqual( + 'text/css;charset=utf-8', + ); + return true; + }, + { timeout }, + ); + + const imagePromise = page.waitForResponse( + (response) => { + const url = new URL(response.url()); + if (url.pathname !== '/tests/utils/smiley.svg') return false; + expect(response.headers()['content-type']).toEqual('image/svg+xml'); + return true; + }, + { timeout }, + ); + + await utils.loadCSS('./external-with-reference.css'); + await stylesheet1Promise; + await imagePromise; + await stylesheet2Promise; + + const div = await screen.getByText(/background/i); + expect( + await div.evaluate((div) => getComputedStyle(div).backgroundImage), + ).toMatch(/^url\(".*\/tests\/utils\/smiley.svg"\)$/); + }), +); diff --git a/tests/utils/runJS.test.tsx b/tests/utils/runJS.test.tsx index 21ef50a8..284ff85b 100644 --- a/tests/utils/runJS.test.tsx +++ b/tests/utils/runJS.test.tsx @@ -221,3 +221,16 @@ test( await expect(heading).toHaveTextContent('Hi'); }), ); + +test( + 'Allows importing CSS into JS file', + withBrowser(async ({ utils, screen }) => { + await utils.injectHTML('

This is a heading

'); + const heading = await screen.getByRole('heading'); + await expect(heading).toBeVisible(); + await utils.runJS(` + import './external.sass' + `); + await expect(heading).not.toBeVisible(); + }), +); diff --git a/tests/utils/smiley.svg b/tests/utils/smiley.svg new file mode 100644 index 00000000..c2d8f4c9 --- /dev/null +++ b/tests/utils/smiley.svg @@ -0,0 +1 @@ + \ No newline at end of file