import { execSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { Path, ProjectRoot } from '@affine-tools/utils/path'; import { Repository } from '@napi-rs/simple-git'; import { HtmlRspackPlugin, type HtmlRspackPluginOptions } from '@rspack/core'; import { once } from 'lodash-es'; type HtmlRspackPluginInstance = InstanceType; type PluginLike = { apply: (compiler: CompilerLike) => void; }; type CompilerLike = { webpack?: { sources?: { RawSource?: new (source: string) => unknown; }; }; hooks: { compilation: { tap: (name: string, callback: (compilation: any) => void) => void; }; }; }; function createRawSource(compiler: CompilerLike, source: string) { const RawSource = compiler.webpack?.sources?.RawSource; if (!RawSource) { throw new Error( 'compiler.webpack.sources.RawSource is required for html plugin assets emission' ); } return new RawSource(source); } export const getPublicPath = (BUILD_CONFIG: BUILD_CONFIG_TYPE) => { const { BUILD_TYPE } = process.env; if (typeof process.env.PUBLIC_PATH === 'string') { return process.env.PUBLIC_PATH; } if ( BUILD_CONFIG.debug || BUILD_CONFIG.distribution === 'desktop' || BUILD_CONFIG.distribution === 'ios' || BUILD_CONFIG.distribution === 'android' ) { return '/'; } switch (BUILD_TYPE) { case 'stable': return 'https://prod.affineassets.com/'; case 'beta': return 'https://beta.affineassets.com/'; default: return 'https://dev.affineassets.com/'; } }; const DESCRIPTION = `There can be more than Notion and Miro. AFFiNE is a next-gen knowledge base that brings planning, sorting and creating all together.`; const gitShortHash = once(() => { const { GITHUB_SHA } = process.env; if (GITHUB_SHA) { return GITHUB_SHA.substring(0, 9); } const repo = new Repository(ProjectRoot.value); const shortSha = repo.head().target()?.substring(0, 9); if (shortSha) { return shortSha; } const sha = execSync(`git rev-parse --short HEAD`, { encoding: 'utf-8', }).trim(); return sha; }); const currentDir = Path.dir(import.meta.url); export interface CreateHTMLPluginConfig { filename?: string; template?: string; copySharedPublicAssets?: boolean; additionalEntryForSelfhost?: boolean; selfhostPublicPath?: string; injectGlobalErrorHandler?: boolean; emitAssetsManifest?: boolean; } function getHTMLPluginOptions(BUILD_CONFIG: BUILD_CONFIG_TYPE) { const publicPath = getPublicPath(BUILD_CONFIG); const cdnOrigin = publicPath.startsWith('/') ? undefined : new URL(publicPath).origin; const templateParams = { GIT_SHORT_SHA: gitShortHash(), DESCRIPTION, PRECONNECT: cdnOrigin ? `` : '', VIEWPORT_FIT: BUILD_CONFIG.isMobileEdition ? 'cover' : 'auto', }; return { template: currentDir.join('template.html').toString(), inject: 'body', minify: false, templateParameters: templateParams, chunks: ['app'], scriptLoading: 'blocking', } satisfies HtmlRspackPluginOptions; } const AssetsManifestPlugin = { apply(compiler: CompilerLike) { compiler.hooks.compilation.tap('assets-manifest-plugin', compilation => { HtmlRspackPlugin.getCompilationHooks( compilation ).beforeAssetTagGeneration.tap('assets-manifest-plugin', arg => { if (!compilation.getAsset('assets-manifest.json')) { compilation.emitAsset( `assets-manifest.json`, createRawSource( compiler, JSON.stringify( { ...arg.assets, js: arg.assets.js.map(file => file.substring(arg.assets.publicPath.length) ), css: arg.assets.css.map(file => file.substring(arg.assets.publicPath.length) ), gitHash: gitShortHash(), description: DESCRIPTION, }, null, 2 ) ), { immutable: false, } ); } return arg; }); }); }, }; const GlobalErrorHandlerPlugin = { apply(compiler: CompilerLike) { const globalErrorHandler = [ 'js/global-error-handler.js', readFileSync(currentDir.join('./error-handler.js').toString(), 'utf-8'), ]; compiler.hooks.compilation.tap( 'global-error-handler-plugin', compilation => { HtmlRspackPlugin.getCompilationHooks( compilation ).beforeAssetTagGeneration.tap('global-error-handler-plugin', arg => { if (!compilation.getAsset(globalErrorHandler[0])) { compilation.emitAsset( globalErrorHandler[0], createRawSource(compiler, globalErrorHandler[1]) ); arg.assets.js.unshift( arg.assets.publicPath + globalErrorHandler[0] ); } return arg; }); } ); }, }; const CorsPlugin = { apply(compiler: CompilerLike) { compiler.hooks.compilation.tap('html-js-cors-plugin', compilation => { HtmlRspackPlugin.getCompilationHooks(compilation).alterAssetTags.tap( 'html-js-cors-plugin', options => { if (options.publicPath !== '/') { options.assetTags.scripts.forEach(script => { script.attributes.crossorigin = true; }); options.assetTags.styles.forEach(style => { style.attributes.crossorigin = true; }); } return options; } ); }); }, }; export function createHTMLPlugins( BUILD_CONFIG: BUILD_CONFIG_TYPE, config: CreateHTMLPluginConfig ): (HtmlRspackPluginInstance | PluginLike)[] { const publicPath = getPublicPath(BUILD_CONFIG); const htmlPluginOptions = { ...getHTMLPluginOptions(BUILD_CONFIG), ...(config.template ? { template: config.template } : {}), }; const selfhostPublicPath = config.selfhostPublicPath ?? '/'; const plugins: (HtmlRspackPluginInstance | PluginLike)[] = []; plugins.push( new HtmlRspackPlugin({ ...htmlPluginOptions, chunks: ['index'], filename: config.filename, publicPath, meta: { 'env:publicPath': publicPath, }, }) ); if (BUILD_CONFIG.isElectron) { plugins.push( new HtmlRspackPlugin({ ...htmlPluginOptions, chunks: ['shell'], filename: 'shell.html', publicPath, meta: { 'env:publicPath': publicPath, }, }), new HtmlRspackPlugin({ ...htmlPluginOptions, filename: 'popup.html', chunks: ['popup'], publicPath, meta: { 'env:publicPath': publicPath, }, }), new HtmlRspackPlugin({ ...htmlPluginOptions, filename: 'background-worker.html', chunks: ['backgroundWorker'], publicPath, meta: { 'env:publicPath': publicPath, }, }) ); } if (!BUILD_CONFIG.isElectron) { plugins.push(CorsPlugin); } if (config.emitAssetsManifest) { plugins.push(AssetsManifestPlugin); } if (config.injectGlobalErrorHandler) { plugins.push(GlobalErrorHandlerPlugin); } if (config.additionalEntryForSelfhost) { plugins.push( new HtmlRspackPlugin({ ...htmlPluginOptions, chunks: ['index'], publicPath: selfhostPublicPath, meta: { 'env:isSelfHosted': 'true', 'env:publicPath': selfhostPublicPath, }, filename: 'selfhost.html', templateParameters: { ...htmlPluginOptions.templateParameters, PRECONNECT: '', }, }) ); } return plugins; }