diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts index d184c9359..a1e96b096 100644 --- a/packages/astro/src/assets/consts.ts +++ b/packages/astro/src/assets/consts.ts @@ -1,4 +1,5 @@ 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 VALID_INPUT_FORMATS = [ // TODO: `image-size` does not support the following formats, so users can't import them. diff --git a/packages/astro/src/assets/utils/icon.ts b/packages/astro/src/assets/utils/icon.ts new file mode 100644 index 000000000..f30878a56 --- /dev/null +++ b/packages/astro/src/assets/utils/icon.ts @@ -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; + lastModified: number; + icons: Record; +} +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 { + 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]; +} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 00e9d097e..f21c0d8c7 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -11,12 +11,14 @@ import { prependForwardSlash, removeQueryString, } 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 { dropAttributes } from './utils/svg.js'; import { hashTransform, propsToFilename } from './utils/transformToPath.js'; +import { getIconData } from './utils/icon.js'; const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; +const resolvedVirtualIconsId = '\0' + VIRTUAL_ICONS_ID; const rawRE = /(?:\?|&)raw(?:&|$)/; const urlRE = /(\?|&)url(?:&|$)/; @@ -142,6 +144,26 @@ export default function assets({ } }, }, + { + 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(`${body}`, 'utf8'); + return makeSvgComponent({ src: `${collection}/${icon}`, format: 'svg', width, height }, svg); + } + }, + } ]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43b0c149e..9ac6392d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,6 +332,10 @@ importers: astro: specifier: ^3.0.8 version: link:../../packages/astro + devDependencies: + '@iconify-icons/mdi': + specifier: ^1.2.47 + version: 1.2.47 examples/non-html-pages: dependencies: @@ -626,6 +630,9 @@ importers: tsconfig-resolver: specifier: ^3.0.1 version: 3.0.1 + ultrahtml: + specifier: ^1.3.0 + version: 1.3.0 undici: specifier: ^5.23.0 version: 5.23.0 @@ -8000,6 +8007,16 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} 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: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}