@openscript/unplugin-favicons
Version:
Generate favicons for your project with caching for blazing fast rebuilds.
321 lines • 15.3 kB
JavaScript
import { join } from "node:path";
import { colorize } from "consola/utils";
import MagicString from "magic-string";
import mime from "mime/lite";
import { findStaticImports, parseStaticImport } from "mlly";
import { PLUGIN_NAME } from "./const.js";
import generateFavicons from "./generate/generate-favicons.js";
import parseHtml from "./parse-html.js";
import updateManifest from "./update-manifest.js";
import consola from "./utils/consola.js";
import findHtmlRspackPlugin from "./utils/find-html-rspack-plugin.js";
import findHtmlWebpackPlugin from "./utils/find-html-webpack-plugin.js";
import formatDuration from "./utils/format-duration.js";
import Oracle from "./utils/oracle.js";
// eslint-disable-next-line sonarjs/cognitive-complexity
const unpluginFactory = (options, meta) => {
const config = { cache: true, inject: true, ...options };
const oracle = new Oracle(config?.projectRoot);
const developer = oracle.guessDeveloper();
let viteCommand;
config.favicons = {
appDescription: oracle.guessDescription(),
appName: oracle.guessAppName(),
developerName: developer.name,
developerURL: developer.url,
version: oracle.guessVersion(),
...config.favicons,
};
let base = "";
let frontendFramework;
let parsedHtml = [];
let runtimeExports;
let injectionStatus = "ENABLED";
if (config.inject !== true) {
injectionStatus = "DISABLED";
}
if (meta.framework === "esbuild") {
base = "/";
consola.warn(`html injection in esbuild is not supported, injection was disabled.`);
injectionStatus = "NOT_SUPPORTED";
}
/**
* Called during the `buildStart` phase to add assets to the compilation
* and update the HTML returned by favicons for injection later.
*/
const emitFiles = (context, response) => {
const { files, html, images } = response;
// Map each image returned from `favicons` into an object containing its
// original name and the resolved name (ie: name it will have in the output
// bundle). Additionally, emit the image into the Vite context.
const emittedImages = images.map((faviconImage) => {
const filePath = join(config.outputPath ?? "", faviconImage.name);
context.emitFile({
fileName: filePath,
source: faviconImage.contents,
type: "asset",
});
consola.debug(`emit ${colorize("green", String(filePath))}`);
return { name: faviconImage.name, resolvedName: filePath };
});
// Map each file returned from `favicons` into an object containing its
// original name and the resolved name (ie: name it will have in the output
// bundle). Additionally, emit the file into the Vite context.
const emittedFiles = files.map((faviconFile) => {
const filePath = join(config.outputPath ?? "", faviconFile.name);
// If the file from favicons is a manifest, we need to update its file
// names to those emitted by Vite rather than the original asset names.
// For all other files, we keep the original contents.
const source = faviconFile.name.includes("manifest") ? updateManifest(emittedImages, faviconFile.contents) : faviconFile.contents;
context.emitFile({
fileName: filePath,
source,
type: "asset",
});
consola.debug(`emit ${colorize("green", filePath)}`);
return { name: faviconFile.name, resolvedName: filePath };
});
// Transform paths in emitted HTML tags using the filenames generated by
// Vite.
parsedHtml = parseHtml([...emittedFiles, ...emittedImages], html, base);
runtimeExports = {
files: emittedFiles,
images: emittedImages,
metadata: parsedHtml.map((tag) => tag.fragment).join(""),
};
};
const serveMap = new Map([]);
/**
* Called during the `buildStart` phase to add assets to the compilation
* and update the HTML returned by favicons for injection later.
*/
const serveFiles = (context, response) => {
const { files, html, images } = response;
// Map each image returned from `favicons` into an object containing its
// original name and the resolved name (ie: name it will have in the output
// bundle). Additionally, emit the image into the Vite context.
const servedImages = images.map((faviconImage) => {
const filePath = join(config.outputPath ?? "", faviconImage.name);
serveMap.set(filePath, faviconImage.contents);
consola.debug(`serve ${colorize("green", String(filePath))}`);
return { name: faviconImage.name, resolvedName: filePath };
});
// Map each file returned from `favicons` into an object containing its
// original name and the resolved name (ie: name it will have in the output
// bundle). Additionally, serve the file into the Vite context.
const servedFiles = files.map((faviconFile) => {
const filePath = join(config.outputPath ?? "", faviconFile.name);
// If the file from favicons is a manifest, we need to update its file
// names to those served by Vite rather than the original asset names.
// For all other files, we keep the original contents.
const source = faviconFile.name.includes("manifest") ? updateManifest(servedImages, faviconFile.contents) : faviconFile.contents;
serveMap.set(filePath, source);
consola.debug(`serve ${colorize("green", filePath)}`);
return { name: faviconFile.name, resolvedName: filePath };
});
// Transform paths in served HTML tags using the filenames generated by
// Vite.
parsedHtml = parseHtml([...servedFiles, ...servedImages], html, base);
runtimeExports = {
files: servedFiles,
images: servedImages,
metadata: parsedHtml.map((tag) => tag.fragment).join(""),
};
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const injectHtmlPlugin = (compilation, plugin) => {
if (plugin) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
plugin.getHooks(compilation)?.alterAssetTags?.tapPromise(PLUGIN_NAME, async (htmlPluginData) => {
// Skip if a custom injectFunction returns false or if
// the htmlWebpackPlugin options includes a `favicons: false` flag
const isInjectionAllowed = htmlPluginData.plugin.userOptions.favicon !== false && htmlPluginData.plugin.userOptions.favicons !== false;
if (!isInjectionAllowed) {
return htmlPluginData;
}
htmlPluginData.assetTags.meta.push(...parsedHtml.map((tag) => {
return {
attributes: tag.attrs,
meta: { plugin: PLUGIN_NAME },
tagName: tag.tag,
voidTag: true,
};
}));
return htmlPluginData;
});
}
};
return {
apply: (_, environment) => {
viteCommand = environment.command;
return true;
},
async buildStart() {
const startTime = Date.now();
const response = await generateFavicons({
...config,
favicons: {
...config.favicons,
path: config?.favicons?.path ?? config.outputPath,
},
});
consola.info(`Generated assets in ${formatDuration(Date.now() - startTime)}.`);
if (viteCommand === undefined || viteCommand === "build") {
emitFiles(this, response);
if (injectionStatus === "DISABLED") {
consola.info("Inject is disabled, a webapp html file will be generated.");
this.emitFile({
fileName: "webapp.html",
source: parsedHtml.map((tag) => tag.fragment).join("\n"),
type: "asset",
});
}
}
else if (viteCommand === "serve") {
serveFiles(this, response);
}
},
name: PLUGIN_NAME,
order: "post",
rollup: {
generateBundle(_, bundle) {
if (injectionStatus === "ENABLED" && bundle["index.html"] && typeof bundle["index.html"]["source"] === "string") {
// eslint-disable-next-line no-param-reassign
bundle["index.html"]["source"] = bundle["index.html"]["source"].replace("</head>", `${parsedHtml.map((tag) => tag.fragment).join("\n")}\n</head>`);
}
},
},
rspack(compiler) {
base = "/";
compiler.hooks.make.tapPromise(PLUGIN_NAME, async (compilation) => {
if (injectionStatus === "ENABLED") {
const rspackPlugin = findHtmlRspackPlugin(compilation);
const htmlWebpackPlugin = findHtmlWebpackPlugin(compilation);
if (rspackPlugin) {
// Hook into the html-webpack-plugin processing and add the html
injectHtmlPlugin(compilation, rspackPlugin);
}
else if (htmlWebpackPlugin) {
// Hook into the html-webpack-plugin processing and add the html
injectHtmlPlugin(compilation, htmlWebpackPlugin);
}
else {
consola.warn(`No "@rspack/plugin-html" or "html-webpack-plugin" plugin was found, injection was disabled, currently the builtin html is not supported.`);
injectionStatus = "NOT_SUPPORTED";
}
}
});
},
transform(code, id) {
const runtimePackageName = `${PLUGIN_NAME}/runtime`;
if (!code.includes(runtimePackageName)) {
return undefined;
}
const s = new MagicString(code);
const statements = findStaticImports(code).filter((index) => index.specifier === runtimePackageName);
if (statements.length === 0) {
return undefined;
}
statements.forEach((index) => {
const staticImport = parseStaticImport(index);
const generatedCode = `const ${staticImport.defaultImport ?? staticImport.imports} = ${JSON.stringify(runtimeExports)};`;
s.overwrite(staticImport.start, staticImport.end, generatedCode);
});
if (!s.hasChanged()) {
return undefined;
}
return {
code: s.toString(),
map: s.generateMap({
includeContent: true,
source: id,
}),
};
},
transformInclude(id) {
return id.match(/\.((c|m)?j|t)sx?$/u);
},
vite: {
configResolved(viteConfig) {
base = viteConfig.base;
viteConfig.plugins.forEach((plugin) => {
if (frontendFramework === undefined && plugin.name.includes("sveltekit")) {
frontendFramework = "sveltekit";
config.outputPath = "_app/immutable/assets/unplugin-favicons";
}
else if (frontendFramework === undefined && plugin.name.includes("astro")) {
frontendFramework = "astro";
}
else if (frontendFramework === undefined && plugin.name.includes("vike")) {
frontendFramework = "vike";
config.outputPath = "assets/static";
}
});
},
configureServer(server) {
if (viteCommand === "serve") {
return () => {
server.middlewares.use((request, response, next) => {
const url = request.url?.slice(1);
if (serveMap.has(url)) {
const source = serveMap.get(url);
const extension = url.split(".").pop();
if (source instanceof Buffer) {
response.setHeader("Content-Type", extension ? mime.getType(extension) : "application/octet-stream");
}
response.end(source);
}
else {
next();
}
});
};
}
return () => { };
},
generateBundle(_, bundle) {
// @see https://github.com/withastro/astro/issues/7695
if (frontendFramework === "astro") {
Object.keys(bundle).forEach((key) => {
const asset = bundle[key];
if (asset?.fileName?.includes(".astro")) {
asset.code = asset.code.replaceAll(/<link rel="icon".*?>/gu, "");
asset.code = asset.code.replace("</head>", `${parsedHtml.map((tag) => tag.fragment).join("")}</head>`);
}
});
}
},
transformIndexHtml: {
handler(html) {
if (injectionStatus === "ENABLED" && frontendFramework !== "vike") {
// eslint-disable-next-line no-param-reassign
html = html.replaceAll(/<link rel="icon".*?>/gu, "");
// eslint-disable-next-line no-param-reassign
html = html.replace("</head>", `${parsedHtml.map((tag) => tag.fragment).join("")}</head>`);
return html;
}
return html;
},
order: "post",
},
},
webpack(compiler) {
if (compiler.options.output.path?.includes(".next")) {
config.outputPath = "static/media/favicons/";
injectionStatus = "NOT_SUPPORTED";
}
else {
base = "/";
}
compiler.hooks.make.tapPromise(PLUGIN_NAME, async (compilation) => {
consola.info(compilation);
if (injectionStatus === "ENABLED") {
// Hook into the html-webpack-plugin processing and add the html
injectHtmlPlugin(compilation, findHtmlWebpackPlugin(compilation));
}
});
},
};
};
export default unpluginFactory;
//# sourceMappingURL=unplugin-factory.js.map