+ ```
+
+- `all`: 查找 图片 和 媒体资源,即 `image` 和 `media` 的合集
+
+直接传入 **资源链接前缀** 或 **资源链接替换函数** 时,插件使用 `all` 规则替换资源链接。
+
+```ts title=".vuepress/config.ts"
+import { replaceAssetsPlugin } from '@vuepress/plugin-replace-assets'
+
+export default {
+ plugins: [
+ // replaceAssetsPlugin('https://cnd.example.com') // [!code hl]
+ replaceAssetsPlugin((url) => `https://cnd.example.com${url}`), // [!code ++]
+ ],
+}
+```
+
+也可以针对不同的内置规则,应用不同的资源链接前缀或资源链接替换函数:
+
+```ts title=".vuepress/config.ts"
+import { replaceAssetsPlugin } from '@vuepress/plugin-replace-assets'
+
+export default {
+ plugins: [
+ // replaceAssetsPlugin({ // [!code hl:4]
+ // image: 'https://image.cdn.com',
+ // media: 'https://media.cdn.com'
+ // }),
+ replaceAssetsPlugin({
+ // [!code ++:4]
+ image: (url) => `https://image.cdn.com${url}`,
+ media: (url) => `https://media.cdn.com${url}`,
+ }),
+ ],
+}
+```
+
+### 自定义资源匹配规则
+
+你也可以自定义资源匹配规则:
+
+```ts title=".vuepress/config.ts"
+import { replaceAssetsPlugin } from '@vuepress/plugin-replace-assets'
+
+export default {
+ plugins: [
+ replaceAssetsPlugin({
+ // [!code ++:4]
+ find: /^\/images\/.*\.(jpg|jpeg|png|gif|svg|webp|avif)$/,
+ replacement: (url) => `https://image.cdn.com${url}`,
+ }),
+ ],
+}
+```
+
+还可以自定义多个匹配规则:
+
+```ts title=".vuepress/config.ts"
+import { replaceAssetsPlugin } from '@vuepress/plugin-replace-assets'
+
+export default {
+ plugins: [
+ replaceAssetsPlugin([
+ // [!code ++:12]
+ // 查找图片资源
+ {
+ find: /^\/images\/.*\.(jpg|jpeg|png|gif|svg|webp|avif)$/,
+ replacement: 'https://image.cdn.com',
+ },
+ // 查找媒体资源
+ {
+ find: /^\/medias\/.*\.(mp4|webm|ogg|mp3|wav|flac|aac|m3u8|m3u|flv|pdf)$/,
+ replacement: (url) => `https://media.cdn.com${url}`,
+ },
+ ]),
+ ],
+}
+```
+
+**`find` 字段说明**
+
+`find` 字段用于匹配资源链接,可以是一个 **正则表达式** 或 **字符串**。
+
+当传入的是一个 `字符串` 时,如果是以 `^` 开头或者以 `$` 结尾的字符串,则会自动转换为一个 **正则表达式**。
+否则则会检查资源链接是否 以 `find` 结尾 或者 以 `find` 开头。
+
+```txt
+'^/images/foo.jpg' -> /^\/images\/foo.jpg/
+'/images/foo.jpg$' -> /^\/images\/foo.jpg$/
+```
+
+::: important 所有匹配的资源地址都是以 `/` 开头。
+:::
diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts
index d937ed0e42..fb7e4f8040 100644
--- a/e2e/docs/.vuepress/config.ts
+++ b/e2e/docs/.vuepress/config.ts
@@ -10,6 +10,7 @@ import { photoSwipePlugin } from '@vuepress/plugin-photo-swipe'
import { pwaPlugin } from '@vuepress/plugin-pwa'
import { redirectPlugin } from '@vuepress/plugin-redirect'
import { registerComponentsPlugin } from '@vuepress/plugin-register-components'
+import { replaceAssetsPlugin } from '@vuepress/plugin-replace-assets'
import { sassPalettePlugin } from '@vuepress/plugin-sass-palette'
import { watermarkPlugin } from '@vuepress/plugin-watermark'
import { defaultTheme } from '@vuepress/theme-default'
@@ -307,6 +308,10 @@ export default defineUserConfig({
componentsDir: path.resolve(__dirname, 'global-components/'),
componentsPatterns: ['**/*.vue', '**/*.ts', '**/*.js'],
}),
+ replaceAssetsPlugin({
+ find: /^\/images\/replace-assets\/.*\.(png|jpg|svg|gif|webp)$/,
+ replacement: 'https://cnd.example.com',
+ }),
sassPalettePlugin({
id: 'test',
defaultConfig: path.resolve(__dirname, './styles/default-config.scss'),
diff --git a/e2e/docs/replace-assets/README.md b/e2e/docs/replace-assets/README.md
new file mode 100644
index 0000000000..dabbbd7bb5
--- /dev/null
+++ b/e2e/docs/replace-assets/README.md
@@ -0,0 +1,32 @@
+# replace-assets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/e2e/package.json b/e2e/package.json
index ab8804f95b..09ead7f80b 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -26,6 +26,7 @@
"@vuepress/plugin-pwa": "workspace:*",
"@vuepress/plugin-redirect": "workspace:*",
"@vuepress/plugin-register-components": "workspace:*",
+ "@vuepress/plugin-replace-assets": "workspace:*",
"@vuepress/plugin-sass-palette": "workspace:*",
"@vuepress/plugin-theme-data": "workspace:*",
"@vuepress/plugin-watermark": "workspace:*",
diff --git a/e2e/tests/plugin-replace-assets/replace-assets.spec.ts b/e2e/tests/plugin-replace-assets/replace-assets.spec.ts
new file mode 100644
index 0000000000..fa1c61f6db
--- /dev/null
+++ b/e2e/tests/plugin-replace-assets/replace-assets.spec.ts
@@ -0,0 +1,26 @@
+import { expect, test } from '@playwright/test'
+
+test.describe('plugin-replace-assets', () => {
+ test('replace assets', async ({ page }) => {
+ await page.goto('replace-assets/')
+
+ const PREFIX = 'https://cnd.example.com/images/replace-assets/foo.'
+
+ const markdownImgList = page.locator('.markdown-syntax img')
+
+ await expect(markdownImgList.nth(0)).toHaveAttribute('src', `${PREFIX}png`)
+ await expect(markdownImgList.nth(1)).toHaveAttribute('src', `${PREFIX}jpg`)
+ await expect(markdownImgList.nth(2)).toHaveAttribute('src', `${PREFIX}gif`)
+
+ const elementImgList = page.locator('.element-syntax img')
+
+ await expect(elementImgList.nth(0)).toHaveAttribute('src', `${PREFIX}png`)
+ await expect(elementImgList.nth(1)).toHaveAttribute('src', `${PREFIX}jpg`)
+ await expect(elementImgList.nth(2)).toHaveAttribute('src', `${PREFIX}gif`)
+
+ await expect(page.locator('.append-img img').first()).toHaveAttribute(
+ 'src',
+ `${PREFIX}png`,
+ )
+ })
+})
diff --git a/plugins/tools/plugin-replace-assets/package.json b/plugins/tools/plugin-replace-assets/package.json
new file mode 100644
index 0000000000..c353a5118d
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@vuepress/plugin-replace-assets",
+ "version": "2.0.0-rc.110",
+ "description": "VuePress plugin - replace assets",
+ "keywords": [
+ "vuepress-plugin",
+ "vuepress",
+ "plugin",
+ "replace assets"
+ ],
+ "homepage": "https://ecosystem.vuejs.press/plugins/tools/replace-assets.html",
+ "bugs": {
+ "url": "https://github.com/vuepress/ecosystem/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vuepress/ecosystem.git",
+ "directory": "plugins/tools/plugin-replace-assets"
+ },
+ "license": "MIT",
+ "author": "pengzhanbo",
+ "maintainers": [
+ {
+ "name": "pengzhanbo",
+ "email": "volodymyr@foxmail.com"
+ }
+ ],
+ "type": "module",
+ "exports": {
+ ".": "./lib/node/index.js",
+ "./package.json": "./package.json"
+ },
+ "main": "./lib/node/index.js",
+ "types": "./lib/node/index.d.ts",
+ "files": [
+ "lib"
+ ],
+ "scripts": {
+ "build": "tsc -b tsconfig.build.json",
+ "bundle": "rollup -c rollup.config.ts --configPlugin esbuild",
+ "clean": "rimraf --glob ./lib ./*.tsbuildinfo"
+ },
+ "dependencies": {
+ "@vuepress/helper": "workspace:*",
+ "magic-string": "^0.30.17",
+ "unplugin": "^2.3.5"
+ },
+ "peerDependencies": {
+ "vuepress": "catalog:"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/plugins/tools/plugin-replace-assets/rollup.config.ts b/plugins/tools/plugin-replace-assets/rollup.config.ts
new file mode 100644
index 0000000000..160efea867
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/rollup.config.ts
@@ -0,0 +1,5 @@
+import { rollupBundle } from '../../../scripts/rollup.js'
+
+export default rollupBundle('node/index', {
+ external: ['magic-string', 'unplugin'],
+})
diff --git a/plugins/tools/plugin-replace-assets/src/node/constants.ts b/plugins/tools/plugin-replace-assets/src/node/constants.ts
new file mode 100644
index 0000000000..571ee6d9f8
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/src/node/constants.ts
@@ -0,0 +1,40 @@
+export const PLUGIN_NAME = '@vuepress/plugin-replace-assets'
+
+/**
+ * images extensions
+ */
+export const KNOWN_IMAGE_EXTENSIONS: string[] = [
+ 'apng',
+ 'bmp',
+ 'png',
+ 'jpe?g',
+ 'jfif',
+ 'pjpeg',
+ 'pjp',
+ 'gif',
+ 'svg',
+ 'ico',
+ 'webp',
+ 'avif',
+ 'cur',
+ 'jxl',
+]
+
+/**
+ * media extensions
+ */
+export const KNOWN_MEDIA_EXTENSIONS: string[] = [
+ 'mp4',
+ 'webm',
+ 'ogg',
+ 'mp3',
+ 'wav',
+ 'flac',
+ 'aac',
+ 'opus',
+ 'mov',
+ 'm4a',
+ 'vtt',
+
+ 'pdf',
+]
diff --git a/plugins/tools/plugin-replace-assets/src/node/index.ts b/plugins/tools/plugin-replace-assets/src/node/index.ts
new file mode 100644
index 0000000000..775f7041b8
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/src/node/index.ts
@@ -0,0 +1,5 @@
+export * from './replaceAssetsPlugin.js'
+export * from './normalizeRules.js'
+export * from './transformAssets.js'
+export * from './unplugin.js'
+export type * from './types.js'
diff --git a/plugins/tools/plugin-replace-assets/src/node/normalizeRules.ts b/plugins/tools/plugin-replace-assets/src/node/normalizeRules.ts
new file mode 100644
index 0000000000..b0531413db
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/src/node/normalizeRules.ts
@@ -0,0 +1,60 @@
+import { isArray, isFunction } from '@vuepress/helper'
+import { KNOWN_IMAGE_EXTENSIONS, KNOWN_MEDIA_EXTENSIONS } from './constants.js'
+import type { ReplaceAssetsPluginOptions, ReplacementRule } from './types.js'
+
+export const createFindPattern = (
+ dir: string,
+ extensions: string[],
+): RegExp => {
+ return new RegExp(`^/${dir}/.*\\.(?:${extensions.join('|')})(\\?.*)?$`)
+}
+
+/**
+ * Normalize replacement rules
+ */
+export const normalizeRules = (
+ options?: ReplaceAssetsPluginOptions,
+): ReplacementRule[] => {
+ const normalized: ReplacementRule[] = []
+
+ if (!options) return []
+
+ if (typeof options === 'string' || isFunction(options)) {
+ // eslint-disable-next-line no-param-reassign
+ options = {
+ all: options,
+ }
+ }
+
+ if (isArray(options)) {
+ normalized.push(...options)
+ return normalized
+ }
+
+ if ('find' in options) {
+ if (options.find && options.replacement) normalized.push(options)
+ return normalized
+ }
+
+ if (options.rules) {
+ normalized.push(
+ ...(isArray(options.rules) ? options.rules : [options.rules]),
+ )
+ }
+
+ if (options.image || options.all) {
+ normalized.push({
+ find: createFindPattern('images', KNOWN_IMAGE_EXTENSIONS),
+ replacement: options.image || options.all!,
+ })
+ }
+
+ if (options.media || options.all) {
+ normalized.push({
+ find: createFindPattern('medias', KNOWN_MEDIA_EXTENSIONS),
+ replacement: options.media || options.all!,
+ })
+ }
+
+ return normalized
+}
diff --git a/plugins/tools/plugin-replace-assets/src/node/replaceAssetsPlugin.ts b/plugins/tools/plugin-replace-assets/src/node/replaceAssetsPlugin.ts
new file mode 100644
index 0000000000..c4e41551b3
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/src/node/replaceAssetsPlugin.ts
@@ -0,0 +1,56 @@
+import { addViteConfig, configWebpack, getBundlerName } from '@vuepress/helper'
+import type { Plugin } from 'vuepress/core'
+import { PLUGIN_NAME } from './constants.js'
+import { normalizeRules } from './normalizeRules.js'
+import type { ReplaceAssetsPluginOptions } from './types.js'
+import {
+ createVitePluginReplaceAssets,
+ createWebpackPluginReplaceAssets,
+} from './unplugin.js'
+
+/**
+ * Plugin for replacing assets path
+ *
+ * 资源路径替换插件
+ *
+ * @example
+ * ```
+ * export default defineUserConfig({
+ * plugins: [
+ * replaceAssetsPlugin('https://cnd.example.com')
+ * ]
+ * })
+ * ```
+ */
+export const replaceAssetsPlugin = (
+ options?: ReplaceAssetsPluginOptions,
+): Plugin => {
+ const EMPTY_PLUGIN = { name: PLUGIN_NAME }
+
+ const rules = normalizeRules(options)
+
+ if (rules.length === 0) return EMPTY_PLUGIN
+
+ return {
+ ...EMPTY_PLUGIN,
+
+ extendsBundlerOptions(bundlerOptions, app) {
+ const bundle = getBundlerName(app)
+
+ if (bundle === 'vite') {
+ const replaceAssets = createVitePluginReplaceAssets()
+ addViteConfig(bundlerOptions, app, {
+ plugins: [replaceAssets(rules)],
+ })
+ }
+
+ if (bundle === 'webpack') {
+ configWebpack(bundlerOptions, app, (config) => {
+ config.plugins ??= []
+ const replaceAssets = createWebpackPluginReplaceAssets()
+ config.plugins.push(replaceAssets(rules))
+ })
+ }
+ },
+ }
+}
diff --git a/plugins/tools/plugin-replace-assets/src/node/transformAssets.ts b/plugins/tools/plugin-replace-assets/src/node/transformAssets.ts
new file mode 100644
index 0000000000..42b69a307f
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/src/node/transformAssets.ts
@@ -0,0 +1,90 @@
+import MagicString from 'magic-string'
+import type { ReplacementRule } from './types.js'
+import { normalizeUrl } from './utils.js'
+
+/**
+ * Check if url matches find
+ */
+export const isMatchUrl = (find: RegExp | string, url: string): boolean => {
+ if (typeof find === 'string') {
+ // like regexp string, start with `^` or end with `$`
+ if (find.startsWith('^') || find.endsWith('$')) {
+ return new RegExp(find).test(url)
+ }
+
+ return url.endsWith(find) || url.startsWith(find)
+ }
+
+ return find.test(url)
+}
+
+const cache = new Map()
+
+/**
+ * Replace asset with rules
+ *
+ * @example
+ * ```ts
+ * replacementAssetWithRules([{ find: '/foo/', replacement: 'https://example.com' }], '/foo/a.jpg')
+ * // -> 'https://example.com/foo/a.jpg'
+ * ```
+ */
+export const replacementAssetWithRules = (
+ rules: ReplacementRule[],
+ url: string,
+): string | void => {
+ if (cache.has(url)) return cache.get(url)
+
+ for (const { find, replacement } of rules) {
+ if (find && isMatchUrl(find, url)) {
+ const replaced =
+ typeof replacement === 'function'
+ ? normalizeUrl(replacement(url))
+ : normalizeUrl(url, replacement)
+
+ /* istanbul ignore if -- @preserve */
+ if (replaced) {
+ cache.set(url, replaced)
+ return replaced
+ }
+ }
+ }
+ return undefined
+}
+
+export const transformAssets = (
+ code: string,
+ pattern: RegExp,
+ rules: ReplacementRule[],
+): string => {
+ const s = new MagicString(code)
+ let matched: RegExpExecArray | null
+ let hasMatched = false
+ // eslint-disable-next-line no-cond-assign
+ while ((matched = pattern.exec(code))) {
+ const assetUrl =
+ matched[6] ||
+ matched[5] ||
+ matched[4] ||
+ matched[3] ||
+ matched[2] ||
+ matched[1]
+ const [left, right] = matched[0].startsWith('(')
+ ? ['("', '")']
+ : matched[0].startsWith('\\"')
+ ? ['\\"', '\\"']
+ : ['"', '"']
+
+ const start = matched.index
+ const end = start + matched[0].length
+ const resolved = replacementAssetWithRules(rules, assetUrl)
+ if (resolved) {
+ hasMatched = true
+ s.update(start, end, `${left}${resolved}${right}`)
+ }
+ }
+
+ if (!hasMatched) return code
+
+ return s.toString()
+}
diff --git a/plugins/tools/plugin-replace-assets/src/node/types.ts b/plugins/tools/plugin-replace-assets/src/node/types.ts
new file mode 100644
index 0000000000..c7f3f4b195
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/src/node/types.ts
@@ -0,0 +1,127 @@
+/**
+ * Assets Replacement Target Path
+ * - `string`: Directly concatenated before the original path
+ * - `(url) => string`: Custom replacement method, returns the new path
+ *
+ * 资源替换目标路径
+ * - `string`: 直接拼接在原始路径前面
+ * - `(url) => string`: 自定义替换方法,返回新路径
+ */
+export type Replacement = string | ((url: string) => string)
+
+/**
+ * Assets Replacement Rule
+ *
+ * 资源替换规则
+ */
+export interface ReplacementRule {
+ /**
+ * Assets Matching
+ *
+ * - `RegExp`: Match using regular expression
+ * - `string`: Match using string
+ * - Strings starting with `^` or ending with `$` are automatically converted to regular expressions
+ * - For ordinary strings, checks if they appear at the start or end
+ *
+ * 资源匹配
+ *
+ * - `RegExp`: 匹配正则
+ * - `string`: 匹配字符串
+ * - 以 `^` 开头或以 `$` 结尾的字符串,会自动转换为正则
+ * - 普通字符串检查是否以其作为开头或结尾
+ *
+ * @example
+ * ```
+ * '^/foo/'
+ * '.jpg$'
+ * /\.jpg$/
+ * ```
+ */
+ find: RegExp | string
+
+ /**
+ * Assets Replacement Target Path
+ * - `string`: Directly concatenated before the original path
+ * - `(url) => string`: Custom replacement method, returns the new path
+ *
+ * 资源替换目标路径
+ * - `string`: 直接拼接在原始路径前面
+ * - `(url) => string`: 自定义替换方法,返回新路径
+ *
+ * @example
+ * ```
+ * 'https://example.com'
+ * (url) => `https://example.com${url}`
+ * ```
+ */
+ replacement: Replacement
+}
+
+export interface ReplaceAssetsOptions {
+ /**
+ * Custom Assets Replacement Rules
+ *
+ * 自定义资源替换规则
+ *
+ * @example
+ * ```ts
+ * {
+ * rules: [{
+ * find: /^\/images\/.*\.(jpe?g|png|gif|svg)$/,
+ * replacement: 'https://cdn.example.com/images/',
+ * }]
+ * }
+ * ```
+ */
+ rules?: ReplacementRule | ReplacementRule[]
+ /**
+ * Built-in image matching rules, designed to match and find common image paths starting with `^/images/`
+ *
+ * 内置的图片匹配规则,匹配查找 `^/images/` 开头的常见的图片路径
+ *
+ * @example
+ * ```
+ * {
+ * image: 'https://example.com'
+ * }
+ * ```
+ */
+ image?: Replacement
+ /**
+ * Built-in media matching rules, designed to match and locate common media paths such as videos and audio that start with `^/medias/`.
+ *
+ * 内置的媒体匹配规则,匹配查找 `^/medias/` 开头的常见的视频、音频等媒体路径
+ *
+ * @example
+ * ```
+ * {
+ * media: 'https://example.com'
+ * }
+ * ```
+ */
+ media?: Replacement
+ /**
+ * Equivalent to setting both {@link ReplaceAssetsOptions.image image} and {@link ReplaceAssetsOptions.media media} simultaneously.
+ *
+ * 相当于同时设置 {@link ReplaceAssetsOptions.image image} 和 {@link ReplaceAssetsOptions.media media}
+ *
+ * @example
+ * ```
+ * {
+ * all: 'https://example.com'
+ * }
+ * ```
+ */
+ all?: Replacement
+}
+
+/**
+ * Assets Replacement Plugin Options
+ *
+ * 资源替换插件配置项
+ */
+export type ReplaceAssetsPluginOptions =
+ | ReplaceAssetsOptions
+ | Replacement
+ | ReplacementRule
+ | ReplacementRule[]
diff --git a/plugins/tools/plugin-replace-assets/src/node/unplugin.ts b/plugins/tools/plugin-replace-assets/src/node/unplugin.ts
new file mode 100644
index 0000000000..197e1457c8
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/src/node/unplugin.ts
@@ -0,0 +1,31 @@
+import type {
+ UnpluginFactory,
+ VitePlugin,
+ WebpackPluginInstance,
+} from 'unplugin'
+import { createVitePlugin, createWebpackPlugin } from 'unplugin'
+import { transformAssets } from './transformAssets.js'
+import type { ReplacementRule } from './types.js'
+import { createAssetPattern } from './utils.js'
+
+const replaceAssetsFactory: UnpluginFactory = (rules) => {
+ const pattern = createAssetPattern('/[^/]')
+ return {
+ name: 'vuepress:replace-assets',
+ enforce: 'pre',
+ transform: {
+ filter: { id: { exclude: [/\.json(?:$|\?)/, /\.html?$/] } },
+ handler(code) {
+ return transformAssets(code, pattern, rules)
+ },
+ },
+ }
+}
+
+export const createVitePluginReplaceAssets: () => (
+ options: ReplacementRule[],
+) => VitePlugin | VitePlugin[] = () => createVitePlugin(replaceAssetsFactory)
+
+export const createWebpackPluginReplaceAssets: () => (
+ options: ReplacementRule[],
+) => WebpackPluginInstance = () => createWebpackPlugin(replaceAssetsFactory)
diff --git a/plugins/tools/plugin-replace-assets/src/node/utils.ts b/plugins/tools/plugin-replace-assets/src/node/utils.ts
new file mode 100644
index 0000000000..a958547d87
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/src/node/utils.ts
@@ -0,0 +1,34 @@
+import { removeEndingSlash, removeLeadingSlash } from '@vuepress/helper'
+
+export const createAssetPattern = (prefix: string): RegExp => {
+ const s = `(${prefix}.*?)`
+ return new RegExp(
+ [
+ `(?:"${s}")`, // "prefix"
+ `(?:'${s}')`, // 'prefix'
+ `(?:\\(${s}\\))`, // (prefix)
+ `(?:\\('${s}'\\))`, // ('prefix')
+ `(?:\\("${s}"\\))`, // ("prefix")
+ `(?:\\\\"${s}\\\\")`, // \"prefix\"
+ ].join('|'),
+ 'gu',
+ )
+}
+
+/**
+ * Normalize url
+ *
+ * @example
+ * ```ts
+ * normalizeUrl('/bar', '/foo/') // -> /foo/bar
+ * ```
+ */
+export const normalizeUrl = (url: string, base?: string): string => {
+ if (!url) return ''
+
+ if (base) {
+ return `${removeEndingSlash(base)}/${removeLeadingSlash(url)}`
+ }
+
+ return url
+}
diff --git a/plugins/tools/plugin-replace-assets/tests/__snapshots__/transformAssets.spec.ts.snap b/plugins/tools/plugin-replace-assets/tests/__snapshots__/transformAssets.spec.ts.snap
new file mode 100644
index 0000000000..3cbca0a780
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/tests/__snapshots__/transformAssets.spec.ts.snap
@@ -0,0 +1,51 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`plugin-replace-assets > transformAssets > should work with like css 1`] = `
+".foo {
+ background-image: url("https://example.com/assets/images/foo.jpg");
+ background-image: url("https://example.com/assets/images/foo.png");
+ background-image: url("https://example.com/assets/images/foo.gif");
+ background-image: url("https://example.com/assets/images/foo.svg");
+ background-image: url("https://example.com/assets/medias/foo.mp4");
+
+ background-image: url("https://example.com/assets/images/foo.jpg");
+ background-image: url("https://example.com/assets/images/foo.png");
+
+ background-image: url("https://example.com/assets/images/foo.jpg?a=1");
+
+ background-image: url("https://not-replace.com/images/foo.jpg");
+
+ background: url("https://example.com/assets/images/foo.png");
+
+}
+"
+`;
+
+exports[`plugin-replace-assets > transformAssets > should work with like html 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`plugin-replace-assets > transformAssets > should work with like js 1`] = `
+" const a = "https://example.com/assets/images/foo.jpg"
+ const b = "https://example.com/assets/images/foo.jpg"
+ const c = "https://example.com/assets/images/foo.jpg?a=1"
+ const d = "https://not-replace.com/images/foo.jpg"
+
+ const json_string = JSON.parse("{\\"a\\":\\"https://example.com/assets/images/foo.jpg\\"}")
+"
+`;
diff --git a/plugins/tools/plugin-replace-assets/tests/normalizeRules.spec.ts b/plugins/tools/plugin-replace-assets/tests/normalizeRules.spec.ts
new file mode 100644
index 0000000000..28687cbccb
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/tests/normalizeRules.spec.ts
@@ -0,0 +1,150 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ KNOWN_IMAGE_EXTENSIONS,
+ KNOWN_MEDIA_EXTENSIONS,
+} from '../src/node/constants.js'
+import {
+ createFindPattern,
+ normalizeRules,
+} from '../src/node/normalizeRules.js'
+
+describe('plugin-replace-assets > normalizeRules', () => {
+ it('should work with empty options', () => {
+ expect(normalizeRules('')).toEqual([])
+ expect(normalizeRules([])).toEqual([])
+ expect(normalizeRules({})).toEqual([])
+ expect(normalizeRules({ rules: [] })).toEqual([])
+ })
+
+ it('should work with string', () => {
+ const rules = normalizeRules('https://example.com/assets/')
+
+ expect(rules).toEqual([
+ {
+ find: createFindPattern('images', KNOWN_IMAGE_EXTENSIONS),
+ replacement: 'https://example.com/assets/',
+ },
+ {
+ find: createFindPattern('medias', KNOWN_MEDIA_EXTENSIONS),
+ replacement: 'https://example.com/assets/',
+ },
+ ])
+ })
+
+ it('should work with function', () => {
+ const replacement = vi.fn(
+ (url: string) => `https://example.com/assets/${url}`,
+ )
+ const rules = normalizeRules(replacement)
+
+ expect(rules).toEqual([
+ {
+ find: createFindPattern('images', KNOWN_IMAGE_EXTENSIONS),
+ replacement,
+ },
+ {
+ find: createFindPattern('medias', KNOWN_MEDIA_EXTENSIONS),
+ replacement,
+ },
+ ])
+ })
+
+ it('should work with single rule', () => {
+ const rules = normalizeRules({
+ find: '^/images/.*\\.(jpe?g|png|gif|svg)$',
+ replacement: 'https://example.com/images/',
+ })
+
+ expect(rules).toEqual([
+ {
+ find: '^/images/.*\\.(jpe?g|png|gif|svg)$',
+ replacement: 'https://example.com/images/',
+ },
+ ])
+ })
+
+ it('should work with multiple rules', () => {
+ const rules = normalizeRules([
+ {
+ find: '^/images/.*\\.(jpe?g|png|gif|svg)$',
+ replacement: 'https://example.com/images/',
+ },
+ {
+ find: '^/medias/.*\\.(mp4|ogg|ogv|webm)$',
+ replacement: 'https://example.com/medias/',
+ },
+ ])
+
+ expect(rules).toEqual([
+ {
+ find: '^/images/.*\\.(jpe?g|png|gif|svg)$',
+ replacement: 'https://example.com/images/',
+ },
+ {
+ find: '^/medias/.*\\.(mp4|ogg|ogv|webm)$',
+ replacement: 'https://example.com/medias/',
+ },
+ ])
+ })
+
+ it('should work with presets', () => {
+ const media = vi.fn((url: string) => `https://example.com/medias/${url}`)
+ const rules = normalizeRules({
+ image: 'https://example.com/images/',
+ media,
+ })
+
+ expect(rules).toEqual([
+ {
+ find: createFindPattern('images', KNOWN_IMAGE_EXTENSIONS),
+ replacement: 'https://example.com/images/',
+ },
+ {
+ find: createFindPattern('medias', KNOWN_MEDIA_EXTENSIONS),
+ replacement: media,
+ },
+ ])
+ })
+
+ it('should work with custom single rule', () => {
+ const rules = normalizeRules({
+ rules: {
+ find: '^/images/.*\\.(jpe?g|png|gif|svg)$',
+ replacement: 'https://example.com/images/',
+ },
+ })
+
+ expect(rules).toEqual([
+ {
+ find: '^/images/.*\\.(jpe?g|png|gif|svg)$',
+ replacement: 'https://example.com/images/',
+ },
+ ])
+ })
+
+ it('should work with custom multiple rules', () => {
+ const rules = normalizeRules({
+ rules: [
+ {
+ find: '^/images/.*\\.(jpe?g|png|gif|svg)$',
+ replacement: 'https://example.com/images/',
+ },
+ {
+ find: '^/medias/.*\\.(mp4|ogg|ogv|webm)$',
+ replacement: 'https://example.com/medias/',
+ },
+ ],
+ })
+
+ expect(rules).toEqual([
+ {
+ find: '^/images/.*\\.(jpe?g|png|gif|svg)$',
+ replacement: 'https://example.com/images/',
+ },
+ {
+ find: '^/medias/.*\\.(mp4|ogg|ogv|webm)$',
+ replacement: 'https://example.com/medias/',
+ },
+ ])
+ })
+})
diff --git a/plugins/tools/plugin-replace-assets/tests/transformAssets.spec.ts b/plugins/tools/plugin-replace-assets/tests/transformAssets.spec.ts
new file mode 100644
index 0000000000..d5242728d7
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/tests/transformAssets.spec.ts
@@ -0,0 +1,257 @@
+import { describe, expect, it, vi } from 'vitest'
+import { normalizeRules } from '../src/node/normalizeRules.js'
+import {
+ isMatchUrl,
+ replacementAssetWithRules,
+ transformAssets,
+} from '../src/node/transformAssets.js'
+import { createAssetPattern } from '../src/node/utils.js'
+
+describe('plugin-replace-assets > isMatchUrl', () => {
+ it.each([
+ {
+ name: 'string like regexp with ^ and $',
+ find: '^/images/.*\\.(jpe?g|png|gif|svg)(\\?.*)?$',
+ expects: [
+ ['/images/foo.jpg', true],
+ ['/images/foo.png', true],
+ ['/images/foo.gif', true],
+ ['/images/foo.svg', true],
+ ['/images/foo.jpg?a=1', true],
+ ['/images/foo.txt', false],
+ ['/medias/foo.mp4', false],
+ ] as const,
+ },
+ {
+ name: 'string like regexp start with ^',
+ find: '^/medias/',
+ expects: [
+ ['/medias/foo.mp4', true],
+ ['/medias/foo.ogg', true],
+ ['/medias/foo.ogv', true],
+ ['/medias/foo.webm', true],
+ ['/images/foo.jpg', false],
+ ] as const,
+ },
+ {
+ name: 'string like regexp end with $',
+ find: '\\.(jpe?g|png|gif|svg)$',
+ expects: [
+ ['/images/foo.jpg', true],
+ ['/images/foo.png', true],
+ ['/images/foo.gif', true],
+ ['/images/foo.svg', true],
+ ['/images/foo.txt', false],
+ ['/medias/foo.mp4', false],
+ ] as const,
+ },
+ {
+ name: 'string start width pathname',
+ find: '/images/',
+ expects: [
+ ['/images/foo.jpg', true],
+ ['/images/foo.png', true],
+ ['/images/foo.gif', true],
+ ['/images/foo', true],
+ ['/medias/foo.mp4', false],
+ ] as const,
+ },
+ {
+ name: 'string end width extension',
+ find: '.jpg',
+ expects: [
+ ['/images/foo.jpg', true],
+ ['/images/foo.png', false],
+ ['/images/foo.gif', false],
+ ['/images/foo', false],
+ ['/medias/foo.mp4', false],
+ ] as const,
+ },
+ {
+ name: 'regexp',
+ find: /^\/images\/.*\.(jpe?g|png|gif|svg)$/,
+ expects: [
+ ['/images/foo.jpg', true],
+ ['/images/foo.png', true],
+ ['/images/foo.gif', true],
+ ['/images/foo.svg', true],
+ ['/images/foo.txt', false],
+ ['/medias/foo.mp4', false],
+ ] as const,
+ },
+ ])('$name', ({ find, expects }) => {
+ for (const [url, expected] of expects) {
+ expect(isMatchUrl(find, url)).toBe(expected)
+ }
+ })
+})
+
+describe('plugin-replace-assets > replacementAssetWithRules', () => {
+ const IMAGE_SUPPORTED = [
+ 'apng',
+ 'bmp',
+ 'png',
+ 'jpg',
+ 'jpeg',
+ 'jfif',
+ 'pjpeg',
+ 'pjp',
+ 'gif',
+ 'svg',
+ 'ico',
+ 'webp',
+ 'avif',
+ 'cur',
+ 'jxl',
+ ]
+ const MEDIA_SUPPORTED = [
+ 'mp4',
+ 'webm',
+ 'ogg',
+ 'mp3',
+ 'wav',
+ 'flac',
+ 'aac',
+ 'opus',
+ 'mov',
+ 'm4a',
+ 'vtt',
+ 'pdf',
+ ]
+ const replacementFn = vi.fn((url) => `https://example.com/assets${url}`)
+
+ it.each([
+ {
+ name: 'string replacement',
+ rules: normalizeRules('https://example.com/assets/'),
+ expects: [
+ // images
+ ...IMAGE_SUPPORTED.map((ext) => [
+ `/images/foo.${ext}`,
+ `https://example.com/assets/images/foo.${ext}`,
+ ]),
+ // media
+ ...MEDIA_SUPPORTED.map((ext) => [
+ `/medias/foo.${ext}`,
+ `https://example.com/assets/medias/foo.${ext}`,
+ ]),
+ // have query string
+ [
+ '/images/foo.jpg?a=1',
+ 'https://example.com/assets/images/foo.jpg?a=1',
+ ],
+ // cached images
+ ['/images/foo.jpg', 'https://example.com/assets/images/foo.jpg'],
+ // no supported
+ ['/images/foo.txt', undefined],
+ ['/medias/foo', undefined],
+ ] as const,
+ },
+ {
+ name: 'function replacement',
+ rules: normalizeRules(replacementFn),
+ expects: [
+ // images
+ ...IMAGE_SUPPORTED.map((ext) => [
+ `/images/bar.${ext}`,
+ `https://example.com/assets/images/bar.${ext}`,
+ ]),
+ // media
+ ...MEDIA_SUPPORTED.map((ext) => [
+ `/medias/bar.${ext}`,
+ `https://example.com/assets/medias/bar.${ext}`,
+ ]),
+ // have query string
+ [
+ '/images/bar.jpg?a=1',
+ 'https://example.com/assets/images/bar.jpg?a=1',
+ ],
+ // cached images
+ ['/images/bar.jpg', 'https://example.com/assets/images/bar.jpg'],
+ // no supported
+ ['/images/bar.txt', undefined],
+ ['/medias/bar', undefined],
+ ] as const,
+ },
+ ])('$name', ({ name, rules, expects }) => {
+ for (const [url, expected] of expects) {
+ expect(replacementAssetWithRules(rules, url)).toBe(expected)
+ }
+ if (name === 'function replacement') {
+ // should not called with cached, and not called with no supported
+ expect(replacementFn).toBeCalledTimes(expects.length - 3)
+ }
+ })
+})
+
+describe('plugin-replace-assets > transformAssets', () => {
+ const rules = normalizeRules('https://example.com/assets/')
+ const pattern = createAssetPattern('/[^/]')
+
+ it('should work with like html', () => {
+ const source = `\
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+ expect(transformAssets(source, pattern, rules)).toMatchSnapshot()
+ })
+
+ it('should work with like css', () => {
+ const source = `\
+.foo {
+ background-image: url("/images/foo.jpg");
+ background-image: url("/images/foo.png");
+ background-image: url("/images/foo.gif");
+ background-image: url("/images/foo.svg");
+ background-image: url("/medias/foo.mp4");
+
+ background-image: url('/images/foo.jpg');
+ background-image: url(/images/foo.png);
+
+ background-image: url("/images/foo.jpg?a=1");
+
+ background-image: url("https://not-replace.com/images/foo.jpg");
+
+ background: url('/images/foo.png');
+
+}
+`
+ expect(transformAssets(source, pattern, rules)).toMatchSnapshot()
+ })
+
+ it('should work with like js', () => {
+ const source = `\
+ const a = "/images/foo.jpg"
+ const b = '/images/foo.jpg'
+ const c = '/images/foo.jpg?a=1'
+ const d = "https://not-replace.com/images/foo.jpg"
+
+ const json_string = JSON.parse("{\\"a\\":\\"/images/foo.jpg\\"}")
+`
+
+ expect(transformAssets(source, pattern, rules)).toMatchSnapshot()
+ })
+
+ it('should work with no match', () => {
+ const source = `\
+
+
+
+const a = "images/foo.jpg"
+`
+ expect(transformAssets(source, pattern, rules)).toBe(source)
+ })
+})
diff --git a/plugins/tools/plugin-replace-assets/tests/utils.spec.ts b/plugins/tools/plugin-replace-assets/tests/utils.spec.ts
new file mode 100644
index 0000000000..a0b0d117f1
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/tests/utils.spec.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { createAssetPattern, normalizeUrl } from '../src/node/utils.js'
+
+describe('plugin-replace-assets > utils', () => {
+ it('createAssetPattern', () => {
+ expect(createAssetPattern('/[^/]').test(`'/images/foo.jpg'`)).toBe(true)
+ expect(createAssetPattern('/[^/]').test(`"/images/foo.jpg"`)).toBe(true)
+ expect(createAssetPattern('/[^/]').test(`(/images/foo.jpg)`)).toBe(true)
+ expect(createAssetPattern('/[^/]').test(`('/images/foo.jpg')`)).toBe(true)
+ expect(createAssetPattern('/[^/]').test(`("/images/foo.jpg")`)).toBe(true)
+ expect(createAssetPattern('/[^/]').test(`"/images/foo.jpg?a=1"`)).toBe(true)
+
+ expect(
+ createAssetPattern('/[^/]').test(`'https://example.com/images/foo.jpg'`),
+ ).toBe(false)
+ expect(createAssetPattern('/[^/]').test(`"./images/foo.jpg"`)).toBe(false)
+ expect(createAssetPattern('/[^/]').test(`"images/foo.jpg"`)).toBe(false)
+ })
+
+ it('normalizeUrl', () => {
+ expect(normalizeUrl('')).toBe('')
+ expect(normalizeUrl('/images/foo.jpg')).toBe('/images/foo.jpg')
+ expect(normalizeUrl('/images/foo.jpg?a=1')).toBe('/images/foo.jpg?a=1')
+ expect(normalizeUrl('/images/foo.jpg', 'https://example.com/')).toBe(
+ 'https://example.com/images/foo.jpg',
+ )
+ expect(normalizeUrl('/images/foo.jpg?a=1', 'https://example.com/')).toBe(
+ 'https://example.com/images/foo.jpg?a=1',
+ )
+ })
+})
diff --git a/plugins/tools/plugin-replace-assets/tsconfig.build.json b/plugins/tools/plugin-replace-assets/tsconfig.build.json
new file mode 100644
index 0000000000..1e7fd0d655
--- /dev/null
+++ b/plugins/tools/plugin-replace-assets/tsconfig.build.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../tsconfig.build.json",
+ "compilerOptions": {
+ "rootDir": "./src",
+ "outDir": "./lib"
+ },
+ "include": ["./src"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f6ad33a2f6..35ba28ab50 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -305,6 +305,9 @@ importers:
'@vuepress/plugin-register-components':
specifier: workspace:*
version: link:../plugins/tools/plugin-register-components
+ '@vuepress/plugin-replace-assets':
+ specifier: workspace:*
+ version: link:../plugins/tools/plugin-replace-assets
'@vuepress/plugin-sass-palette':
specifier: workspace:*
version: link:../plugins/development/plugin-sass-palette
@@ -1350,6 +1353,21 @@ importers:
specifier: ^4.0.9
version: 4.0.9
+ plugins/tools/plugin-replace-assets:
+ dependencies:
+ '@vuepress/helper':
+ specifier: workspace:*
+ version: link:../../../tools/helper
+ magic-string:
+ specifier: ^0.30.17
+ version: 0.30.17
+ unplugin:
+ specifier: ^2.3.5
+ version: 2.3.5
+ vuepress:
+ specifier: 'catalog:'
+ version: 2.0.0-rc.23(@vuepress/bundler-vite@2.0.0-rc.23(@types/node@24.0.3)(jiti@2.4.2)(lightningcss@1.30.1)(sass-embedded@1.89.2)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(@vuepress/bundler-webpack@2.0.0-rc.23(esbuild@0.25.5)(typescript@5.8.3))(typescript@5.8.3)(vue@3.5.17(typescript@5.8.3))
+
themes/theme-default:
dependencies:
'@vuepress/helper':
@@ -9087,6 +9105,10 @@ packages:
resolution: {integrity: sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==}
engines: {node: '>=18.12.0'}
+ unplugin@2.3.5:
+ resolution: {integrity: sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==}
+ engines: {node: '>=18.12.0'}
+
unrs-resolver@1.9.0:
resolution: {integrity: sha512-wqaRu4UnzBD2ABTC1kLfBjAqIDZ5YUTr/MLGa7By47JV1bJDSW7jq/ZSLigB7enLe7ubNaJhtnBXgrc/50cEhg==}
@@ -9357,6 +9379,9 @@ packages:
resolution: {integrity: sha512-YtFduE7cIRW2v/Ofqmac68UNLk8C8ZnwQTBuClynJfGlMn4LgcDo5utFAx+Y5f8k5aG+yreUmEdXxwTsb3zxYQ==}
engines: {node: '>=10.13.0'}
+ webpack-virtual-modules@0.6.2:
+ resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+
webpack@5.99.9:
resolution: {integrity: sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==}
engines: {node: '>=10.13.0'}
@@ -18222,6 +18247,12 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.2
+ unplugin@2.3.5:
+ dependencies:
+ acorn: 8.15.0
+ picomatch: 4.0.2
+ webpack-virtual-modules: 0.6.2
+
unrs-resolver@1.9.0:
dependencies:
napi-postinstall: 0.2.4
@@ -18544,6 +18575,8 @@ snapshots:
deepmerge: 4.3.1
javascript-stringify: 2.1.0
+ webpack-virtual-modules@0.6.2: {}
+
webpack@5.99.9(esbuild@0.25.5):
dependencies:
'@types/eslint-scope': 3.7.7
diff --git a/tsconfig.build.json b/tsconfig.build.json
index f3417f7da0..d3c898b2f2 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -108,6 +108,7 @@
"path": "./plugins/tools/plugin-register-components/tsconfig.build.json"
},
{ "path": "./plugins/tools/plugin-cache/tsconfig.build.json" },
+ { "path": "./plugins/tools/plugin-replace-assets/tsconfig.build.json" },
// themes
{ "path": "./themes/theme-default/tsconfig.build.json" },