diff --git a/.changeset/eleven-baboons-try.md b/.changeset/eleven-baboons-try.md new file mode 100644 index 000000000000..f953ae10c0f0 --- /dev/null +++ b/.changeset/eleven-baboons-try.md @@ -0,0 +1,5 @@ +--- +'@astrojs/image': minor +--- + +Added a `background` option to specify a background color to replace transparent pixels (alpha layer). diff --git a/packages/integrations/image/README.md b/packages/integrations/image/README.md index 2ecc813640a4..fda9727e6b31 100644 --- a/packages/integrations/image/README.md +++ b/packages/integrations/image/README.md @@ -24,9 +24,9 @@ This integration provides `` and `` components as well as a ba ### Quick Install - + The `astro add` command-line tool automates the installation for you. Run one of the following commands in a new terminal window. (If you aren't sure which package manager you're using, run the first command.) Then, follow the prompts, and type "y" in the terminal (meaning "yes") for each one. - + ```sh # Using NPM npx astro add image @@ -35,13 +35,13 @@ yarn astro add image # Using PNPM pnpm astro add image ``` - + Finally, in the terminal window running Astro, press `CTRL+C` and then restart the dev server. If you run into any issues, [feel free to report them to us on GitHub](https://github.com/withastro/astro/issues) and try the manual installation steps below. ### Manual Install - + First, install the `@astrojs/image` package using your package manager. If you're using npm or aren't sure, run this in the terminal: ```sh npm install @astrojs/image @@ -57,7 +57,7 @@ export default { // ... integrations: [image()], } -``` +``` Then, restart the dev server. ### Update `env.d.ts` @@ -190,6 +190,24 @@ A `string` can be provided in the form of `{width}:{height}`, ex: `16:9` or `3:4 A `number` can also be provided, useful when the aspect ratio is calculated at build time. This can be an inline number such as `1.777` or inlined as a JSX expression like `aspectRatio={16/9}`. +#### background + +

+ +**Type:** `ColorDefinition`
+**Default:** `undefined` +

+ +The background color to use for replacing the alpha channel with `sharp`'s `flatten` method. In case the output format +doesn't support transparency (i.e. `jpeg`), it's advisable to include a background color, otherwise black will be used +as default replacement for transparent pixels. + +The parameter accepts a `string` as value. + +The parameter can be a [named HTML color](https://www.w3schools.com/tags/ref_colornames.asp), a hexadecimal +color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`, or an RGB definition in the form +`rgb(100,100,100)`. + ### ` #### src @@ -271,6 +289,24 @@ A `number` can also be provided, useful when the aspect ratio is calculated at b The output formats to be used in the optimized image. If not provided, `webp` and `avif` will be used in addition to the original image format. +#### background + +

+ +**Type:** `ColorDefinition`
+**Default:** `undefined` +

