Compare commits
2 commits
Author | SHA1 | Date | |
---|---|---|---|
|
04c563429c | ||
|
c86ae12557 |
8 changed files with 208 additions and 6 deletions
|
@ -55,6 +55,7 @@
|
||||||
"./components": "./components/index.ts",
|
"./components": "./components/index.ts",
|
||||||
"./components/*": "./components/*",
|
"./components/*": "./components/*",
|
||||||
"./assets": "./dist/assets/index.js",
|
"./assets": "./dist/assets/index.js",
|
||||||
|
"./assets/runtime": "./dist/assets/runtime.js",
|
||||||
"./assets/utils": "./dist/assets/utils/index.js",
|
"./assets/utils": "./dist/assets/utils/index.js",
|
||||||
"./assets/image-endpoint": "./dist/assets/image-endpoint.js",
|
"./assets/image-endpoint": "./dist/assets/image-endpoint.js",
|
||||||
"./assets/services/sharp": "./dist/assets/services/sharp.js",
|
"./assets/services/sharp": "./dist/assets/services/sharp.js",
|
||||||
|
@ -167,6 +168,7 @@
|
||||||
"string-width": "^6.1.0",
|
"string-width": "^6.1.0",
|
||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
"tsconfig-resolver": "^3.0.1",
|
"tsconfig-resolver": "^3.0.1",
|
||||||
|
"ultrahtml": "^1.3.0",
|
||||||
"undici": "^5.23.0",
|
"undici": "^5.23.0",
|
||||||
"unist-util-visit": "^4.1.2",
|
"unist-util-visit": "^4.1.2",
|
||||||
"vfile": "^5.3.7",
|
"vfile": "^5.3.7",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export const VIRTUAL_MODULE_ID = 'astro:assets';
|
export const VIRTUAL_MODULE_ID = 'astro:assets';
|
||||||
|
export const VIRTUAL_ICONS_ID = 'astro:assets/icons/';
|
||||||
export const VIRTUAL_SERVICE_ID = 'virtual:image-service';
|
export const VIRTUAL_SERVICE_ID = 'virtual:image-service';
|
||||||
export const VALID_INPUT_FORMATS = [
|
export const VALID_INPUT_FORMATS = [
|
||||||
// TODO: `image-size` does not support the following formats, so users can't import them.
|
// TODO: `image-size` does not support the following formats, so users can't import them.
|
||||||
|
|
35
packages/astro/src/assets/runtime.ts
Normal file
35
packages/astro/src/assets/runtime.ts
Normal file
|
@ -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<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;
|
||||||
|
export function createSvgComponent({ meta, attributes, children }: SvgComponentProps) {
|
||||||
|
const id = `a:${ids++}`;
|
||||||
|
const rendered = new WeakSet<Response>();
|
||||||
|
const Component = createComponent((result, props) => {
|
||||||
|
const { viewBox, title: titleProp, ...normalizedProps } = normalizeProps(attributes, props);
|
||||||
|
const title = titleProp ? unescapeHTML(`<title>${titleProp}</title>`) : '';
|
||||||
|
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(`<symbol${spreadAttributes({ viewBox, id })}>${children}</symbol>`);
|
||||||
|
rendered.add(result.response);
|
||||||
|
}
|
||||||
|
return render`<svg${spreadAttributes(normalizedProps)}>${title}${symbol}<use xlink:href="#${id}" /></svg>`;
|
||||||
|
})
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -5,11 +5,13 @@ import { prependForwardSlash, slash } from '../../core/path.js';
|
||||||
import type { ImageMetadata } from '../types.js';
|
import type { ImageMetadata } from '../types.js';
|
||||||
import { imageMetadata } from './metadata.js';
|
import { imageMetadata } from './metadata.js';
|
||||||
|
|
||||||
|
type ImageMetadataWithContents = ImageMetadata & { contents?: Buffer };
|
||||||
|
|
||||||
export async function emitESMImage(
|
export async function emitESMImage(
|
||||||
id: string | undefined,
|
id: string | undefined,
|
||||||
watchMode: boolean,
|
watchMode: boolean,
|
||||||
fileEmitter: any
|
fileEmitter: any
|
||||||
): Promise<ImageMetadata | undefined> {
|
): Promise<ImageMetadata & { contents?: Buffer } | undefined> {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -28,11 +30,15 @@ export async function emitESMImage(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emittedImage: ImageMetadata = {
|
const emittedImage: ImageMetadataWithContents = {
|
||||||
src: '',
|
src: '',
|
||||||
...fileMetadata,
|
...fileMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (fileMetadata.format === 'svg') {
|
||||||
|
emittedImage.contents = fileData;
|
||||||
|
}
|
||||||
|
|
||||||
// Build
|
// Build
|
||||||
if (!watchMode) {
|
if (!watchMode) {
|
||||||
const pathname = decodeURI(url.pathname);
|
const pathname = decodeURI(url.pathname);
|
||||||
|
|
62
packages/astro/src/assets/utils/icon.ts
Normal file
62
packages/astro/src/assets/utils/icon.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import fs, { readFileSync } from 'node:fs';
|
||||||
|
import type { AstroConfig, AstroSettings } from '../../@types/astro';
|
||||||
|
import { prependForwardSlash } from '../../core/path.js';
|
||||||
|
|
||||||
|
interface IconCollection {
|
||||||
|
prefix: string;
|
||||||
|
info: Record<string, string>;
|
||||||
|
lastModified: number;
|
||||||
|
icons: Record<string, IconData>;
|
||||||
|
}
|
||||||
|
interface IconData {
|
||||||
|
body: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONIFY_REPO = new URL(`https://raw.githubusercontent.com/iconify/icon-sets/master/json/`);
|
||||||
|
function getIconifyUrl(collection: string) {
|
||||||
|
return new URL(`./${collection}.json`, ICONIFY_REPO);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIconData(
|
||||||
|
settings: AstroSettings,
|
||||||
|
collection: string,
|
||||||
|
name: string
|
||||||
|
): Promise<IconData | undefined> {
|
||||||
|
const iconsCacheDir = new URL('assets/icons/', settings.config.cacheDir);
|
||||||
|
|
||||||
|
// Ensure that the cache directory exists
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(iconsCacheDir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
// logger.warn(
|
||||||
|
// 'astro:assets',
|
||||||
|
// `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${err}`
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedFileURL = new URL(`./${collection}.json`, iconsCacheDir);
|
||||||
|
|
||||||
|
let collectionData: IconCollection | undefined;
|
||||||
|
if (fs.existsSync(cachedFileURL)) {
|
||||||
|
try {
|
||||||
|
collectionData = JSON.parse(fs.readFileSync(cachedFileURL, { encoding: 'utf8' })) as IconCollection;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!collectionData) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(getIconifyUrl(collection));
|
||||||
|
collectionData = await res.json();
|
||||||
|
await fs.promises.writeFile(cachedFileURL, JSON.stringify(collectionData), { encoding: 'utf8' })
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Unable to locate icon "${collection}/${name}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { icons } = collectionData!;
|
||||||
|
if (icons[name] === undefined) {
|
||||||
|
throw new Error(`Unable to locate icon "${collection}/${name}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return icons[name];
|
||||||
|
}
|
27
packages/astro/src/assets/utils/svg.ts
Normal file
27
packages/astro/src/assets/utils/svg.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
type SvgAttributes = Record<string, any>;
|
||||||
|
|
||||||
|
// 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<string, any>) {
|
||||||
|
for (const property in object) {
|
||||||
|
Object.defineProperty(object, property, { enumerable: false });
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,18 +1,24 @@
|
||||||
import MagicString from 'magic-string';
|
|
||||||
import type * as vite from 'vite';
|
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 { normalizePath } from 'vite';
|
||||||
import type { AstroPluginOptions, ImageTransform } from '../@types/astro';
|
import { parse, renderSync } from 'ultrahtml';
|
||||||
import {
|
import {
|
||||||
appendForwardSlash,
|
appendForwardSlash,
|
||||||
joinPaths,
|
joinPaths,
|
||||||
prependForwardSlash,
|
prependForwardSlash,
|
||||||
removeQueryString,
|
removeQueryString,
|
||||||
} from '../core/path.js';
|
} from '../core/path.js';
|
||||||
import { VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
|
import { VIRTUAL_ICONS_ID, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
|
||||||
import { emitESMImage } from './utils/emitAsset.js';
|
import { emitESMImage } from './utils/emitAsset.js';
|
||||||
|
import { dropAttributes } from './utils/svg.js';
|
||||||
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
|
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
|
||||||
|
import { getIconData } from './utils/icon.js';
|
||||||
|
|
||||||
const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
|
const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
|
||||||
|
const resolvedVirtualIconsId = '\0' + VIRTUAL_ICONS_ID;
|
||||||
|
|
||||||
const rawRE = /(?:\?|&)raw(?:&|$)/;
|
const rawRE = /(?:\?|&)raw(?:&|$)/;
|
||||||
const urlRE = /(\?|&)url(?:&|$)/;
|
const urlRE = /(\?|&)url(?:&|$)/;
|
||||||
|
@ -125,11 +131,57 @@ export default function assets({
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanedUrl = removeQueryString(id);
|
const cleanedUrl = removeQueryString(id);
|
||||||
|
|
||||||
if (/\.(jpeg|jpg|png|tiff|webp|gif|svg)$/.test(cleanedUrl)) {
|
if (/\.(jpeg|jpg|png|tiff|webp|gif|svg)$/.test(cleanedUrl)) {
|
||||||
const meta = await emitESMImage(id, this.meta.watchMode, this.emitFile);
|
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)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'astro:assets/icons',
|
||||||
|
async resolveId(id) {
|
||||||
|
if (id.startsWith(VIRTUAL_ICONS_ID)) {
|
||||||
|
return `\0${id}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async load(id) {
|
||||||
|
if (id.startsWith(resolvedVirtualIconsId)) {
|
||||||
|
const name = id.slice(resolvedVirtualIconsId.length);
|
||||||
|
const [collection, icon] = name.split('/');
|
||||||
|
|
||||||
|
const data = await getIconData(settings, collection, icon);
|
||||||
|
if (!data) return;
|
||||||
|
const { width = 24, height = 24, body } = data;
|
||||||
|
const svg = Buffer.from(`<svg data-name="${name}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`, 'utf8');
|
||||||
|
return makeSvgComponent({ src: `${collection}/${icon}`, format: 'svg', width, height }, svg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)});`;
|
||||||
|
}
|
||||||
|
|
|
@ -332,6 +332,10 @@ importers:
|
||||||
astro:
|
astro:
|
||||||
specifier: ^3.0.8
|
specifier: ^3.0.8
|
||||||
version: link:../../packages/astro
|
version: link:../../packages/astro
|
||||||
|
devDependencies:
|
||||||
|
'@iconify-icons/mdi':
|
||||||
|
specifier: ^1.2.47
|
||||||
|
version: 1.2.47
|
||||||
|
|
||||||
examples/non-html-pages:
|
examples/non-html-pages:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -626,6 +630,9 @@ importers:
|
||||||
tsconfig-resolver:
|
tsconfig-resolver:
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
|
ultrahtml:
|
||||||
|
specifier: ^1.3.0
|
||||||
|
version: 1.3.0
|
||||||
undici:
|
undici:
|
||||||
specifier: ^5.23.0
|
specifier: ^5.23.0
|
||||||
version: 5.23.0
|
version: 5.23.0
|
||||||
|
@ -8000,6 +8007,16 @@ packages:
|
||||||
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
|
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@iconify-icons/mdi@1.2.47:
|
||||||
|
resolution: {integrity: sha512-XwrHxJb2GzToyyoI9gaVm6/yE3aRlxB2IolKXzTEf6qAtjv3S4xFAxYaOlm6iuylQv+WyquH9C4cBudNPRHApg==}
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@iconify/types@2.0.0:
|
||||||
|
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@jest/schemas@29.6.3:
|
/@jest/schemas@29.6.3:
|
||||||
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
|
|
Loading…
Reference in a new issue