import path from 'node:path'
import fs from 'fs-extra'
import semver from 'semver'
import { installPackageTemporary } from '../vite-plugins/installPackageTemporary.js'
import { copyPlugin } from '../vite-plugins/cdnCopyPlugin.js'
import { dedupeCopyFiles } from './locateCdnNpmInfo.js'
const logger = console
const defaultCopyConfig = {
'@opentiny/vue-theme': {
filePathInPackage: '/'
},
'@opentiny/vue-renderless': {
filePathInPackage: '/'
},
'@opentiny/vue-runtime': {
filePathInPackage: '/dist3/'
},
'@vue/devtools-api': {
filePathInPackage: '/'
}
}
* 从importMapUrl字符串中提取包名、版本和文件路径
* ${versionDelimiter} 和 ${fileDelimiter} 是默认的 importMapUrl 中的占位符,
* 例如:
* importMapUrl = '${VITE_CDN_DOMAIN}/${packageName}${versionDelimiter}${version}${fileDelimiter}${filePath}'
* 提取的信息对象:
* {
* packageName: '${packageName}',
* version: '${version}',
* filePathInPackage: '${filePath}'
* }
* @param {string} str - 导入字符串
* @returns {Object} - 提取的信息对象
* @returns {string} packageName - 包名
* @returns {string} version - 版本
* @returns {string} filePathInPackage - 包内文件路径
* */
function extractInfo(str) {
try {
let [packageName, versionAndPath] = str.split('${versionDelimiter}')
packageName = packageName.replace(/^\$\{VITE_CDN_DOMAIN\}\//, '')
const [version, filePath] = versionAndPath.split('${fileDelimiter}')
return {
packageName,
version,
filePathInPackage: filePath || '/'
}
} catch (error) {
logger.error(`[import-map-local-plugin]: Failed to extract info from ${str} 提取 importMap 信息失败`, error)
}
}
* 比较两个版本号是否相同
* @param {string} versionOrigin - 源版本号, 可能包含 ^ 或 ~ 开头
* @param {string} versionTarget - 目标版本号,来源于 package.json 的 version, 不能包含 ^ 或 ~ 开头
* @returns {boolean} - 是否相同
*/
const compareIsSameVersion = (versionOrigin, versionTarget) => {
if (versionOrigin === versionTarget) {
return true
}
return semver.satisfies(versionTarget, versionOrigin)
}
function getCdnPathNpmInfo(
cdnDependencyItem,
base,
cdnDir,
tempDir = 'bundle-deps',
copyConfig = {}
) {
let { packageName, version, filePathInPackage } = cdnDependencyItem
const originVersion = version
if (copyConfig[packageName]) {
const { version: copyVersion, filePathInPackage: copyFilePathInPackage } = copyConfig[packageName]
if (copyVersion) {
version = copyVersion
}
if (copyFilePathInPackage) {
filePathInPackage = copyFilePathInPackage
}
}
let isFolder = filePathInPackage.endsWith('/')
let src = `node_modules/${packageName}${filePathInPackage}`
const pkgFilePath = `node_modules/${packageName}/package.json`
let isSameVersion = false
if (fs.existsSync(pkgFilePath)) {
try {
const pkg = JSON.parse(fs.readFileSync(path.resolve(pkgFilePath)))
isSameVersion = compareIsSameVersion(version, pkg.version)
} catch (error) {
}
}
const sourceExist = fs.existsSync(path.resolve(src)) && isSameVersion
if (sourceExist) {
const stat = fs.statSync(path.resolve(src))
if (stat.isDirectory()) {
isFolder = true
}
} else {
src = tempDir + '/' + src
}
const destPackageDir = `${cdnDir}/${packageName}@${originVersion}`
const destFullPath = `${destPackageDir}${filePathInPackage}`
const destFullPathWithoutTailSlash = destFullPath
const dest = destFullPathWithoutTailSlash
let destDir = dest
if (!isFolder) {
destDir = path.dirname(destFullPathWithoutTailSlash)
}
return {
src,
packageName,
version,
sourceExist,
dest: destDir
}
}
function parseImportMapLocalConfig(importMapLocalConfig) {
let parsedImportMapLocalConfig = importMapLocalConfig
if (!parsedImportMapLocalConfig || typeof parsedImportMapLocalConfig !== 'object') {
logger.warn('[import-map-local-plugin]: Invalid importMapLocalConfig, using defaults')
parsedImportMapLocalConfig = { importMap: { imports: {} }, copy: {} }
}
if (!parsedImportMapLocalConfig.importMap || typeof parsedImportMapLocalConfig.importMap !== 'object') {
logger.warn('[import-map-local-plugin]: Invalid importMapConfig, using defaults')
parsedImportMapLocalConfig.importMap = { imports: {} }
}
if (!parsedImportMapLocalConfig.copy || typeof parsedImportMapLocalConfig.copy !== 'object') {
logger.warn('[import-map-local-plugin]: Invalid copyConfig, using defaults')
parsedImportMapLocalConfig.copy = {}
}
return parsedImportMapLocalConfig
}
* 本地化CDN插件
* @param {Object} options - 配置选项
* @param {Object} options.importMapLocalConfig - 本地CDN配置
* @param {Object} options.importMapLocalConfig.importMap - 导入映射配置,定义需要本地化的CDN依赖
* @param {Object} options.importMapLocalConfig.copy - 自定义复制配置,可以覆盖特定包的默认配置
* @param {string} options.base - 构建的base URL
* @param {string} options.cdnDir - 构建目录中的CDN文件夹名称
* @param {string} options.bundleTempDir - 临时存放下载的包的目录
* @returns {Array} - Vite插件数组
*/
export function importMapLocalPlugin({
importMapLocalConfig = { importMap: { imports: {} }, copy: {} },
base = './',
cdnDir = 'local-cdn-static', // 构建目录中的CDN文件夹名称
bundleTempDir = 'bundle-deps/local-cdn' // 临时存放下载的包的目录
}) {
let parsedImportMapLocalConfig = parseImportMapLocalConfig(importMapLocalConfig)
const copyConfig = parsedImportMapLocalConfig.copy || {}
const defaultImportMapConfig = JSON.parse(
fs.readFileSync(path.resolve(process.cwd(), './node_modules/@opentiny/tiny-engine/dist/import-map.json'), 'utf-8')
)
const parsedDefaultImportMapConfig = Object.values(defaultImportMapConfig.imports)
.map((item) => extractInfo(item))
.filter(Boolean)
const parsedImportMapConfig = Object.values(parsedImportMapLocalConfig.importMap.imports)
.map((item) => extractInfo(item))
.filter(Boolean)
const parsedImportMapStylesConfig = Object.values(defaultImportMapConfig.importStyles || {})
.map((item) => extractInfo(item))
.filter(Boolean)
const overriddenImportMap = parsedDefaultImportMapConfig.filter((item) => {
return !parsedImportMapConfig.find((parsedItem) => parsedItem.packageName === item.packageName)
})
const combinedImportMapConfig = [...overriddenImportMap, ...parsedImportMapConfig, ...parsedImportMapStylesConfig]
if (combinedImportMapConfig.length === 0) {
logger.warn('[import-map-local-plugin]: No CDN dependencies found or configured')
return []
}
const combinedCopyConfig = { ...defaultCopyConfig, ...copyConfig }
const cdnFiles = combinedImportMapConfig.map((cdnDependencyItem) =>
getCdnPathNpmInfo(cdnDependencyItem, base, cdnDir, bundleTempDir, combinedCopyConfig)
)
const packageNeedToInstall = cdnFiles
.filter((item) => !item.sourceExist)
.map(({ packageName, version }) => ({ packageName, version }))
.reduce((acc, cur) => {
if (!acc.some(({ packageName }) => cur.packageName === packageName)) {
acc.push(cur)
}
return acc
}, [])
logger.log(
`[import-map-local-plugin]: Processing ${combinedImportMapConfig.length} CDN dependencies to local directory: ${cdnDir}`
)
logger.log(`[import-map-local-plugin]: Need to install ${packageNeedToInstall.length} packages`)
const targetFiles = dedupeCopyFiles(cdnFiles)
return [
...installPackageTemporary(packageNeedToInstall, bundleTempDir),
copyPlugin(targetFiles)
]
}