Skip to content
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

feat!: support generating web app manifest when using html.appIcon #3219

Merged
merged 2 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions e2e/cases/html/app-icon/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 }],
},
},
},
});
Expand All @@ -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(
'<link rel="apple-touch-icon" sizes="180*180" href="/static/image/icon.png">',
'<link rel="apple-touch-icon" sizes="180x180" href="/static/image/icon.png">',
);
});

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',
Expand All @@ -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(
'<link rel="apple-touch-icon" sizes="180*180" href="https://www.example.com/static/image/icon.png">',
'<link rel="apple-touch-icon" sizes="180x180" href="https://www.example.com/static/image/icon.png">',
);
});
8 changes: 6 additions & 2 deletions e2e/cases/html/combined/html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
Expand All @@ -46,7 +48,9 @@ test.describe('should combine multiple html config correctly', () => {

test('appicon', async () => {
const [, iconRelativePath] =
/<link.*rel="apple-touch-icon".*href="(.*?)">/.exec(mainContent) || [];
/<link rel="apple-touch-icon" sizes="180x180" href="(.*?)">/.exec(
mainContent,
) || [];

expect(iconRelativePath).toBeDefined();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export const getPublicPathFromChain = (
};

export const getPublicPathFromCompiler = (
compiler: Rspack.Compiler,
compiler: Rspack.Compiler | Rspack.Compilation,
): string => {
const { publicPath } = compiler.options.output;

Expand Down
153 changes: 106 additions & 47 deletions packages/core/src/plugins/appIcon.ts
Original file line number Diff line number Diff line change
@@ -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<string, HtmlBasicTag[]>();
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();
});
},
});
16 changes: 15 additions & 1 deletion packages/core/src/types/config/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type HtmlTagDescriptor = HtmlTag | HtmlTagHandler;

type ChainedHtmlOption<O> = ConfigChainMergeContext<O, { entryName: string }>;

export type AppIconItem = { src: string; size: number };

export interface HtmlConfig {
/**
* Configure the `<meta>` tag of the HTML.
Expand All @@ -75,8 +77,20 @@ export interface HtmlConfig {
favicon?: ChainedHtmlOption<string>;
/**
* 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.
*/
Expand Down
Loading