+ +The background color to use for replacing the alpha channel with `sharp`'s `flatten` method. In case the output format +doesn't support transparency (i.e. `jpeg`), it's advisable to include a background color, otherwise black will be used +as default replacement for transparent pixels. + +The parameter accepts a `string` as value. + +The parameter can be a [named HTML color](https://www.w3schools.com/tags/ref_colornames.asp), a hexadecimal +color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`, or an RGB definition in the form +`rgb(100,100,100)`. + ### `getImage` This is the helper function used by the `` component to build `` attributes for the transformed image. This helper can be used directly for more complex use cases that aren't currently supported by the `` component. @@ -307,7 +343,7 @@ The integration can be configured to run with a different image service, either ### config.serviceEntryPoint - + The `serviceEntryPoint` should resolve to the image service installed from NPM. The default entry point is `@astrojs/image/sharp`, which resolves to the entry point exported from this integration's `package.json`. ```js @@ -342,7 +378,7 @@ export default { ## Examples ### Local images - + Image files in your project's `src` directory can be imported in frontmatter and passed directly to the `` component. All other properties are optional and will default to the original image file's properties if not provided. ```astro @@ -371,7 +407,7 @@ import heroImage from '../assets/hero.png'; Files in the `/public` directory are always served or copied as-is, with no processing. We recommend that local images are always kept in `src/` so that Astro can transform, optimize and bundle them. But if you absolutely must keep an image in `public/`, use its relative URL path as the image's `src=` attribute. It will be treated as a remote image, which requires an `aspectRatio` attribute. -Alternatively, you can import an image from your `public/` directory in your frontmatter and use a variable in your `src=` attribute. You cannot, however, import this directly inside the component as its `src` value. +Alternatively, you can import an image from your `public/` directory in your frontmatter and use a variable in your `src=` attribute. You cannot, however, import this directly inside the component as its `src` value. For example, use an image located at `public/social.png` in either static or SSR builds like so: @@ -386,7 +422,7 @@ import socialImage from '/social.png'; ``` ### Remote images - + Remote images can be transformed with the `` component. The `` component needs to know the final dimensions for the `` element to avoid content layout shifts. For remote images, this means you must either provide `width` and `height`, or one of the dimensions plus the required `aspectRatio`. ```astro diff --git a/packages/integrations/image/components/Picture.astro b/packages/integrations/image/components/Picture.astro index 7fe43d9dbe1b..e28f5bf409f7 100644 --- a/packages/integrations/image/components/Picture.astro +++ b/packages/integrations/image/components/Picture.astro @@ -27,6 +27,7 @@ interface RemoteImageProps widths: number[]; aspectRatio: TransformOptions['aspectRatio']; formats?: OutputFormat[]; + background: TransformOptions['background']; } export type Props = LocalImageProps | RemoteImageProps; @@ -37,6 +38,7 @@ const { sizes, widths, aspectRatio, + background, formats = ['avif', 'webp'], loading = 'lazy', decoding = 'async', @@ -47,7 +49,7 @@ if (alt === undefined || alt === null) { warnForMissingAlt(); } -const { image, sources } = await getPicture({ src, widths, formats, aspectRatio }); +const { image, sources } = await getPicture({ src, widths, formats, aspectRatio, background }); --- diff --git a/packages/integrations/image/src/lib/get-image.ts b/packages/integrations/image/src/lib/get-image.ts index 15a0d91dbccb..856f9f8c6d90 100644 --- a/packages/integrations/image/src/lib/get-image.ts +++ b/packages/integrations/image/src/lib/get-image.ts @@ -1,5 +1,10 @@ /// -import type { ImageService, OutputFormat, TransformOptions } from '../loaders/index.js'; +import type { + ColorDefinition, + ImageService, + OutputFormat, + TransformOptions, +} from '../loaders/index.js'; import { isSSRService, parseAspectRatio } from '../loaders/index.js'; import sharp from '../loaders/sharp.js'; import { isRemoteImage } from '../utils/paths.js'; @@ -63,7 +68,7 @@ async function resolveTransform(input: GetImageTransform): Promise getSource(format))); diff --git a/packages/integrations/image/src/loaders/colornames.ts b/packages/integrations/image/src/loaders/colornames.ts new file mode 100644 index 000000000000..806e55f31d1e --- /dev/null +++ b/packages/integrations/image/src/loaders/colornames.ts @@ -0,0 +1,290 @@ +export type NamedColor = + | 'aliceblue' + | 'antiquewhite' + | 'aqua' + | 'aquamarine' + | 'azure' + | 'beige' + | 'bisque' + | 'black' + | 'blanchedalmond' + | 'blue' + | 'blueviolet' + | 'brown' + | 'burlywood' + | 'cadetblue' + | 'chartreuse' + | 'chocolate' + | 'coral' + | 'cornflowerblue' + | 'cornsilk' + | 'crimson' + | 'cyan' + | 'darkblue' + | 'darkcyan' + | 'darkgoldenrod' + | 'darkgray' + | 'darkgreen' + | 'darkkhaki' + | 'darkmagenta' + | 'darkolivegreen' + | 'darkorange' + | 'darkorchid' + | 'darkred' + | 'darksalmon' + | 'darkseagreen' + | 'darkslateblue' + | 'darkslategray' + | 'darkturquoise' + | 'darkviolet' + | 'deeppink' + | 'deepskyblue' + | 'dimgray' + | 'dodgerblue' + | 'firebrick' + | 'floralwhite' + | 'forestgreen' + | 'fuchsia' + | 'gainsboro' + | 'ghostwhite' + | 'gold' + | 'goldenrod' + | 'gray' + | 'green' + | 'greenyellow' + | 'honeydew' + | 'hotpink' + | 'indianred' + | 'indigo' + | 'ivory' + | 'khaki' + | 'lavender' + | 'lavenderblush' + | 'lawngreen' + | 'lemonchiffon' + | 'lightblue' + | 'lightcoral' + | 'lightcyan' + | 'lightgoldenrodyellow' + | 'lightgray' + | 'lightgreen' + | 'lightpink' + | 'lightsalmon' + | 'lightsalmon' + | 'lightseagreen' + | 'lightskyblue' + | 'lightslategray' + | 'lightsteelblue' + | 'lightyellow' + | 'lime' + | 'limegreen' + | 'linen' + | 'magenta' + | 'maroon' + | 'mediumaquamarine' + | 'mediumblue' + | 'mediumorchid' + | 'mediumpurple' + | 'mediumseagreen' + | 'mediumslateblue' + | 'mediumslateblue' + | 'mediumspringgreen' + | 'mediumturquoise' + | 'mediumvioletred' + | 'midnightblue' + | 'mintcream' + | 'mistyrose' + | 'moccasin' + | 'navajowhite' + | 'navy' + | 'oldlace' + | 'olive' + | 'olivedrab' + | 'orange' + | 'orangered' + | 'orchid' + | 'palegoldenrod' + | 'palegreen' + | 'paleturquoise' + | 'palevioletred' + | 'papayawhip' + | 'peachpuff' + | 'peru' + | 'pink' + | 'plum' + | 'powderblue' + | 'purple' + | 'rebeccapurple' + | 'red' + | 'rosybrown' + | 'royalblue' + | 'saddlebrown' + | 'salmon' + | 'sandybrown' + | 'seagreen' + | 'seashell' + | 'sienna' + | 'silver' + | 'skyblue' + | 'slateblue' + | 'slategray' + | 'snow' + | 'springgreen' + | 'steelblue' + | 'tan' + | 'teal' + | 'thistle' + | 'tomato' + | 'turquoise' + | 'violet' + | 'wheat' + | 'white' + | 'whitesmoke' + | 'yellow' + | 'yellowgreen'; + +export const htmlColorNames: NamedColor[] = [ + 'aliceblue', + 'antiquewhite', + 'aqua', + 'aquamarine', + 'azure', + 'beige', + 'bisque', + 'black', + 'blanchedalmond', + 'blue', + 'blueviolet', + 'brown', + 'burlywood', + 'cadetblue', + 'chartreuse', + 'chocolate', + 'coral', + 'cornflowerblue', + 'cornsilk', + 'crimson', + 'cyan', + 'darkblue', + 'darkcyan', + 'darkgoldenrod', + 'darkgray', + 'darkgreen', + 'darkkhaki', + 'darkmagenta', + 'darkolivegreen', + 'darkorange', + 'darkorchid', + 'darkred', + 'darksalmon', + 'darkseagreen', + 'darkslateblue', + 'darkslategray', + 'darkturquoise', + 'darkviolet', + 'deeppink', + 'deepskyblue', + 'dimgray', + 'dodgerblue', + 'firebrick', + 'floralwhite', + 'forestgreen', + 'fuchsia', + 'gainsboro', + 'ghostwhite', + 'gold', + 'goldenrod', + 'gray', + 'green', + 'greenyellow', + 'honeydew', + 'hotpink', + 'indianred', + 'indigo', + 'ivory', + 'khaki', + 'lavender', + 'lavenderblush', + 'lawngreen', + 'lemonchiffon', + 'lightblue', + 'lightcoral', + 'lightcyan', + 'lightgoldenrodyellow', + 'lightgray', + 'lightgreen', + 'lightpink', + 'lightsalmon', + 'lightsalmon', + 'lightseagreen', + 'lightskyblue', + 'lightslategray', + 'lightsteelblue', + 'lightyellow', + 'lime', + 'limegreen', + 'linen', + 'magenta', + 'maroon', + 'mediumaquamarine', + 'mediumblue', + 'mediumorchid', + 'mediumpurple', + 'mediumseagreen', + 'mediumslateblue', + 'mediumslateblue', + 'mediumspringgreen', + 'mediumturquoise', + 'mediumvioletred', + 'midnightblue', + 'mintcream', + 'mistyrose', + 'moccasin', + 'navajowhite', + 'navy', + 'oldlace', + 'olive', + 'olivedrab', + 'orange', + 'orangered', + 'orchid', + 'palegoldenrod', + 'palegreen', + 'paleturquoise', + 'palevioletred', + 'papayawhip', + 'peachpuff', + 'peru', + 'pink', + 'plum', + 'powderblue', + 'purple', + 'rebeccapurple', + 'red', + 'rosybrown', + 'royalblue', + 'saddlebrown', + 'salmon', + 'sandybrown', + 'seagreen', + 'seashell', + 'sienna', + 'silver', + 'skyblue', + 'slateblue', + 'slategray', + 'snow', + 'springgreen', + 'steelblue', + 'tan', + 'teal', + 'thistle', + 'tomato', + 'turquoise', + 'violet', + 'wheat', + 'white', + 'whitesmoke', + 'yellow', + 'yellowgreen', +]; diff --git a/packages/integrations/image/src/loaders/index.ts b/packages/integrations/image/src/loaders/index.ts index 58a9924a8913..c3ca5a59a621 100644 --- a/packages/integrations/image/src/loaders/index.ts +++ b/packages/integrations/image/src/loaders/index.ts @@ -1,3 +1,5 @@ +import { type NamedColor, htmlColorNames } from './colornames.js'; + /// export type InputFormat = | 'heic' @@ -10,16 +12,35 @@ export type InputFormat = | 'webp' | 'gif'; -export type OutputFormat = 'avif' | 'jpeg' | 'png' | 'webp'; +export type OutputFormatSupportsAlpha = 'avif' | 'png' | 'webp'; +export type OutputFormat = OutputFormatSupportsAlpha | 'jpeg'; + +export type ColorDefinition = + | NamedColor + | `#${string}` + | `rgb(${number}, ${number}, ${number})` + | `rgb(${number},${number},${number})`; export function isOutputFormat(value: string): value is OutputFormat { return ['avif', 'jpeg', 'png', 'webp'].includes(value); } +export function isOutputFormatSupportsAlpha(value: string): value is OutputFormatSupportsAlpha { + return ['avif', 'png', 'webp'].includes(value); +} + export function isAspectRatioString(value: string): value is `${number}:${number}` { return /^\d*:\d*$/.test(value); } +export function isColor(value: string): value is ColorDefinition { + return ( + (htmlColorNames as string[]).includes(value.toLowerCase()) || + /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(value) || + /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i.test(value) + ); +} + export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) { if (!aspectRatio) { return undefined; @@ -75,6 +96,15 @@ export interface TransformOptions { * @example "16:9" - strings can be used in the format of `{ratioWidth}:{ratioHeight}`. */ aspectRatio?: number | `${number}:${number}`; + /** + * The background color to use when converting from a transparent image format to a + * non-transparent format. This is useful for converting PNGs to JPEGs. + * + * @example "white" - a named color + * @example "#ffffff" - a hex color + * @example "rgb(255, 255, 255)" - an rgb color + */ + background?: ColorDefinition; } export interface HostedImageService { diff --git a/packages/integrations/image/src/loaders/sharp.ts b/packages/integrations/image/src/loaders/sharp.ts index 4e7b3f104af1..dbb082dba147 100644 --- a/packages/integrations/image/src/loaders/sharp.ts +++ b/packages/integrations/image/src/loaders/sharp.ts @@ -1,11 +1,11 @@ import sharp from 'sharp'; -import { isAspectRatioString, isOutputFormat } from '../loaders/index.js'; +import { isAspectRatioString, isColor, isOutputFormat } from '../loaders/index.js'; import type { OutputFormat, SSRImageService, TransformOptions } from './index.js'; class SharpService implements SSRImageService { async getImageAttributes(transform: TransformOptions) { // strip off the known attributes - const { width, height, src, format, quality, aspectRatio, ...rest } = transform; + const { width, height, src, format, quality, aspectRatio, background, ...rest } = transform; return { ...rest, @@ -37,6 +37,10 @@ class SharpService implements SSRImageService { searchParams.append('ar', transform.aspectRatio.toString()); } + if (transform.background) { + searchParams.append('bg', transform.background); + } + return { searchParams }; } @@ -72,6 +76,13 @@ class SharpService implements SSRImageService { } } + if (searchParams.has('bg')) { + const background = searchParams.get('bg')!; + if (isColor(background)) { + transform.background = background; + } + } + return transform; } @@ -87,6 +98,11 @@ class SharpService implements SSRImageService { sharpImage.resize(width, height); } + // remove alpha channel and replace with background color if requested + if (transform.background) { + sharpImage.flatten({ background: transform.background }); + } + if (transform.format) { sharpImage.toFormat(transform.format, { quality: transform.quality }); } diff --git a/packages/integrations/image/test/background-color-image-ssg.test.js b/packages/integrations/image/test/background-color-image-ssg.test.js new file mode 100644 index 000000000000..3c488a3ffc12 --- /dev/null +++ b/packages/integrations/image/test/background-color-image-ssg.test.js @@ -0,0 +1,116 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import sharp from 'sharp'; +import { fileURLToPath } from 'url'; +import { loadFixture } from './test-utils.js'; + +describe('SSG image with background - dev', function () { + let fixture; + let devServer; + let $; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/background-color-image/' }); + devServer = await fixture.startDevServer(); + const html = await fixture.fetch('/').then((res) => res.text()); + $ = cheerio.load(html); + }); + + after(async () => { + await devServer.stop(); + }); + + [ + { + title: 'Named color', + id: '#named', + bg: 'dimgray', + }, + { + title: 'Hex color', + id: '#hex', + bg: '#696969', + }, + { + title: 'Hex color short', + id: '#hex-short', + bg: '#666', + }, + { + title: 'RGB color', + id: '#rgb', + bg: 'rgb(105,105,105)', + }, + { + title: 'RGB color with spaces', + id: '#rgb-spaced', + bg: 'rgb(105, 105, 105)', + }, + ].forEach(({ title, id, bg }) => { + it(title, async () => { + const image = $(id); + const src = image.attr('src'); + const [_, params] = src.split('?'); + const searchParams = new URLSearchParams(params); + expect(searchParams.get('bg')).to.equal(bg); + }); + }); +}); + +describe('SSG image with background - build', function () { + let fixture; + let $; + let html; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/background-color-image/' }); + await fixture.build(); + + html = await fixture.readFile('/index.html'); + $ = cheerio.load(html); + }); + + async function verifyImage(pathname, expectedBg) { + const url = new URL('./fixtures/background-color-image/dist/' + pathname, import.meta.url); + const dist = fileURLToPath(url); + const data = await sharp(dist).raw().toBuffer(); + // check that the first RGB pixel indeed has the requested background color + expect(data[0]).to.equal(expectedBg[0]); + expect(data[1]).to.equal(expectedBg[1]); + expect(data[2]).to.equal(expectedBg[2]); + } + + [ + { + title: 'Named color', + id: '#named', + bg: [105, 105, 105], + }, + { + title: 'Hex color', + id: '#hex', + bg: [105, 105, 105], + }, + { + title: 'Hex color short', + id: '#hex-short', + bg: [102, 102, 102], + }, + { + title: 'RGB color', + id: '#rgb', + bg: [105, 105, 105], + }, + { + title: 'RGB color with spaces', + id: '#rgb-spaced', + bg: [105, 105, 105], + }, + ].forEach(({ title, id, bg }) => { + it(title, async () => { + const image = $(id); + const src = image.attr('src'); + await verifyImage(src, bg); + }); + }); +}); diff --git a/packages/integrations/image/test/background-color-image-ssr.test.js b/packages/integrations/image/test/background-color-image-ssr.test.js new file mode 100644 index 000000000000..ff4c208f8fe7 --- /dev/null +++ b/packages/integrations/image/test/background-color-image-ssr.test.js @@ -0,0 +1,98 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; +import testAdapter from '../../../astro/test/test-adapter.js'; + +let fixture; + +describe('SSR image with background', function () { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/background-color-image/', + adapter: testAdapter({ streaming: false }), + output: 'server', + }); + await fixture.build(); + }); + + [ + { + title: 'Named color', + id: '#named', + query: { + f: 'jpeg', + w: '256', + h: '256', + href: /^\/assets\/file-icon.\w{8}.png/, + bg: 'dimgray', + }, + }, + { + title: 'Hex color', + id: '#hex', + query: { + f: 'avif', + w: '256', + h: '256', + href: /^\/assets\/file-icon.\w{8}.png/, + bg: '#696969', + }, + }, + { + title: 'Hex color short', + id: '#hex-short', + query: { + f: 'png', + w: '256', + h: '256', + href: /^\/assets\/file-icon.\w{8}.png/, + bg: '#666', + }, + }, + { + title: 'RGB color', + id: '#rgb', + query: { + f: 'webp', + w: '256', + h: '256', + href: /^\/assets\/file-icon.\w{8}.png/, + bg: 'rgb(105,105,105)', + }, + }, + { + title: 'RGB color with spaces', + id: '#rgb-spaced', + query: { + f: 'jpeg', + w: '256', + h: '256', + href: /^\/assets\/file-icon.\w{8}.png/, + bg: 'rgb(105, 105, 105)', + }, + }, + ].forEach(({ title, id, query }) => { + it(title, async () => { + const app = await fixture.loadTestAdapterApp(); + + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + + const image = $(id); + const src = image.attr('src'); + const [_, params] = src.split('?'); + + const searchParams = new URLSearchParams(params); + + for (const [key, value] of Object.entries(query)) { + if (typeof value === 'string') { + expect(searchParams.get(key)).to.equal(value); + } else { + expect(searchParams.get(key)).to.match(value); + } + } + }); + }); +}); diff --git a/packages/integrations/image/test/fixtures/background-color-image/astro.config.mjs b/packages/integrations/image/test/fixtures/background-color-image/astro.config.mjs new file mode 100644 index 000000000000..7dafac3b65a3 --- /dev/null +++ b/packages/integrations/image/test/fixtures/background-color-image/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; +import image from '@astrojs/image'; + +// https://astro.build/config +export default defineConfig({ + site: 'http://localhost:3000', + integrations: [image({ logLevel: 'silent' })] +}); diff --git a/packages/integrations/image/test/fixtures/background-color-image/package.json b/packages/integrations/image/test/fixtures/background-color-image/package.json new file mode 100644 index 000000000000..bca4ff1783e6 --- /dev/null +++ b/packages/integrations/image/test/fixtures/background-color-image/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/background-color-image", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/image": "workspace:*", + "@astrojs/node": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/image/test/fixtures/background-color-image/public/favicon.ico b/packages/integrations/image/test/fixtures/background-color-image/public/favicon.ico new file mode 100644 index 000000000000..578ad458b890 Binary files /dev/null and b/packages/integrations/image/test/fixtures/background-color-image/public/favicon.ico differ diff --git a/packages/integrations/image/test/fixtures/background-color-image/server/server.mjs b/packages/integrations/image/test/fixtures/background-color-image/server/server.mjs new file mode 100644 index 000000000000..d7a0a7a40f77 --- /dev/null +++ b/packages/integrations/image/test/fixtures/background-color-image/server/server.mjs @@ -0,0 +1,44 @@ +import { createServer } from 'http'; +import fs from 'fs'; +import mime from 'mime'; +import { handler as ssrHandler } from '../dist/server/entry.mjs'; + +const clientRoot = new URL('../dist/client/', import.meta.url); + +async function handle(req, res) { + ssrHandler(req, res, async (err) => { + if (err) { + res.writeHead(500); + res.end(err.stack); + return; + } + + let local = new URL('.' + req.url, clientRoot); + try { + const data = await fs.promises.readFile(local); + res.writeHead(200, { + 'Content-Type': mime.getType(req.url), + }); + res.end(data); + } catch { + res.writeHead(404); + res.end(); + } + }); +} + +const server = createServer((req, res) => { + handle(req, res).catch((err) => { + console.error(err); + res.writeHead(500, { + 'Content-Type': 'text/plain', + }); + res.end(err.toString()); + }); +}); + +server.listen(8085); +console.log('Serving at http://localhost:8085'); + +// Silence weird