diff --git a/.changeset/blue-socks-doubt.md b/.changeset/blue-socks-doubt.md new file mode 100644 index 000000000000..638e22e0804d --- /dev/null +++ b/.changeset/blue-socks-doubt.md @@ -0,0 +1,30 @@ +--- +'astro': minor +--- + +Adds experimental support for built-in SVG components. + + +This feature allows you to import SVG files directly into your Astro project as components. By default, Astro will inline the SVG content into your HTML output. + +To enable this feature, set `experimental.svg` to `true` in your Astro config: + +```js +{ + experimental: { + svg: true, + }, +} +``` + +To use this feature, import an SVG file in your Astro project, passing any common SVG attributes to the imported component. Astro also provides a `size` attribute to set equal `height` and `width` properties: + +```astro +--- +import Logo from './path/to/svg/file.svg'; +--- + + +``` + +For a complete overview, and to give feedback on this experimental API, see the [Feature RFC](https://github.com/withastro/roadmap/pull/1035). diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index eafabab2b84b..0c75465a5b7d 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -101,14 +101,31 @@ declare module '*.webp' { const metadata: ImageMetadata; export default metadata; } -declare module '*.svg' { - const metadata: ImageMetadata; - export default metadata; -} declare module '*.avif' { const metadata: ImageMetadata; export default metadata; } +declare module '*.svg' { + type Props = { + /** + * Accesible, short-text description + * + * {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title|MDN Reference} + */ + title?: string; + /** + * Shorthand for setting the `height` and `width` properties + */ + size?: number | string; + /** + * Override the default rendering mode for SVGs + */ + mode?: import('./dist/assets/utils/svg.js').SvgRenderMode + } & astroHTML.JSX.SVGAttributes + + const Component: ((_props: Props) => any) & ImageMetadata; + export default Component; +} declare module 'astro:transitions' { type TransitionModule = typeof import('./dist/virtual-modules/transitions.js'); diff --git a/packages/astro/package.json b/packages/astro/package.json index 303c40ee7a9b..88f95a2388a0 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -59,6 +59,7 @@ "./toolbar": "./dist/toolbar/index.js", "./actions/runtime/*": "./dist/actions/runtime/*", "./assets": "./dist/assets/index.js", + "./assets/runtime": "./dist/assets/runtime.js", "./assets/utils": "./dist/assets/utils/index.js", "./assets/utils/inferRemoteSize.js": "./dist/assets/utils/remoteProbe.js", "./assets/endpoint/*": "./dist/assets/endpoint/*.js", @@ -163,6 +164,7 @@ "shiki": "^1.22.0", "tinyexec": "^0.3.1", "tsconfck": "^3.1.4", + "ultrahtml": "^1.5.3", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3", "vite": "6.0.0-beta.2", diff --git a/packages/astro/src/assets/runtime.ts b/packages/astro/src/assets/runtime.ts new file mode 100644 index 000000000000..e48a7139f49c --- /dev/null +++ b/packages/astro/src/assets/runtime.ts @@ -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; + children: string; +} + +/** + * Make sure these IDs are kept on the module-level so they're incremented on a per-page basis + */ +const ids = new WeakMap(); +let counter = 0; + +export function createSvgComponent({ meta, attributes, children }: SvgComponentProps) { + const rendered = new WeakSet(); + const Component = createComponent((result, props) => { + let id; + if (ids.has(result)) { + id = ids.get(result)!; + } else { + counter += 1; + ids.set(result, counter); + id = counter; + } + id = `a:${id}`; + + const { + title: titleProp, + viewBox, + mode, + ...normalizedProps + } = normalizeProps(attributes, props); + const title = titleProp ? unescapeHTML(`${titleProp}`) : ''; + + 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(`${children}`); + rendered.add(result.response); + } + + return render`${title}${symbol}`; + } + + // Default to inline mode + return render`${title}${unescapeHTML(children)}`; + }); + + 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), + }); + } + + // Attaching the metadata to the component to maintain current functionality + return Object.assign(Component, meta); +} + +type SvgAttributes = Record; + +/** + * 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) { + for (const property in object) { + Object.defineProperty(object, property, { enumerable: false }); + } +} diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts index e22bada898e4..cdc4f5355fd1 100644 --- a/packages/astro/src/assets/services/service.ts +++ b/packages/astro/src/assets/services/service.ts @@ -3,7 +3,7 @@ import { isRemotePath, joinPaths } from '../../core/path.js'; import type { AstroConfig } from '../../types/public/config.js'; import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js'; import type { ImageOutputFormat, ImageTransform, UnresolvedSrcSetValue } from '../types.js'; -import { isESMImportedImage } from '../utils/imageKind.js'; +import { isESMImportedImage, isRemoteImage } from '../utils/imageKind.js'; import { isRemoteAllowed } from '../utils/remotePattern.js'; export type ImageService = LocalImageService | ExternalImageService; @@ -142,7 +142,7 @@ export const baseService: Omit = { propertiesToHash: DEFAULT_HASH_PROPS, validateOptions(options) { // `src` is missing or is `undefined`. - if (!options.src || (typeof options.src !== 'string' && typeof options.src !== 'object')) { + if (!options.src || (!isRemoteImage(options.src) && !isESMImportedImage(options.src))) { throw new AstroError({ ...AstroErrorData.ExpectedImage, message: AstroErrorData.ExpectedImage.message( diff --git a/packages/astro/src/assets/utils/imageKind.ts b/packages/astro/src/assets/utils/imageKind.ts index e3e1b3341a4b..87946364f0b4 100644 --- a/packages/astro/src/assets/utils/imageKind.ts +++ b/packages/astro/src/assets/utils/imageKind.ts @@ -1,7 +1,7 @@ import type { ImageMetadata, UnresolvedImageTransform } from '../types.js'; export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata { - return typeof src === 'object'; + return typeof src === 'object' || (typeof src === 'function' && 'src' in src); } export function isRemoteImage(src: ImageMetadata | string): src is string { diff --git a/packages/astro/src/assets/utils/index.ts b/packages/astro/src/assets/utils/index.ts index 69e7c88dc401..98044ac9fa1c 100644 --- a/packages/astro/src/assets/utils/index.ts +++ b/packages/astro/src/assets/utils/index.ts @@ -13,3 +13,4 @@ export { } from './remotePattern.js'; export { hashTransform, propsToFilename } from './transformToPath.js'; export { inferRemoteSize } from './remoteProbe.js'; +export { makeSvgComponent } from './svg.js' diff --git a/packages/astro/src/assets/utils/node/emitAsset.ts b/packages/astro/src/assets/utils/node/emitAsset.ts index 42dd1681f974..79a5287f64ab 100644 --- a/packages/astro/src/assets/utils/node/emitAsset.ts +++ b/packages/astro/src/assets/utils/node/emitAsset.ts @@ -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 { +): Promise { if (!id) { return undefined; } @@ -30,7 +31,7 @@ export async function emitESMImage( const fileMetadata = await imageMetadata(fileData, id); - const emittedImage: Omit = { + const emittedImage: Omit = { src: '', ...fileMetadata, }; @@ -42,6 +43,11 @@ export async function emitESMImage( value: id, }); + // Attach file data for SVGs + if (fileMetadata.format === 'svg') { + emittedImage.contents = fileData; + } + // 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 { diff --git a/packages/astro/src/assets/utils/svg.ts b/packages/astro/src/assets/utils/svg.ts new file mode 100644 index 000000000000..70088ba64a7c --- /dev/null +++ b/packages/astro/src/assets/utils/svg.ts @@ -0,0 +1,27 @@ +import { parse, renderSync } from 'ultrahtml'; +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)})`; +} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 037fae725a97..b8f3cf41e59b 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -18,6 +18,7 @@ import { isESMImportedImage } from './utils/imageKind.js'; import { emitESMImage } from './utils/node/emitAsset.js'; import { getProxyCode } from './utils/proxy.js'; import { hashTransform, propsToFilename } from './utils/transformToPath.js'; +import { makeSvgComponent } from './utils/svg.js'; const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; @@ -52,7 +53,7 @@ const addStaticImageFactory = ( let finalFilePath: string; let transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath); - let transformForHash = transformsForPath?.transforms.get(hash); + const transformForHash = transformsForPath?.transforms.get(hash); // If the same image has already been transformed with the same options, we'll reuse the final path if (transformsForPath && transformForHash) { @@ -213,6 +214,12 @@ export default function assets({ settings }: { settings: AstroSettings }): vite. }); } + if (settings.config.experimental.svg && /\.svg$/.test(id)) { + const { contents, ...metadata } = imageMetadata; + // We know that the contents are present, as we only emit this property for SVG files + return makeSvgComponent(metadata, contents!, { mode: settings.config.experimental.svg.mode }); + } + // We can only reliably determine if an image is used on the server, as we need to track its usage throughout the entire build. // Since you cannot use image optimization on the client anyway, it's safe to assume that if the user imported // an image on the client, it should be present in the final build. diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 48af43339c96..c19b4d9559f4 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -95,6 +95,9 @@ export const ASTRO_CONFIG_DEFAULTS = { experimental: { clientPrerender: false, contentIntellisense: false, + svg: { + mode: 'inline', + }, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -525,6 +528,24 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense), + svg: z.union([ + z.boolean(), + z + .object({ + mode: z + .union([z.literal('inline'), z.literal('sprite')]) + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.svg.mode), + }) + ]) + .optional() + .transform((svgConfig) => { + // Handle normalization of `experimental.svg` config boolean values + if (typeof svgConfig === 'boolean') { + return svgConfig ? ASTRO_CONFIG_DEFAULTS.experimental.svg : undefined; + } + return svgConfig; + }), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.`, diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 1e24f419408e..298807eb8a81 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -7,6 +7,7 @@ import type { } from '@astrojs/markdown-remark'; import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite'; import type { RemotePattern } from '../../assets/utils/remotePattern.js'; +import type { SvgRenderMode } from '../../assets/utils/svg.js'; import type { AssetsPrefix } from '../../core/app/types.js'; import type { AstroConfigType } from '../../core/config/schema.js'; import type { REDIRECT_STATUS_CODES } from '../../core/constants.js'; @@ -1681,7 +1682,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * @name experimental.contentIntellisense * @type {boolean} * @default `false` - * @version 4.14.0 + * @version 5.x * @description * * Enables Intellisense features (e.g. code completion, quick hints) for your content collection entries in compatible editors. @@ -1699,6 +1700,67 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * To use this feature with the Astro VS Code extension, you must also enable the `astro.content-intellisense` option in your VS Code settings. For editors using the Astro language server directly, pass the `contentIntellisense: true` initialization parameter to enable this feature. */ contentIntellisense?: boolean; + + /** + * @docs + * @name experimental.svg + * @type {boolean|object} + * @default `undefined` + * @version 5.x + * @description + * + * This feature allows you to import SVG files directly into your Astro project. By default, Astro will inline the SVG content into your HTML output. + * + * To enable this feature, set `experimental.svg` to `true` in your Astro config: + * + * ```js + * { + * experimental: { + * svg: true, + * }, + * } + * ``` + * + * To use this feature, import an SVG file in your Astro project, passing any common SVG attributes to the imported component. + * Astro also provides a `size` attribute to set equal `height` and `width` properties: + * + * ```astro + * --- + * import Logo from './path/to/svg/file.svg'; + * --- + * + * + * ``` + * + * For a complete overview, and to give feedback on this experimental API, + * see the [Feature RFC](https://github.com/withastro/roadmap/pull/1035). + */ + svg?: { + /** + * @docs + * @name experimental.svg.mode + * @type {string} + * @default 'inline' + * + * The default technique for handling imported SVG files. Astro will inline the SVG content into your HTML output if not specified. + * + * - `inline`: Astro will inline the SVG content into your HTML output. + * - `sprite`: Astro will generate a sprite sheet with all imported SVG files. + * + * When using 'sprite' mode, it is important to note that the symbol definition will be included with the first instance of the SVG that is rendered. This means that if the first instance is hidden or removed from the DOM, all other references will break. + * + * This mode can be overridden by passing a `mode` prop to the imported SVG component. + * + * ```astro + * --- + * import Logo from './path/to/svg/file.svg'; + * --- + * + * + * ``` + */ + mode?: SvgRenderMode; + }; }; } diff --git a/packages/astro/test/core-image-svg.test.js b/packages/astro/test/core-image-svg.test.js new file mode 100644 index 000000000000..d6134aaf775c --- /dev/null +++ b/packages/astro/test/core-image-svg.test.js @@ -0,0 +1,406 @@ +import assert from 'node:assert/strict'; +import { Writable } from 'node:stream'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { Logger } from '../dist/core/logger/core.js'; +import { loadFixture } from './test-utils.js'; + +describe('astro:assets - SVG Components', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + describe('dev', () => { + /** @type {import('./test-utils').DevServer} */ + let devServer; + /** @type {Array<{ type: any, level: 'error', message: string; }>} */ + let logs = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-svg/', + }); + + devServer = await fixture.startDevServer({ + logger: new Logger({ + level: 'error', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }), + }); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('basics', () => { + let $; + before(async () => { + let res = await fixture.fetch('/'); + let html = await res.text(); + $ = cheerio.load(html, { xml: true }); + }); + it('Inlines the SVG by default', () => { + const $svgs = $('.inline svg'); + assert.equal($svgs.length, 2); + $svgs.each(function () { + assert.equal($(this).attr('role'), 'img'); + assert.equal(!!$(this).attr('mode'), false); + const $use = $(this).children('use'); + assert.equal($use.length, 0); + }) + }); + + it('Adds the tag with the definition', () => { + const $svg = $('.sprite #definition svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('role'), 'img'); + + const $symbol = $svg.children('symbol'); + assert.equal($symbol.length, 1); + assert.equal($symbol.attr('id').startsWith('a:'), true); + + const $use = $svg.children('use'); + assert.equal($use.length, 1); + assert.equal($use.attr('href').startsWith('#a:'), true); + assert.equal($use.attr('href').slice(1), $symbol.attr('id')); + }); + it('Adds the tag that uses the definition', () => { + let $svg = $('.sprite #reused svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('role'), 'img'); + + const $symbol = $svg.children('symbol'); + assert.equal($symbol.length, 0); + + const definitionId = $('#definition svg symbol').attr('id') + const $use = $svg.children('use'); + assert.equal($use.length, 1); + assert.equal($use.attr('href').startsWith('#a:'), true); + assert.equal($use.attr('href').slice(1), definitionId); + }); + }); + + describe('props', () => { + describe('size', () => { + let $; + before(async () => { + let res = await fixture.fetch('/size'); + let html = await res.text(); + $ = cheerio.load(html, { xml: true }); + }); + + it('has no height and width - no dimensions set', () => { + let $svg = $('#base svg'); + assert.equal($svg.length, 1); + assert.equal(!!$svg.attr('height'), false); + assert.equal(!!$svg.attr('width'), false); + }); + it('has height and width - no dimensions set', () => { + let $svg = $('#base-with-defaults svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('height'), '1em'); + assert.equal($svg.attr('width'), '1em'); + }); + it('has height and width - string size set', () => { + let $svg = $('#size-string svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('height'), '32'); + assert.equal($svg.attr('width'), '32'); + assert.equal(!!$svg.attr('size'), false); + }); + it('has height and width - number size set', () => { + let $svg = $('#size-number svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('height'), '48'); + assert.equal($svg.attr('width'), '48'); + assert.equal(!!$svg.attr('size'), false); + }); + it('has height and width overridden - size set', () => { + let $svg = $('#override-attrs svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('height'), '16'); + assert.equal($svg.attr('width'), '16'); + assert.equal(!!$svg.attr('size'), false); + }); + it('has unchanged width - size set', () => { + let $svg = $('#ignore-size-for-width svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('height'), '1em'); + assert.equal($svg.attr('width'), '24'); + assert.equal(!!$svg.attr('size'), false); + }); + it('has unchanged height - size set', () => { + let $svg = $('#ignore-size-for-height svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('height'), '24'); + assert.equal($svg.attr('width'), '1em'); + assert.equal(!!$svg.attr('size'), false); + }); + it('has unchanged height and with - size set', () => { + let $svg = $('#ignore-size svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('height'), '24'); + assert.equal($svg.attr('width'), '24'); + assert.equal(!!$svg.attr('size'), false); + }); + }); + describe('mode', () => { + let $; + before(async () => { + let res = await fixture.fetch('/inline'); + let html = await res.text(); + $ = cheerio.load(html, { xml: true }); + }); + + it('adds the svg into the document directly by default', () => { + let $svg = $('#default svg'); + assert.equal($svg.length, 1); + assert.equal(!!$svg.attr('viewBox'), true); + assert.equal($svg.attr('height'), '1em'); + assert.equal($svg.attr('width'), '1em'); + assert.equal($svg.attr('role'), 'img'); + assert.equal(!!$svg.attr('mode'), false); + + const $symbol = $svg.children('symbol') + assert.equal($symbol.length, 0); + const $use = $svg.children('use') + assert.equal($use.length, 0); + const $path = $svg.children('path'); + assert.equal($path.length, 1); + }) + it('adds the svg into the document directly', () => { + let $svg = $('#inline svg'); + assert.equal($svg.length, 1); + assert.equal(!!$svg.attr('viewBox'), true); + assert.equal($svg.attr('height'), '1em'); + assert.equal($svg.attr('width'), '1em'); + assert.equal($svg.attr('role'), 'img'); + assert.equal(!!$svg.attr('mode'), false); + + const $symbol = $svg.children('symbol') + assert.equal($symbol.length, 0); + const $use = $svg.children('use') + assert.equal($use.length, 0); + const $path = $svg.children('path'); + assert.equal($path.length, 1); + }); + it('adds the svg into the document and overrides the dimensions', () => { + let $svg = $('#inline-with-size svg'); + assert.equal($svg.length, 1); + assert.equal(!!$svg.attr('viewBox'), true); + assert.equal($svg.attr('height'), '24'); + assert.equal($svg.attr('width'), '24'); + assert.equal($svg.attr('role'), 'img'); + assert.equal(!!$svg.attr('mode'), false); + + const $symbol = $svg.children('symbol') + assert.equal($symbol.length, 0); + const $use = $svg.children('use') + assert.equal($use.length, 0); + const $path = $svg.children('path'); + assert.equal($path.length, 1); + }) + it('adds the svg into the document as a sprite, overridding the default', () => { + let $svg = $('#definition svg'); + assert.equal($svg.length, 1); + assert.equal(!!$svg.attr('viewBox'), false); + assert.equal($svg.attr('height'), '1em'); + assert.equal($svg.attr('width'), '1em'); + assert.equal($svg.attr('role'), 'img'); + assert.equal(!!$svg.attr('mode'), false); + + let $symbol = $svg.children('symbol') + assert.equal($symbol.length, 1); + assert.equal(!!$symbol.attr('viewBox'), true); + let $use = $svg.children('use') + assert.equal($use.length, 1); + let $path = $svg.children('path'); + assert.equal($path.length, 0); + + $svg = $('#reused svg'); + assert.equal($svg.length, 1); + assert.equal(!!$svg.attr('viewBox'), false); + assert.equal($svg.attr('height'), '1em'); + assert.equal($svg.attr('width'), '1em'); + assert.equal($svg.attr('role'), 'img'); + assert.equal(!!$svg.attr('mode'), false); + + $symbol = $svg.children('symbol') + assert.equal($symbol.length, 0); + assert.equal(!!$symbol.attr('viewBox'), false); + $use = $svg.children('use') + assert.equal($use.length, 1); + $path = $svg.children('path'); + assert.equal($path.length, 0); + }) + }); + describe('title', () => { + let $; + before(async () => { + let res = await fixture.fetch('/title'); + let html = await res.text(); + $ = cheerio.load(html, { xml: true }); + }); + + it('adds a title into the SVG', () => { + let $svg = $('#base svg'); + assert.equal($svg.length, 1); + assert.equal(!!$svg.attr('title'), false); + + const $title = $('#base svg > title'); + assert.equal($title.length, 1); + assert.equal($title.text(), 'GitHub Logo') + }); + }); + describe('strip', () => { + let $; + before(async () => { + let res = await fixture.fetch('/strip'); + let html = await res.text(); + $ = cheerio.load(html, { xml: true }); + }); + + it('removes unnecessary attributes', () => { + let $svg = $('#base svg'); + assert.equal($svg.length, 1); + assert.equal(!!$svg.attr('xmlns'), false); + assert.equal(!!$svg.attr('xmlns:xlink'), false); + assert.equal(!!$svg.attr('version'), false); + }); + }); + describe('additional props', () => { + let $; + before(async () => { + let res = await fixture.fetch('/props'); + let html = await res.text(); + $ = cheerio.load(html, { xml: true }); + }); + + it('adds the props to the svg', () => { + let $svg = $('#base svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('aria-hidden'), 'true'); + assert.equal($svg.attr('id'), 'plus'); + assert.equal($svg.attr('style'), `color:red;font-size:32px`); + assert.equal($svg.attr('class'), 'foobar'); + assert.equal($svg.attr('data-state'), 'open'); + + const $symbol = $svg.children('symbol') + assert.equal($symbol.length, 0); + const $use = $svg.children('use') + assert.equal($use.length, 0); + const $path = $svg.children('path') + assert.equal($path.length, 1); + }); + it('allows overriding the role attribute', () => { + let $svg = $('#role svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('role'), 'presentation'); + }); + }); + }); + + describe('multiple', () => { + let $; + before(async () => { + let res = await fixture.fetch('/multiple'); + let html = await res.text(); + $ = cheerio.load(html, { xml: true }); + }); + + it('adds only one definition for each svg', () => { + // First SVG + let $svg = $('.one svg'); + assert.equal($svg.length, 2); + let $symbol = $('.one svg > symbol'); + assert.equal($symbol.length, 1); + let $use = $('.one svg > use'); + assert.equal($use.length, 2); + let defId = $('.one.def svg > use').attr('id'); + let useId = $('.one.use svg > use').attr('id'); + assert.equal(defId, useId); + + // Second SVG + $svg = $('.two svg'); + assert.equal($svg.length, 2); + $symbol = $('.two svg > symbol'); + assert.equal($symbol.length, 1); + $use = $('.two svg > use'); + assert.equal($use.length, 2); + defId = $('.two.def svg > use').attr('id'); + useId = $('.two.use svg > use').attr('id'); + assert.equal(defId, useId); + + + // Third SVG + $svg = $('.three svg'); + assert.equal($svg.length, 1); + $symbol = $('.three svg > symbol'); + assert.equal($symbol.length, 1); + $use = $('.three svg > use'); + assert.equal($use.length, 1); + }); + }); + + describe('markdown', () => { + it('Adds the tag with the definition', async () => { + let res = await fixture.fetch('/blog/basic'); + let html = await res.text(); + const $ = cheerio.load(html, { xml: true }); + + const $svg = $('svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('role'), 'img'); + + const $symbol = $svg.children('symbol'); + assert.equal($symbol.length, 0); + const $use = $svg.children('use'); + assert.equal($use.length, 0); + const $path = $svg.children('path'); + assert.equal($path.length > 0, true); + }); + it('Adds the tag that uses the definition', async () => { + let res = await fixture.fetch('/blog/sprite'); + let html = await res.text(); + const $ = cheerio.load(html, { xml: true }); + + const $svg = $('svg'); + assert.equal($svg.length, 2); + $svg.each(function() { assert.equal($(this).attr('role'), 'img') }); + + const definitionId = $($svg[0]).children('symbol').attr('id') + + const $reuse = $($svg[1]); + const $symbol = $reuse.children('symbol'); + assert.equal($symbol.length, 0); + + const $use = $reuse.children('use'); + assert.equal($use.length, 1); + assert.equal($use.attr('href').startsWith('#a:'), true); + assert.equal($use.attr('href').slice(1), definitionId); + }); + it('Adds the tag that applies props', async () => { + let res = await fixture.fetch('/blog/props'); + let html = await res.text(); + const $ = cheerio.load(html, { xml: true }); + + const $svg = $('svg'); + assert.equal($svg.length, 1); + assert.equal($svg.attr('role'), 'img'); + assert.equal($svg.attr('height'), '48'); + assert.equal($svg.attr('width'), '48'); + assert.equal(!!$svg.attr('size'), false); + assert.equal($svg.attr('class'), 'icon'); + assert.equal($svg.attr('data-icon'), 'github'); + assert.equal($svg.attr('aria-description'), 'Some description'); + assert.equal($svg.children('title').text(), 'Find out more on GitHub!'); + }); + }); + }); +}); diff --git a/packages/astro/test/fixtures/core-image-svg/astro.config.mjs b/packages/astro/test/fixtures/core-image-svg/astro.config.mjs new file mode 100644 index 000000000000..e6b44a9d9e42 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/astro.config.mjs @@ -0,0 +1,9 @@ +import mdx from '@astrojs/mdx'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + integrations: [mdx()], + experimental: { + svg: {} + } +}); diff --git a/packages/astro/test/fixtures/core-image-svg/package.json b/packages/astro/test/fixtures/core-image-svg/package.json new file mode 100644 index 000000000000..675c0b41acf0 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/package.json @@ -0,0 +1,12 @@ +{ + "name": "@test/core-image-svg", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/mdx": "workspace:*" + }, + "scripts": { + "dev": "astro dev" + } +} diff --git a/packages/astro/test/fixtures/core-image-svg/src/assets/alpine-multi-color.svg b/packages/astro/test/fixtures/core-image-svg/src/assets/alpine-multi-color.svg new file mode 100644 index 000000000000..3654564da09b --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/assets/alpine-multi-color.svg @@ -0,0 +1,10 @@ + + Custom Preset 4 Copy 5 + + + + + + + + diff --git a/packages/astro/test/fixtures/core-image-svg/src/assets/astro.svg b/packages/astro/test/fixtures/core-image-svg/src/assets/astro.svg new file mode 100644 index 000000000000..d9e8024ad11a --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/assets/astro.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/astro/test/fixtures/core-image-svg/src/assets/chevron-right.svg b/packages/astro/test/fixtures/core-image-svg/src/assets/chevron-right.svg new file mode 100644 index 000000000000..3fcc67a0e52b --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/assets/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/astro/test/fixtures/core-image-svg/src/assets/github.svg b/packages/astro/test/fixtures/core-image-svg/src/assets/github.svg new file mode 100644 index 000000000000..ce45f4c95b3e --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/assets/github.svg @@ -0,0 +1,6 @@ + + + diff --git a/packages/astro/test/fixtures/core-image-svg/src/assets/penguin.svg b/packages/astro/test/fixtures/core-image-svg/src/assets/penguin.svg new file mode 100644 index 000000000000..d93379b6846f --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/assets/penguin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/astro/test/fixtures/core-image-svg/src/assets/plus.svg b/packages/astro/test/fixtures/core-image-svg/src/assets/plus.svg new file mode 100644 index 000000000000..d0f57e4f60be --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/assets/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/astro/test/fixtures/core-image-svg/src/content/blog/basic.mdx b/packages/astro/test/fixtures/core-image-svg/src/content/blog/basic.mdx new file mode 100644 index 000000000000..b501cdb60512 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/content/blog/basic.mdx @@ -0,0 +1,7 @@ +--- +title: Basic Test +description: Check that SVG Components work +--- +import Astro from '~/assets/astro.svg'; + + diff --git a/packages/astro/test/fixtures/core-image-svg/src/content/blog/props.mdx b/packages/astro/test/fixtures/core-image-svg/src/content/blog/props.mdx new file mode 100644 index 000000000000..6d9586a94b98 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/content/blog/props.mdx @@ -0,0 +1,14 @@ +--- +title: Props Test +description: Check that SVG Components work +--- +import Github from '~/assets/github.svg'; + + diff --git a/packages/astro/test/fixtures/core-image-svg/src/content/blog/sprite.mdx b/packages/astro/test/fixtures/core-image-svg/src/content/blog/sprite.mdx new file mode 100644 index 000000000000..9f0ddb02789f --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/content/blog/sprite.mdx @@ -0,0 +1,8 @@ +--- +title: Kitchen Sink Test +description: Check that SVG Components work +--- +import Astro from '~/assets/astro.svg'; + + + diff --git a/packages/astro/test/fixtures/core-image-svg/src/content/config.ts b/packages/astro/test/fixtures/core-image-svg/src/content/config.ts new file mode 100644 index 000000000000..fbf0a269703d --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/content/config.ts @@ -0,0 +1,10 @@ +import { defineCollection, z } from 'astro:content'; + +const blog = defineCollection({ + schema: z.object({ + title: z.string(), + description: z.string().max(60, 'For SEO purposes, keep descriptions short!'), + }), +}); + +export const collections = { blog }; diff --git a/packages/astro/test/fixtures/core-image-svg/src/pages/blog/[...slug].astro b/packages/astro/test/fixtures/core-image-svg/src/pages/blog/[...slug].astro new file mode 100644 index 000000000000..e1ced40b1348 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/pages/blog/[...slug].astro @@ -0,0 +1,22 @@ +--- +import { getCollection } from 'astro:content'; + +export async function getStaticPaths() { + const blogEntries = await getCollection('blog'); + return blogEntries.map(entry => ({ + params: { slug: entry.slug }, props: { entry }, + })); +} + +const { entry } = Astro.props; +const { Content } = await entry.render(); +--- + + + {entry.data.title} + + + + + + diff --git a/packages/astro/test/fixtures/core-image-svg/src/pages/index.astro b/packages/astro/test/fixtures/core-image-svg/src/pages/index.astro new file mode 100644 index 000000000000..17125868f388 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/pages/index.astro @@ -0,0 +1,27 @@ +--- +import AstroLogo from "~/assets/astro.svg"; +--- + + + + + + +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ + diff --git a/packages/astro/test/fixtures/core-image-svg/src/pages/inline.astro b/packages/astro/test/fixtures/core-image-svg/src/pages/inline.astro new file mode 100644 index 000000000000..1d6ef113086d --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/pages/inline.astro @@ -0,0 +1,25 @@ +--- +import ChrevronRight from '~/assets/chevron-right.svg' +--- + + + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + diff --git a/packages/astro/test/fixtures/core-image-svg/src/pages/multiple.astro b/packages/astro/test/fixtures/core-image-svg/src/pages/multiple.astro new file mode 100644 index 000000000000..9fe38460c74f --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/pages/multiple.astro @@ -0,0 +1,27 @@ +--- +import AstroLogo from '~/assets/astro.svg'; +import GithubLogo from '~/assets/github.svg'; +import AlpineLogo from '~/assets/alpine-multi-color.svg'; +--- + + + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + diff --git a/packages/astro/test/fixtures/core-image-svg/src/pages/props.astro b/packages/astro/test/fixtures/core-image-svg/src/pages/props.astro new file mode 100644 index 000000000000..131a52269df5 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/pages/props.astro @@ -0,0 +1,16 @@ +--- +import Plus from '~/assets/plus.svg' +--- + + + + + +
+
+
+ +
+ + diff --git a/packages/astro/test/fixtures/core-image-svg/src/pages/size.astro b/packages/astro/test/fixtures/core-image-svg/src/pages/size.astro new file mode 100644 index 000000000000..fc06b1e1de50 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/pages/size.astro @@ -0,0 +1,35 @@ +--- +import AstroLogo from '~/assets/astro.svg'; +import Plus from '~/assets/plus.svg'; +--- + + + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + diff --git a/packages/astro/test/fixtures/core-image-svg/src/pages/strip.astro b/packages/astro/test/fixtures/core-image-svg/src/pages/strip.astro new file mode 100644 index 000000000000..744b9cc8e1ca --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/pages/strip.astro @@ -0,0 +1,13 @@ +--- +import Alpine from '~/assets/alpine-multi-color.svg' +--- + + + + + +
+ +
+ + diff --git a/packages/astro/test/fixtures/core-image-svg/src/pages/title.astro b/packages/astro/test/fixtures/core-image-svg/src/pages/title.astro new file mode 100644 index 000000000000..eb3b5f11d65a --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/src/pages/title.astro @@ -0,0 +1,13 @@ +--- +import GitHub from '~/assets/github.svg' +--- + + + + + +
+ +
+ + diff --git a/packages/astro/test/fixtures/core-image-svg/tsconfig.json b/packages/astro/test/fixtures/core-image-svg/tsconfig.json new file mode 100644 index 000000000000..923ed4e24fb7 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/assets/*": [ + "src/assets/*" + ] + }, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a148ecb1213a..a2e345f99304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -576,6 +576,9 @@ importers: tsconfck: specifier: ^3.1.4 version: 3.1.4(typescript@5.6.3) + ultrahtml: + specifier: ^1.5.3 + version: 1.5.3 unist-util-visit: specifier: ^5.0.0 version: 5.0.0 @@ -2711,6 +2714,15 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/core-image-svg: + dependencies: + '@astrojs/mdx': + specifier: workspace:* + version: link:../../../../integrations/mdx + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/core-image-unconventional-settings: dependencies: astro: