-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(astro): add Built-in SVG component support #12067
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
aa3f606
0832ae3
5f128ea
434ceda
f8e94e3
2b6744b
a751408
11e2eba
3c576fd
3f274c4
a92cb57
66eec7a
fcc6eca
78490fb
ddf5604
767559c
31b08e7
c03a467
6ab0f1a
d40d45d
9344a25
28d6c38
676add9
08e5708
6e41401
7b4a3a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| --- | ||
| 'astro': minor | ||
| --- | ||
|
|
||
| Adds experimental support for built-in SVG components. | ||
|
|
||
| After enabling the `experimental.svg` flag, `.svg` files can be imported and used as components. They will be inlined into the HTML output. | ||
|
|
||
| ```astro | ||
| --- | ||
| import Logo from './path/to/svg/file.svg'; | ||
| --- | ||
| <Logo size={24} /> | ||
| ``` | ||
|
|
||
| To learn more, check out [the documentation](https://docs.astro.build/reference/configuration-reference/#experimentalsvg). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { | ||
| createComponent, | ||
| render, | ||
| spreadAttributes, | ||
| unescapeHTML, | ||
| } from '../runtime/server/index.js'; | ||
| import type { SSRResult } from '../types/public/index.js'; | ||
| import type { ImageMetadata } from './types.js'; | ||
|
|
||
| export interface SvgComponentProps { | ||
| meta: ImageMetadata; | ||
| attributes: Record<string, string>; | ||
| children: string; | ||
| } | ||
|
|
||
| /** | ||
| * Make sure these IDs are kept on the module-level so they're incremented on a per-page basis | ||
| */ | ||
| // let ids = 0; | ||
|
|
||
| const ids = new WeakMap<SSRResult, number>(); | ||
| let counter = 0; | ||
|
|
||
| export function createSvgComponent({ meta, attributes, children }: SvgComponentProps) { | ||
| const rendered = new WeakSet<Response>(); | ||
| const Component = createComponent((result, props) => { | ||
| let id; | ||
| if (ids.has(result)) { | ||
| id = ids.get(result)!; | ||
| } else { | ||
| counter += 1; | ||
| ids.set(result, counter); | ||
| id = counter; | ||
| } | ||
| const { | ||
| title: titleProp, | ||
| viewBox, | ||
| mode, | ||
| ...normalizedProps | ||
| } = normalizeProps(attributes, props); | ||
| const title = titleProp ? unescapeHTML(`<title>${titleProp}</title>`) : ''; | ||
|
|
||
| if (mode === 'sprite') { | ||
| // On the first render, include the symbol definition | ||
| let symbol: any = ''; | ||
| if (!rendered.has(result.response)) { | ||
| // We only need the viewBox on the symbol definition, we can drop it everywhere else | ||
| symbol = unescapeHTML(`<symbol${spreadAttributes({ viewBox, id })}>${children}</symbol>`); | ||
| rendered.add(result.response); | ||
| } | ||
|
|
||
| return render`<svg${spreadAttributes(normalizedProps)}>${title}${symbol}<use href="#${id}" /></svg>`; | ||
| } | ||
|
|
||
| // Default to inline mode | ||
| return render`<svg${spreadAttributes({ viewBox, ...normalizedProps })}>${title}${unescapeHTML(children)}</svg>`; | ||
| }); | ||
|
|
||
| if (import.meta.env.DEV) { | ||
| // Prevent revealing that this is a component | ||
| makeNonEnumerable(Component); | ||
|
|
||
| // Maintaining the current `console.log` output for SVG imports | ||
| Object.defineProperty(Component, Symbol.for('nodejs.util.inspect.custom'), { | ||
| value: (_: any, opts: any, inspect: any) => inspect(meta, opts), | ||
| }); | ||
stramel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // Attaching the metadata to the component to maintain current functionality | ||
| return Object.assign(Component, meta); | ||
| } | ||
|
|
||
| type SvgAttributes = Record<string, any>; | ||
|
|
||
| /** | ||
| * Some attributes required for `image/svg+xml` are irrelevant when inlined in a `text/html` document. We can 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; | ||
| } | ||
|
|
||
| function normalizeProps(attributes: SvgAttributes, { size, ...props }: SvgAttributes) { | ||
| if (size !== undefined && props.width === undefined && props.height === undefined) { | ||
| props.height = size; | ||
| props.width = size; | ||
| } | ||
|
|
||
| return dropAttributes({ ...DEFAULT_ATTRS, ...attributes, ...props }); | ||
| } | ||
|
|
||
| function makeNonEnumerable(object: Record<string, any>) { | ||
| for (const property in object) { | ||
| Object.defineProperty(object, property, { enumerable: false }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import type { ImageMetadata } from '../../types.js'; | |
| import { imageMetadata } from '../metadata.js'; | ||
|
|
||
| type FileEmitter = vite.Rollup.EmitFile; | ||
| type ImageMetadataWithContents = ImageMetadata & { contents?: Buffer }; | ||
|
|
||
| export async function emitESMImage( | ||
| id: string | undefined, | ||
|
|
@@ -15,7 +16,7 @@ export async function emitESMImage( | |
| // FIX: in Astro 6, this function should not be passed in dev mode at all. | ||
| // Or rethink the API so that a function that throws isn't passed through. | ||
| fileEmitter?: FileEmitter, | ||
| ): Promise<ImageMetadata | undefined> { | ||
| ): Promise<ImageMetadataWithContents | undefined> { | ||
| if (!id) { | ||
| return undefined; | ||
| } | ||
|
|
@@ -30,7 +31,7 @@ export async function emitESMImage( | |
|
|
||
| const fileMetadata = await imageMetadata(fileData, id); | ||
|
|
||
| const emittedImage: Omit<ImageMetadata, 'fsPath'> = { | ||
| const emittedImage: Omit<ImageMetadataWithContents, 'fsPath'> = { | ||
| src: '', | ||
| ...fileMetadata, | ||
| }; | ||
|
|
@@ -42,6 +43,11 @@ export async function emitESMImage( | |
| value: id, | ||
| }); | ||
|
|
||
| // Attach file data for SVGs | ||
| if (fileMetadata.format === 'svg') { | ||
| emittedImage.contents = fileData; | ||
| } | ||
|
Comment on lines
+46
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would love @Princesseuh's take on this. Any expected footguns with including the file content for SVGs in the image metadata? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you have a lot of SVGs, this can be problematic for SSR since it'll get in your bundle, that's my only concerns There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right. Unfortunately that will be unavoidable for this feature. I think the DX improvement is worth the tradeoff, though! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is definitely outside my realm of expertise. I'll defer to whatever the consensus is here :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Something we could do one day (definitely outside of this PR), though it'd make SVG icons somewhat slower in SSR is add a lazy |
||
|
|
||
| // Build | ||
| let isBuild = typeof fileEmitter === 'function'; | ||
| if (isBuild) { | ||
|
|
@@ -71,7 +77,7 @@ export async function emitESMImage( | |
| emittedImage.src = `/@fs` + prependForwardSlash(fileURLToNormalizedPath(url)); | ||
| } | ||
|
|
||
| return emittedImage as ImageMetadata; | ||
| return emittedImage as ImageMetadataWithContents; | ||
| } | ||
|
|
||
| function fileURLToNormalizedPath(filePath: URL): string { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { parse, renderSync } from 'ultrahtml'; | ||
natemoo-re marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| import type { ImageMetadata } from '../types.js'; | ||
| import type { SvgComponentProps } from '../runtime.js'; | ||
| import { dropAttributes } from '../runtime.js'; | ||
|
|
||
| function parseSvg(contents: string) { | ||
| const root = parse(contents); | ||
| const [{ attributes, children }] = root.children; | ||
| const body = renderSync({ ...root, children }); | ||
|
|
||
| return { attributes, body }; | ||
| } | ||
|
|
||
| export type SvgRenderMode = 'inline' | 'sprite'; | ||
|
|
||
| export function makeSvgComponent(meta: ImageMetadata, contents: Buffer | string, options?: { mode?: SvgRenderMode }) { | ||
| const file = typeof contents === 'string' ? contents : contents.toString('utf-8'); | ||
| const { attributes, body: children } = parseSvg(file); | ||
| const props: SvgComponentProps = { | ||
| meta, | ||
| attributes: dropAttributes({ mode: options?.mode, ...attributes }), | ||
| children, | ||
| }; | ||
|
|
||
| return `import { createSvgComponent } from 'astro/assets/runtime'; | ||
| export default createSvgComponent(${JSON.stringify(props)})`; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.