diff --git a/e2e/cases/html/app-icon/index.test.ts b/e2e/cases/html/app-icon/index.test.ts index 3529573aa1..6a320b7ddd 100644 --- a/e2e/cases/html/app-icon/index.test.ts +++ b/e2e/cases/html/app-icon/index.test.ts @@ -1,12 +1,14 @@ import { build } from '@e2e/helper'; import { expect, test } from '@playwright/test'; -test('should emit app icon to dist path', async () => { +test('should emit apple-touch-icon to dist path', async () => { const rsbuild = await build({ cwd: __dirname, rsbuildConfig: { html: { - appIcon: '../../../assets/icon.png', + appIcon: { + icons: [{ src: '../../../assets/icon.png', size: 180 }], + }, }, }, }); @@ -20,16 +22,18 @@ test('should emit app icon to dist path', async () => { files[Object.keys(files).find((file) => file.endsWith('index.html'))!]; expect(html).toContain( - '', + '', ); }); -test('should apply asset prefix to app icon URL', async () => { +test('should apply asset prefix to apple-touch-icon URL', async () => { const rsbuild = await build({ cwd: __dirname, rsbuildConfig: { html: { - appIcon: '../../../assets/icon.png', + appIcon: { + icons: [{ src: '../../../assets/icon.png', size: 180 }], + }, }, output: { assetPrefix: 'https://www.example.com', @@ -48,6 +52,6 @@ test('should apply asset prefix to app icon URL', async () => { files[Object.keys(files).find((file) => file.endsWith('index.html'))!]; expect(html).toContain( - '', + '', ); }); diff --git a/e2e/cases/html/combined/html.test.ts b/e2e/cases/html/combined/html.test.ts index 5cec492492..6470b24c6c 100644 --- a/e2e/cases/html/combined/html.test.ts +++ b/e2e/cases/html/combined/html.test.ts @@ -24,7 +24,9 @@ test.describe('should combine multiple html config correctly', () => { description: 'a description of the page', }, inject: 'body', - appIcon: '../../../assets/icon.png', + appIcon: { + icons: [{ src: '../../../assets/icon.png', size: 180 }], + }, favicon: '../../../assets/icon.png', }, }, @@ -46,7 +48,9 @@ test.describe('should combine multiple html config correctly', () => { test('appicon', async () => { const [, iconRelativePath] = - //.exec(mainContent) || []; + //.exec( + mainContent, + ) || []; expect(iconRelativePath).toBeDefined(); diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index 7511d96866..1e0107f29d 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -143,7 +143,7 @@ export const getPublicPathFromChain = ( }; export const getPublicPathFromCompiler = ( - compiler: Rspack.Compiler, + compiler: Rspack.Compiler | Rspack.Compilation, ): string => { const { publicPath } = compiler.options.output; diff --git a/packages/core/src/plugins/appIcon.ts b/packages/core/src/plugins/appIcon.ts index 69c439214c..0987e7bc58 100644 --- a/packages/core/src/plugins/appIcon.ts +++ b/packages/core/src/plugins/appIcon.ts @@ -1,91 +1,150 @@ import fs from 'node:fs'; import path from 'node:path'; -import { ensureAssetPrefix, isFileExists } from '../helpers'; -import type { EnvironmentContext, RsbuildPlugin } from '../types'; +import { + ensureAssetPrefix, + getPublicPathFromCompiler, + isFileExists, +} from '../helpers'; +import type { AppIconItem, HtmlBasicTag, RsbuildPlugin } from '../types'; export const pluginAppIcon = (): RsbuildPlugin => ({ name: 'rsbuild:app-icon', setup(api) { - const cache = new Map< + const htmlTagsMap = new Map(); + const iconPathMap = new Map< string, - { absolutePath: string; relativePath: string } + { absolutePath: string; relativePath: string; requestPath: string } >(); - const getIconPath = ({ config, name }: EnvironmentContext) => { - const { appIcon } = config.html; - if (!appIcon) { - return; - } + const formatIcon = ( + icon: AppIconItem, + distDir: string, + publicPath: string, + ) => { + const { src, size } = icon; + const cached = iconPathMap.get(src); + const sizes = `${size}x${size}`; - const cached = cache.get(name); if (cached) { - cached; + return { + sizes, + ...cached, + ...icon, + }; } - const distDir = config.output.distPath.image; - const absolutePath = path.isAbsolute(appIcon) - ? appIcon - : path.join(api.context.rootPath, appIcon); + const absolutePath = path.isAbsolute(src) + ? src + : path.join(api.context.rootPath, src); const relativePath = path.posix.join( distDir, path.basename(absolutePath), ); + const requestPath = ensureAssetPrefix(relativePath, publicPath); const paths = { + requestPath, absolutePath, relativePath, }; - cache.set(name, paths); + iconPathMap.set(src, paths); - return paths; + return { + sizes, + ...paths, + ...icon, + }; }; api.processAssets( { stage: 'additional' }, async ({ compilation, environment, sources }) => { - const iconPath = getIconPath(environment); - if (!iconPath) { + const { config } = environment; + const { appIcon } = config.html; + + if (!appIcon) { return; } - if (!(await isFileExists(iconPath.absolutePath))) { - throw new Error( - `[rsbuild:app-icon] Can not find the app icon, please check if the '${iconPath.relativePath}' file exists'.`, + const distDir = config.output.distPath.image; + const publicPath = getPublicPathFromCompiler(compilation); + const icons = appIcon.icons.map((icon) => + formatIcon(icon, distDir, publicPath), + ); + const tags: HtmlBasicTag[] = []; + + for (const icon of icons) { + if (!(await isFileExists(icon.absolutePath))) { + throw new Error( + `[rsbuild:app-icon] Can not find the app icon, please check if the '${icon.relativePath}' file exists'.`, + ); + } + + const source = await fs.promises.readFile(icon.absolutePath); + + compilation.emitAsset( + icon.relativePath, + new sources.RawSource(source), ); + + if (icon.size < 200) { + tags.push({ + tag: 'link', + attrs: { + rel: 'apple-touch-icon', + sizes: icon.sizes, + href: icon.requestPath, + }, + }); + } } - const source = await fs.promises.readFile(iconPath.absolutePath); + if (appIcon.name) { + const manifestIcons = icons.map((icon) => ({ + src: icon.requestPath, + sizes: icon.sizes, + })); - compilation.emitAsset( - iconPath.relativePath, - new sources.RawSource(source), - ); - }, - ); + const manifest = { + name: appIcon.name, + icons: manifestIcons, + }; + + const manifestFile = 'manifest.webmanifest'; + + compilation.emitAsset( + manifestFile, + new sources.RawSource(JSON.stringify(manifest)), + ); - api.modifyHTMLTags( - ({ headTags, bodyTags }, { environment, compilation }) => { - const iconPath = getIconPath(environment); - if (!iconPath) { - return { headTags, bodyTags }; + tags.push({ + tag: 'link', + attrs: { + rel: 'manifest', + href: ensureAssetPrefix(manifestFile, publicPath), + }, + }); } - headTags.unshift({ - tag: 'link', - attrs: { - rel: 'apple-touch-icon', - sizes: '180*180', - href: ensureAssetPrefix( - iconPath.relativePath, - compilation.outputOptions.publicPath, - ), - }, - }); - - return { headTags, bodyTags }; + if (tags.length) { + htmlTagsMap.set(environment.name, tags); + } }, ); + + api.modifyHTMLTags(({ headTags, bodyTags }, { environment }) => { + const tags = htmlTagsMap.get(environment.name); + if (tags) { + headTags.unshift(...tags); + } + return { headTags, bodyTags }; + }); + + api.onCloseDevServer(() => { + htmlTagsMap.clear(); + iconPathMap.clear(); + }); }, }); diff --git a/packages/core/src/types/config/html.ts b/packages/core/src/types/config/html.ts index 7dd7cbf8eb..3ca43dcdc7 100644 --- a/packages/core/src/types/config/html.ts +++ b/packages/core/src/types/config/html.ts @@ -52,6 +52,8 @@ export type HtmlTagDescriptor = HtmlTag | HtmlTagHandler; type ChainedHtmlOption = ConfigChainMergeContext; +export type AppIconItem = { src: string; size: number }; + export interface HtmlConfig { /** * Configure the `` tag of the HTML. @@ -75,8 +77,20 @@ export interface HtmlConfig { favicon?: ChainedHtmlOption; /** * Set the file path of the app icon, which can be a relative path or an absolute path. + * + * @example + * appIcon: { + * name: 'My Website', + * icons: [ + * { src: './icon-192.png', size: 192 }, + * { src: './icon-512.png', size: 512 }, + * ] + * } */ - appIcon?: string; + appIcon?: { + name?: string; + icons: AppIconItem[]; + }; /** * Set the id of root element. */