diff --git a/packages/astro/package.json b/packages/astro/package.json index 528f27d28..a252acbd7 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -55,6 +55,7 @@ "./components": "./components/index.ts", "./components/*": "./components/*", "./assets": "./dist/assets/index.js", + "./assets/runtime": "./dist/assets/runtime.js", "./assets/utils": "./dist/assets/utils/index.js", "./assets/image-endpoint": "./dist/assets/image-endpoint.js", "./assets/services/sharp": "./dist/assets/services/sharp.js", @@ -167,6 +168,7 @@ "string-width": "^6.1.0", "strip-ansi": "^7.1.0", "tsconfig-resolver": "^3.0.1", + "ultrahtml": "^1.3.0", "undici": "^5.23.0", "unist-util-visit": "^4.1.2", "vfile": "^5.3.7", diff --git a/packages/astro/src/assets/runtime.ts b/packages/astro/src/assets/runtime.ts new file mode 100644 index 000000000..66ed00a8e --- /dev/null +++ b/packages/astro/src/assets/runtime.ts @@ -0,0 +1,35 @@ +import type { ImageMetadata } from './types.js'; +import { createComponent, render, spreadAttributes, unescapeHTML } from '../runtime/server/index.js'; +import { normalizeProps, makeNonEnumerable } from './utils/svg.js'; + +export interface SvgComponentProps { + meta: ImageMetadata; + attributes: Record; + children: string; +} + +// Make sure these IDs are kept on the module-level +// so they're incremented on a per-page basis +let ids = 0; +export function createSvgComponent({ meta, attributes, children }: SvgComponentProps) { + const id = `a:${ids++}`; + const rendered = new WeakSet(); + const Component = createComponent((result, props) => { + const { viewBox, title: titleProp, ...normalizedProps } = normalizeProps(attributes, props); + const title = titleProp ? unescapeHTML(`${titleProp}`) : ''; + let symbol: any = ''; + // On first render, include the symbol definition + if (!rendered.has(result.response)) { + // We only need the viewBox on the symbol, can drop it everywhere else + symbol = unescapeHTML(`${children}`); + rendered.add(result.response); + } + return render`${title}${symbol}`; + }) + makeNonEnumerable(Component); + if (import.meta.env.DEV) { + Object.defineProperty(Component, Symbol.for('nodejs.util.inspect.custom'), { value: (_: any, opts: any, inspect: any) => inspect(meta, opts) }) + } + return Object.assign(Component, meta); +} + diff --git a/packages/astro/src/assets/utils/emitAsset.ts b/packages/astro/src/assets/utils/emitAsset.ts index 9b83a020a..5bccef480 100644 --- a/packages/astro/src/assets/utils/emitAsset.ts +++ b/packages/astro/src/assets/utils/emitAsset.ts @@ -5,11 +5,13 @@ import { prependForwardSlash, slash } from '../../core/path.js'; import type { ImageMetadata } from '../types.js'; import { imageMetadata } from './metadata.js'; +type ImageMetadataWithContents = ImageMetadata & { contents?: Buffer }; + export async function emitESMImage( id: string | undefined, watchMode: boolean, fileEmitter: any -): Promise { +): Promise { if (!id) { return undefined; } @@ -28,11 +30,15 @@ export async function emitESMImage( return undefined; } - const emittedImage: ImageMetadata = { + const emittedImage: ImageMetadataWithContents = { src: '', ...fileMetadata, }; + if (fileMetadata.format === 'svg') { + emittedImage.contents = fileData; + } + // Build if (!watchMode) { const pathname = decodeURI(url.pathname); diff --git a/packages/astro/src/assets/utils/svg.ts b/packages/astro/src/assets/utils/svg.ts new file mode 100644 index 000000000..f277f0f44 --- /dev/null +++ b/packages/astro/src/assets/utils/svg.ts @@ -0,0 +1,27 @@ +type SvgAttributes = Record; + +// Some attributes required for `image/svg+xml` are irrelevant when +// inlined in a `text/html` doc. Save a few bytes by dropping them! +const ATTRS_TO_DROP = ['xmlns', 'xmlns:xlink', 'version']; +const DEFAULT_ATTRS: SvgAttributes = { role: 'img' } + +export function dropAttributes(attributes: SvgAttributes) { + for (const attr of ATTRS_TO_DROP) { + delete attributes[attr]; + } + return attributes; +} + +export function normalizeProps(attributes: SvgAttributes, { size, ...props }: SvgAttributes) { + if (size) { + props.width = size; + props.height = size; + } + return dropAttributes({ ...DEFAULT_ATTRS, ...attributes, ...props }); +} + +export function makeNonEnumerable(object: Record) { + for (const property in object) { + Object.defineProperty(object, property, { enumerable: false }); + } +} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index f194e5288..00e9d097e 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -1,7 +1,10 @@ -import MagicString from 'magic-string'; import type * as vite from 'vite'; +import type { AstroPluginOptions, ImageTransform, ImageMetadata } from '../@types/astro'; +import type { SvgComponentProps } from './runtime'; + +import MagicString from 'magic-string'; import { normalizePath } from 'vite'; -import type { AstroPluginOptions, ImageTransform } from '../@types/astro'; +import { parse, renderSync } from 'ultrahtml'; import { appendForwardSlash, joinPaths, @@ -10,6 +13,7 @@ import { } from '../core/path.js'; import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js'; import { emitESMImage } from './utils/emitAsset.js'; +import { dropAttributes } from './utils/svg.js'; import { hashTransform, propsToFilename } from './utils/transformToPath.js'; const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; @@ -125,11 +129,37 @@ export default function assets({ } const cleanedUrl = removeQueryString(id); + if (/\.(jpeg|jpg|png|tiff|webp|gif|svg)$/.test(cleanedUrl)) { const meta = await emitESMImage(id, this.meta.watchMode, this.emitFile); - return `export default ${JSON.stringify(meta)}`; + if (!meta) return; + if (/\.svg$/.test(cleanedUrl)) { + const { contents, ...metadata } = meta; + return makeSvgComponent(metadata, contents!); + } else { + return `export default ${JSON.stringify(meta)}`; + } } }, }, ]; } + +function parseSVG(contents: string) { + const root = parse(contents); + const [{ attributes, children }] = root.children; + const body = renderSync({ ...root, children }); + return { attributes, body }; +} + +function makeSvgComponent(meta: ImageMetadata, contents: Buffer) { + const file = contents.toString('utf-8'); + const { attributes, body: children } = parseSVG(file); + const props: SvgComponentProps = { + meta, + attributes: dropAttributes(attributes), + children + } + return `import { createSvgComponent } from 'astro/assets/runtime'; +export default createSvgComponent(${JSON.stringify(props)});`; +}