diff --git a/README.md b/README.md index a074288..3283558 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,79 @@ A Vite plugin that supports runtime CDN configuration. +## Install + +> Make sure your vite version is **2.9.0** or higher. + +```sh +# pnpm +pnpm add -D vite-plugin-runtime-cdn +# yarn +yarn add -D vite-plugin-runtime-cdn +# npm +npm i -D vite-plugin-runtime-cdn +``` + +## Usage + +```ts +// vite.config.js +import { defineConfig } from 'vite' + +// 1. import the plugin +import { RuntimeCdnPlugin } from 'vite-plugin-runtime-cdn' + +export default defineConfig({ + plugins: [ + // 2. add it to the plugins list + RuntimeCdnPlugin(), + ], +}) +``` + +The plugin's runtime CDN domain is obtained through `window.cdn_domain`, with the default configuration set to `${window.cdn_domain || ''}`. Therefore, you need to ensure that `window.cdn_domain` has been injected into the HTML or other entry files beforehand. If you want to modify this configuration, you can pass in the `cdnDomainPlaceholder` parameter. However, you need to use `${}` to wrap it, such as `${window.myCustomCDNDomain || ''}`. + +```ts +// vite.config.js +import { defineConfig } from 'vite' + +// 1. import the plugin +import { RuntimeCdnPlugin } from 'vite-plugin-runtime-cdn' + +export default defineConfig({ + plugins: [ + // 2. add it to the plugins list + RuntimeCdnPlugin({ + cdnDomainPlaceholder: `${window.myCustomCDNDomain || ''}`, + }), + ], +}) +``` + +By default, this plugin does not transform static resource references (like images) within CSS files. If you want to also convert static resources inside CSS to runtime CDN configurations, you need to inject the CSS into their respective JS modules, then transforming them into runtime CDN configurations. This approach of injecting CSS into JS modules is based on the [`vite-plugin-inject-css-to-js`](https://github.com/Levix/vite-plugin-inject-css-to-js) plugin. + +You can transform static resources referenced inside CSS to runtime CDN configurations by configuring the `transformCssSourceURL` parameter. However, note that CSS files referenced in the HTML entry are not allowed to be injected into corresponding JS modules. For more details, refer to [`Why can't I build all css files into js?`](https://github.com/Levix/vite-plugin-inject-css-to-js?tab=readme-ov-file#why-cant-i-build-all-css-files-into-js). + +```ts +// vite.config.js +import { defineConfig } from 'vite' + +// 1. import the plugin +import { RuntimeCdnPlugin } from 'vite-plugin-runtime-cdn' + +export default defineConfig({ + plugins: [ + // 2. add it to the plugins list + RuntimeCdnPlugin({ + transformCssSourceURL: true, + }), + ], +}) +``` + ## License -[MIT](./LICENSE) License © 2023-PRESENT [Levix](https://github.com/Levix) +[MIT](./LICENSE) License © [Levix](https://github.com/Levix) @@ -22,5 +92,3 @@ A Vite plugin that supports runtime CDN configuration. [bundle-href]: https://bundlephobia.com/result?p=vite-plugin-runtime-cdn [license-src]: https://img.shields.io/github/license/Levix/vite-plugin-runtime-cdn.svg?style=flat&colorA=080f12&colorB=1fa669 [license-href]: https://github.com/Levix/vite-plugin-runtime-cdn/blob/main/LICENSE -[jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669 -[jsdocs-href]: https://www.jsdocs.io/package/vite-plugin-runtime-cdn diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..f48101c --- /dev/null +++ b/README_CN.md @@ -0,0 +1,94 @@ +# vite-plugin-runtime-cdn + +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![bundle][bundle-src]][bundle-href] +[![JSDocs][jsdocs-src]][jsdocs-href] +[![License][license-src]][license-href] + +一个支持运行时 CDN 配置的 Vite 插件。 + +## 安装 + +> 请确认你的 Vite 版本在 **2.9.0** 以上。 + +```sh +# pnpm +pnpm add -D vite-plugin-runtime-cdn +# yarn +yarn add -D vite-plugin-runtime-cdn +# npm +npm i -D vite-plugin-runtime-cdn +``` + +## 使用 + +```ts +// vite.config.js +import { defineConfig } from 'vite' + +// 1. 导入插件 +import { RuntimeCdnPlugin } from 'vite-plugin-runtime-cdn' + +export default defineConfig({ + plugins: [ + // 2. 添加至插件列表 + RuntimeCdnPlugin(), + ], +}) +``` + +该插件运行时的 cdn 域名是通过 `window.cdn_domain` 取到的,默认配置为 `${window.cdn_domain || ''}`,所以你需要保证 `window.cdn_domain` 已经在 html 或者其它入口文件已经注入,如果你想修改该配置,你可以传入 `cdnDomainPlaceholder` 参数,但你需要使用 `${}` 进行包裹,如 `${window.myCustomCDNDomain || ''}`。 + +```ts +// vite.config.js +import { defineConfig } from 'vite' + +// 1. 导入插件 +import { RuntimeCdnPlugin } from 'vite-plugin-runtime-cdn' + +export default defineConfig({ + plugins: [ + // 2. 添加至插件列表 + RuntimeCdnPlugin({ + cdnDomainPlaceholder: `${window.myCustomCDNDomain || ''}`, + }), + ], +}) +``` + +该插件默认不会转换 css 文件内部的静态资源(如图片等)引用,如果你想同时将 css 内部的静态资源也转化为运行时 cdn 配置,则需要将 css 注入到各自的 js 模块内部,再变成运行时 cdn 配置,这里将 css 注入至 js 模块参考的是 [`vite-plugin-inject-css-to-js`](https://github.com/Levix/vite-plugin-inject-css-to-js) 插件。 + +你可以通过配置 `transformCssSourceURL` 参数将 css 内部引用的静态资源转换为运行时 cdn 配置,但请注意,html 入口引用的 css 文件是不允许注入到对应的 js 模块的,具体可以参考 [`Why can't I build all css files into js?`](https://github.com/Levix/vite-plugin-inject-css-to-js?tab=readme-ov-file#why-cant-i-build-all-css-files-into-js)。 + +```ts +// vite.config.js +import { defineConfig } from 'vite' + +// 1. 导入插件 +import { RuntimeCdnPlugin } from 'vite-plugin-runtime-cdn' + +export default defineConfig({ + plugins: [ + // 2. 添加至插件列表 + RuntimeCdnPlugin({ + transformCssSourceURL: true, + }), + ], +}) +``` + +## License + +[MIT](./LICENSE) License © [Levix](https://github.com/Levix) + + + +[npm-version-src]: https://img.shields.io/npm/v/vite-plugin-runtime-cdn?style=flat&colorA=080f12&colorB=1fa669 +[npm-version-href]: https://npmjs.com/package/vite-plugin-runtime-cdn +[npm-downloads-src]: https://img.shields.io/npm/dm/vite-plugin-runtime-cdn?style=flat&colorA=080f12&colorB=1fa669 +[npm-downloads-href]: https://npmjs.com/package/vite-plugin-runtime-cdn +[bundle-src]: https://img.shields.io/bundlephobia/minzip/vite-plugin-runtime-cdn?style=flat&colorA=080f12&colorB=1fa669&label=minzip +[bundle-href]: https://bundlephobia.com/result?p=vite-plugin-runtime-cdn +[license-src]: https://img.shields.io/github/license/Levix/vite-plugin-runtime-cdn.svg?style=flat&colorA=080f12&colorB=1fa669 +[license-href]: https://github.com/Levix/vite-plugin-runtime-cdn/blob/main/LICENSE diff --git a/package.json b/package.json index 42642f1..59749e6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,15 @@ "url": "git+https://github.com/Levix/vite-plugin-runtime-cdn.git" }, "bugs": "https://github.com/Levix/vite-plugin-runtime-cdn/issues", - "keywords": [], + "keywords": [ + "vite", + "vite2.9", + "vite3", + "vite4", + "vite-plugin", + "vite-plugin-runtime-cdn", + "runtime-cdn" + ], "sideEffects": false, "exports": { ".": { @@ -47,6 +55,11 @@ "typecheck": "tsc --noEmit", "prepare": "simple-git-hooks" }, + "peerDependencies": { + "es-module-lexer": "^1.4.1", + "magic-string": "^0.30.5", + "vite": ">= 2.9.0" + }, "devDependencies": { "@antfu/eslint-config": "^2.6.0", "@antfu/ni": "^0.21.12", diff --git a/src/index.ts b/src/index.ts index e1c8f2c..c87d845 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,377 @@ -export const one = 1 -export const two = 2 +import path from 'node:path' +import fs from 'node:fs' +import { parse as parseImports } from 'es-module-lexer' +import MagicString from 'magic-string' +import type { ImportSpecifier } from 'es-module-lexer' +import type { OutputBundle, OutputChunk, PluginContext } from 'rollup' +import type { ChunkMetadata, IndexHtmlTransformContext, PluginOption, ResolvedConfig } from 'vite' + +// Extend the Rollup RenderedChunk type with viteMetadata property +declare module 'rollup' { + export interface RenderedChunk { + viteMetadata?: ChunkMetadata + } +} + +interface Options { + cdnDomainPlaceholder?: string + transformCssSourceURL?: boolean +} + +// Get a random ID +function getRandomID(length = 10) { + let result = '' + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const charactersLength = characters.length + for (let i = 0; i < length; i++) + result += characters.charAt(Math.floor(Math.random() * charactersLength)) + + return result +} + +// Preload helper module ID +const preloadHelperId = '\0vite/preload-helper' + +// CDN maker placeholder that will be replaced at runtime +const cdnMaker = `__VITE_CDN__` + +// Resolved Vite configuration +let config: ResolvedConfig + +// Set of dependencies to be loaded +const deps: Set = new Set() + +// Need to filter the injected CSS tag collection +const cssTags: Set = new Set() + +// Map of CSS file names to their source content +const cssSourceMap: Map = new Map() + +// Set of imported assets +const importedAssets: Set = new Set() + +/** + * Overwrite the preload helper code + * + * @param code string + * @param id string + * @returns { code: string, map: SourceMap | null } | undefined + */ +function overwritePreloadHelper(code: string, id: string) { + // Check if the current module ID matches the preload helper ID + if (preloadHelperId.includes(id)) { + let transformCode = code + // Replace the base path in the code if config.base is set + if (config.base) { + transformCode = code.replace(config.base, '') + const s = new MagicString(transformCode) + return { + code: transformCode, + map: config.build.sourcemap ? s.generateMap({ hires: 'boundary' }) : null, + } + } + } +} + +/** + * Set the CSS tags for the HTML transform context + * + * @param ctx IndexHtmlTransformContext + */ +function setCssTags(ctx: IndexHtmlTransformContext) { + // Map to keep track of analyzed chunks + const analyzedChunk: Map = new Map() + + const getCssTagsForChunk = (chunk: OutputChunk, seen: Set = new Set()) => { + const tags: { filename: string }[] = [] + if (!analyzedChunk.has(chunk)) { + analyzedChunk.set(chunk, 1) + // Recursively get CSS tags for imported chunks + chunk.imports.forEach((file) => { + const importee = ctx.bundle?.[file] + if (importee?.type === 'chunk') + tags.push(...getCssTagsForChunk(importee, seen)) + }) + } + + // Add CSS files imported by the chunk to the tags array + chunk?.viteMetadata!.importedCss.forEach((file) => { + if (!seen.has(file)) { + seen.add(file) + tags.push({ + filename: file, + }) + } + }) + + return tags + } + + // Get CSS tags for entry chunks + if (ctx.chunk?.type === 'chunk' && ctx.chunk.isEntry) { + getCssTagsForChunk(ctx.chunk).forEach((cssTag) => { + cssTags.add(cssTag.filename) + }) + } +} + +/** + * Set the CSS source content and remove CSS assets from the bundle + * + * @param bundle OutputBundle + */ +function setCssSource(bundle: OutputBundle) { + for (const file in bundle) { + const chunk = bundle[file] + if (chunk.type === 'asset' && chunk.fileName.endsWith('.css')) { + cssSourceMap.set(chunk.fileName, chunk.source as string) + delete bundle[file] + } + } +} + +/** + * Set the imported assets from the bundle + * + * @param bundle OutputBundle + */ +function setImportedAssets(bundle: OutputBundle) { + for (const file in bundle) { + const chunk = bundle[file] + if (chunk.type === 'chunk' && chunk?.viteMetadata?.importedAssets.size) { + chunk.viteMetadata.importedAssets.forEach((asset) => { + importedAssets.add(asset) + }) + } + } +} + +/** + * Inject CSS into JavaScript chunks + * + * @param ctx PluginContext + * @param bundle OutputBundle + */ +function injectCssTojs(ctx: PluginContext, bundle: OutputBundle) { + // List of emitted CSS files + const emittedFileList: string[] = [] + + for (const file in bundle) { + const chunk = bundle[file] + if (chunk.type === 'chunk' && chunk?.viteMetadata?.importedCss.size) { + const importedCss = Array.from(chunk.viteMetadata.importedCss) + for (const cssId of importedCss) { + // Get the CSS code by its ID + const cssCode = cssSourceMap.get(cssId) + if (!cssCode) + continue + + // Check if the CSS ID is in the set of CSS tags + if (cssTags.has(cssId)) { + // Emit the CSS file if it hasn't been emitted yet + if (!emittedFileList.includes(cssId)) { + emittedFileList.push(cssId) + ctx.emitFile({ type: 'asset', fileName: cssId, source: cssCode }) + } + } + else { + // Inject the CSS code directly into the JavaScript chunk + const initialCode = chunk.code + chunk.code + = `(function(){ try {var elementStyle = document.createElement('style'); elementStyle.appendChild(document.createTextNode(` + + `${JSON.stringify(cssCode.trim()).replace(/^"|"$/g, '`')}` + + `));document.head.appendChild(elementStyle);} catch(e) {console.error('style-injected-by-js', e);} })(); ` + + `${initialCode}` + } + } + // Clear the imported CSS set for the chunk + chunk.viteMetadata.importedCss.clear() + } + } +} + +/** + * Set the dependencies for the bundle + * + * @param bundle OutputBundle + */ +function setDeps(bundle: OutputBundle) { + for (const file in bundle) { + const chunk = bundle[file] + + if (chunk.type === 'chunk') { + const code = chunk.code + const imports: ImportSpecifier[] = parseImports(code)[0].filter(i => i.d > -1) + + if (imports.length) { + for (let index = 0; index < imports.length; index++) { + const { n: name, s: start, e: end } = imports[index] + let url = name + if (!url) { + const rawUrl = code.slice(start, end) + if (rawUrl[0] === `"` && rawUrl[rawUrl.length - 1] === `"`) + url = rawUrl.slice(1, -1) + } + + let normalizedFile: string | undefined + + if (url) { + normalizedFile = path.posix.join(path.posix.dirname(chunk.fileName), url) + const ownerFilename = chunk.fileName + const analyzed: Set = new Set() + + // Function to recursively add dependencies + const addDeps = (filename: string) => { + if (filename === ownerFilename) + return + if (analyzed.has(filename)) + return + analyzed.add(filename) + const chunk = bundle[filename] + if (chunk && chunk.type === 'chunk') { + // Add the chunk file name to the dependencies + deps.add(chunk.fileName) + // Recursively add dependencies for imported chunks + chunk.imports.forEach(addDeps) + } + } + + // Add dependencies for the normalized file + addDeps(normalizedFile) + } + } + } + } + } +} + +/** + * Overwrite the chunk code with CDN URLs + * + * @param chunk OutputChunk + * @returns string + */ +function overwriteChunkCode(chunk: OutputChunk, cdnDomainPlaceholder: string) { + let code = chunk.code + // Replace dependencies with CDN URLs + deps.forEach((dep) => { + if (code.includes(dep)) + code = code.replace(new RegExp(`"${dep}"`, 'g'), '`' + `${cdnDomainPlaceholder}` + `${config.base}` + `${dep}\``) + }) + + // Replace imported assets with CDN URLs + if (chunk?.viteMetadata?.importedAssets.size) { + // Map of CDN file names to their source content + const cdnSourceMap: Map = new Map() + + chunk.viteMetadata.importedAssets.forEach((asset) => { + if (code.includes(asset) && importedAssets.has(asset)) { + // Regular expression to match CSS URLs + const cssUrlRE = new RegExp( + `(?<=^|[^\\w\-\\u0080-\\uffff])url\\(\[\"|\'\]?\(${config.base}${asset}\(\\?t=\\d+\)?(#.*?)?\)\[\"|\'\]?\(?=\\\)|,|$)`, + ) + + // Replace CSS URLs with CDN URLs + if (cssUrlRE.test(code)) { + let match: RegExpExecArray | null + while ((match = cssUrlRE.exec(code))) { + const fileName = match[1] + const randomID = `${cdnMaker}${getRandomID()}` + cdnSourceMap.set(randomID, fileName) + code = code.replace(fileName, `${cdnDomainPlaceholder}${randomID}`) + } + } + + // Regular expression to match static source URLs + const staticSourceUrlRE = new RegExp(`${config.base}${asset}`) + // Replace static source URLs with CDN URLs + if (staticSourceUrlRE.test(code)) { + let match: RegExpExecArray | null + while ((match = staticSourceUrlRE.exec(code))) { + const fileName = match[0] + const randomID = `${cdnMaker}${getRandomID()}` + cdnSourceMap.set(randomID, fileName) + code = code.replace( + new RegExp(`\[\"|\'\]?${fileName}\[\"|\'\]?`), + `\`${cdnDomainPlaceholder}\` + '${randomID}'`, + ) + } + } + } + }) + + if (cdnSourceMap.size) { + cdnSourceMap.forEach((fileName, key) => { + code = code.replace(key, fileName) + }) + } + + cdnSourceMap.clear() + } + + return code +} + +/** + * Define the Runtime CDN plugin for Vite + * + * @returns PluginOption + */ +export function RuntimeCdnPlugin(options: Options = {}): PluginOption { + const { transformCssSourceURL = false, cdnDomainPlaceholder = '${window.cdn_domain || \'\'}' } = options + + if (!/^\${.*}$/.test(cdnDomainPlaceholder)) { + throw new Error( + `The 'cdnDomainPlaceholder' configuration only allows a format like '\${window.cdn_domain || ''}. You need to wrap it using \${}.`, + ) + } + + return { + name: `vite:runtime-cdn`, + + enforce: 'post', + + configResolved(resolvedConfig) { + config = resolvedConfig + }, + + transformIndexHtml: { + enforce: 'post', + transform(html, ctx) { + if (transformCssSourceURL) + setCssTags(ctx) + + return html + }, + }, + + transform(code, id) { + const transformResult = overwritePreloadHelper(code, id) + if (transformResult) + return transformResult + }, + + generateBundle(_, bundle) { + if (transformCssSourceURL) + setCssSource(bundle) + + setImportedAssets(bundle) + + if (transformCssSourceURL) + injectCssTojs(this, bundle) + + setDeps(bundle) + }, + + writeBundle(options, bundle) { + for (const file in bundle) { + const chunk = bundle[file] + if (chunk.type === 'chunk') { + const filePath = path.resolve(options.dir || '', chunk.fileName) + const code = overwriteChunkCode(chunk, cdnDomainPlaceholder) + fs.writeFileSync(filePath, code) + } + } + }, + } +}