@epxing/icons 0.0.0-stage → 5.3.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +111 -0
- package/README.md +2 -2
- package/package.json +43 -3
- package/src/runtime.mjs +390 -0
- package/src/vite-plugin.mjs +345 -0
- package/types/index.d.ts +6 -0
- package/types/runtime.d.ts +15 -0
package/API.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @epxing/icons API
|
|
2
|
+
|
|
3
|
+
Vite 构建插件和浏览器图标补齐工具。根入口用于 Node 构建,/runtime 用于浏览器。
|
|
4
|
+
|
|
5
|
+
本页对应包版本 `5.3.0-beta.2`。导出名、子路径和签名从当前源码、package.json 与类型声明生成。
|
|
6
|
+
|
|
7
|
+
## 安装与使用
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pnpm add @epxing/icons@5.3.0-beta.2
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// vite.config.mjs(Node)
|
|
17
|
+
import epxIcons from '@epxing/icons'
|
|
18
|
+
export default { plugins: [epxIcons()] }
|
|
19
|
+
|
|
20
|
+
// 产品入口(浏览器)
|
|
21
|
+
// import { configureIcons, collectIconNames, ensureIcons, createEndpointLoader } from '@epxing/icons/runtime'
|
|
22
|
+
// configureIcons({ loader: createEndpointLoader({ url: '/api/icons' }), timeout: 3000 })
|
|
23
|
+
// await ensureIcons(collectIconNames(pageConfig))
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 接入约定
|
|
27
|
+
|
|
28
|
+
浏览器从 @epxing/icons/runtime 导入,避免把 Node 构建依赖带进产品。图标宿主样式由 @epxing/theme/src/icons/base.scss 提供;loader 补入图案前需先加载底座样式。ready/missing 表示本次检查结果;静态分片的编号规则与 shardOf 保持一致。
|
|
29
|
+
|
|
30
|
+
## 根入口完整导出
|
|
31
|
+
|
|
32
|
+
| 导出名 | 形态 | 用途 | 参数 / 配置 / 事件 |
|
|
33
|
+
| --- | --- | --- | --- |
|
|
34
|
+
| `emitShards` | 函数 | 构建图标静态分片;在 Node 构建阶段调用,输出位置和分片数由选项决定。 | [详细参考](../../docs/revision/icons/api/vite-plugin.md) |
|
|
35
|
+
| `default` | 默认导出 | epxIcons(options?):Vite 图标构建插件工厂。 | [详细参考](../../docs/revision/icons/api/vite-plugin.md) |
|
|
36
|
+
|
|
37
|
+
## 函数、类与工具对象
|
|
38
|
+
|
|
39
|
+
签名中的 any 表示现有声明未进一步限定结构;源代码方法签名保留默认参数。异步结果与泛型以声明为准。
|
|
40
|
+
|
|
41
|
+
### emitShards
|
|
42
|
+
|
|
43
|
+
构建图标静态分片;在 Node 构建阶段调用,输出位置和分片数由选项决定。
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
emitShards(options?: IconOptions): unknown
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
[详细参考](../../docs/revision/icons/api/vite-plugin.md),包括参数、返回值与运行约束。
|
|
50
|
+
|
|
51
|
+
## 稳定子路径
|
|
52
|
+
|
|
53
|
+
下表按实际 package.json 的公开入口列出。default 使用默认导入,其他名称使用具名导入;类型接口用 import type。
|
|
54
|
+
|
|
55
|
+
| 导入路径 | 完整导出名 | 用途 / 详细说明 |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `@epxing/icons/runtime` | `configureIcons`、`collectIconNames`、`ensureIcons`、`ensureIcon`、`createEndpointLoader`、`createShardLoader`、`shardOf`、`__reset`、`IconRuntimeOptions`(类型)、`IconResult`(类型) | 浏览器图标运行时:配置 loader、收集名称、补齐图案、创建加载器和计算分片编号。 [详细参考](../../docs/revision/icons/api/runtime.md) |
|
|
58
|
+
|
|
59
|
+
### 浏览器 runtime 函数
|
|
60
|
+
|
|
61
|
+
| 函数 | 调用签名 | 用途 |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `configureIcons` | `configureIcons(options?: IconRuntimeOptions): void` | 配置浏览器图标 loader、builtin、match 和 timeout;timeout 以毫秒计,默认 0 不设上限。 |
|
|
64
|
+
| `collectIconNames` | `collectIconNames(config: unknown, out?: Set<string>): string[]` | 递归收集配置中的图标名并去重,返回字符串数组;可传入 Set 累积结果。 |
|
|
65
|
+
| `ensureIcons` | `ensureIcons(names: Iterable<string>): Promise<IconResult>` | 批量补齐图标并合并并发请求,返回 Promise<{ ready, missing }>。 |
|
|
66
|
+
| `ensureIcon` | `ensureIcon(name: string): Promise<IconResult>` | 确保单个图标可用,返回 Promise<{ ready, missing }>。 |
|
|
67
|
+
| `createEndpointLoader` | `createEndpointLoader(options?: Record<string, unknown>): (names: string[]) => Promise<unknown>` | 创建从图标 HTTP 接口批量取图案的 loader,传给 configureIcons。 |
|
|
68
|
+
| `createShardLoader` | `createShardLoader(options?: Record<string, unknown>): (names: string[]) => Promise<unknown>` | 创建从静态分片加载图标的 loader,传给 configureIcons。 |
|
|
69
|
+
| `shardOf` | `shardOf(name: string, shards?: number): number` | 按图标名计算静态分片编号;构建端与运行时须使用相同分片数。 |
|
|
70
|
+
| `__reset` | `__reset(): void` | 仅供测试:清空图标运行时缓存、loader 和已注入样式;产品业务代码不调用。 |
|
|
71
|
+
|
|
72
|
+
## 导出的 TypeScript 类型
|
|
73
|
+
|
|
74
|
+
以下名称仅存在于类型系统,用 import type 导入,不作为运行时对象使用。
|
|
75
|
+
|
|
76
|
+
### IconOptions
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
interface IconOptions {
|
|
80
|
+
[key: string]: unknown
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### IconRuntimeOptions
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
interface IconRuntimeOptions {
|
|
88
|
+
loader?: (names: string[]) => Promise<unknown>
|
|
89
|
+
timeout?: number
|
|
90
|
+
[key: string]: unknown
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### IconResult
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
interface IconResult { ready: string[]; missing: string[] }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 源码兼容与其他入口
|
|
101
|
+
|
|
102
|
+
/src 路径保留给已有产品和源码级扩展,可能包含平台内部实现;新产品优先采用上方根导出与稳定子路径。通配符表示按实际文件名匹配,不代表所有源码都属于根入口。
|
|
103
|
+
|
|
104
|
+
| 公开路径模式 | 实际目标 |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `@epxing/icons/src/*` | `./src/*` |
|
|
107
|
+
| `@epxing/icons/package.json` | `./package.json` |
|
|
108
|
+
|
|
109
|
+
## 文档维护
|
|
110
|
+
|
|
111
|
+
本页由 build/gen-package-api-docs.mjs 生成。修改公开入口并更新构建产物和类型声明后,在框架根运行 `pnpm docs:api`;`pnpm docs:api:check` 检查清单、类型、包内文档与站点镜像是否同步,同时验证页面编译和文档链接。用途和示例维护在 build/package-api-notes.mjs,组件和源码方法的详细参考位于 docs/revision。
|
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @epxing/icons
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[完整公开 API、导入方式与示例](./API.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@epxing/icons",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "5.3.0-beta.2",
|
|
4
|
+
"description": "图标构建工具 + 运行时(源码分发:在消费方的构建里跑,无需预构建)。构建期按需生成 Remix 图标与项目彩色图标的 CSS 规则;运行时按需补齐数据库里配的、构建期看不见的图标。渲染底座在 @epxing/theme 的 icons/base.scss。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/vite-plugin.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./types/index.d.ts",
|
|
10
|
+
"import": "./src/vite-plugin.mjs",
|
|
11
|
+
"default": "./src/vite-plugin.mjs"
|
|
12
|
+
},
|
|
13
|
+
"./runtime": {
|
|
14
|
+
"types": "./types/runtime.d.ts",
|
|
15
|
+
"import": "./src/runtime.mjs",
|
|
16
|
+
"default": "./src/runtime.mjs"
|
|
17
|
+
},
|
|
18
|
+
"./src/*": "./src/*",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"types",
|
|
24
|
+
"API.md"
|
|
25
|
+
],
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@iconify-json/ri": "^1.2.10",
|
|
28
|
+
"vite": "^6.0.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"@iconify-json/ri": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"vite": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"types": "./types/index.d.ts",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "echo '@epxing/icons 为纯 Node 源码包,无需构建'"
|
|
45
|
+
}
|
|
6
46
|
}
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// epx-frame 图标运行时:按需补齐「构建期不知道、只有运行时才知道」的图标。
|
|
2
|
+
//
|
|
3
|
+
// 解决的是什么问题:配置驱动的平台里,图标名存在**数据库**(菜单 FONT_ICON、
|
|
4
|
+
// 按钮 operate.iconCls、页面配置 configs.icon)。构建期没有库连接、配置又随时可改,
|
|
5
|
+
// 这些名字在原理上扫不到。此前唯一的办法是手工登记(icon-include.override.cjs),
|
|
6
|
+
// 想让后台图标选择器放开到 Remix 全量 3229 个就不现实了——全量内联进 CSS 是
|
|
7
|
+
// 259KB gzip(实测),是当前 461 个的 3.8 倍。
|
|
8
|
+
//
|
|
9
|
+
// 按需之所以在这套底座上特别便宜:渲染机制是 `--epx-i` 这一个 CSS 变量,
|
|
10
|
+
// 盒子 / mask / currentColor 的宿主规则早就在 CSS 里了。运行时补一个图标 =
|
|
11
|
+
// 补一条自定义属性声明,**不需要新增任何渲染机制**,也不碰 DOM 元素本身。
|
|
12
|
+
// 实测单个图标 319B raw,一页用 15 个 = 1.6KB gzip、一次请求。
|
|
13
|
+
//
|
|
14
|
+
// 用法:
|
|
15
|
+
// import { configureIcons, ensureIcons, collectIconNames, createEndpointLoader }
|
|
16
|
+
// from '@epxing/icons/runtime'
|
|
17
|
+
//
|
|
18
|
+
// configureIcons({ loader: createEndpointLoader({ url: '/api/icons' }) })
|
|
19
|
+
//
|
|
20
|
+
// // 配置驱动页面的最佳时机:配置到手、渲染之前 —— 图标就位后再渲染,不闪
|
|
21
|
+
// await ensureIcons(collectIconNames(pageConfig))
|
|
22
|
+
//
|
|
23
|
+
// ⚠️ 本文件跑在**浏览器**里,与同目录的 vite-plugin.mjs(跑在 Node 构建期)
|
|
24
|
+
// 是两套东西,不要互相 import:那会把 node:fs 拖进浏览器产物。
|
|
25
|
+
|
|
26
|
+
// 归运行时管的名字。默认只管 `ri-`:`el-icon-*` / `epx-icon-*` 那 461 个是兼容层,
|
|
27
|
+
// 随 lib 一起发布、永远在 CSS 里,探测它们纯属浪费。
|
|
28
|
+
const DEFAULT_MATCH = /^ri-[a-z0-9-]+$/
|
|
29
|
+
|
|
30
|
+
// 从任意配置对象里捞图标名时用的形状。比 DEFAULT_MATCH 宽——配置里存的可能是
|
|
31
|
+
// 平台名,也可能是 Remix 名,先都捞出来,交给 match 去筛。
|
|
32
|
+
const NAME_SHAPE = /^(?:ri|el-icon|epx-icon)-[a-z0-9-]+$/
|
|
33
|
+
|
|
34
|
+
const STYLE_ATTR = 'data-epx-icons'
|
|
35
|
+
|
|
36
|
+
let loader = null
|
|
37
|
+
let match = DEFAULT_MATCH
|
|
38
|
+
// 0 = 不设上限。放在渲染关键路径上时必须设:取数挂住(分片 404、后端超时、网络黑洞)
|
|
39
|
+
// 不该把整页渲染一起拖住。超时只是**不再等**,不取消请求——晚到的数据照样注入,
|
|
40
|
+
// 图标随后自己出来,比"永远空白"好。
|
|
41
|
+
let timeoutMs = 0
|
|
42
|
+
// 已确认可用的名字。三个来源:调用方声明的 builtin、探测判定为「已在 CSS 里」的、
|
|
43
|
+
// 本运行时注入过的。判定一次就记住,同一个名字不会探测第二次。
|
|
44
|
+
const ready = new Set()
|
|
45
|
+
// 名字 → 正在进行的 Promise。并发调用同一个图标只发一次请求。
|
|
46
|
+
const inflight = new Map()
|
|
47
|
+
let warnedHostMissing = false
|
|
48
|
+
|
|
49
|
+
const hasDom = () => typeof document !== 'undefined' && !!document.head
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 配置运行时。不调用也能工作(只是没有 loader,缺图标时会给出明确警告而不是静默空白)。
|
|
53
|
+
*
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* @param {function} opts.loader async (names: string[]) => ({ [name]: svg | {body,width,height} })
|
|
56
|
+
* 返回值两种形状都认:完整的 `<svg>...</svg>` 字符串,
|
|
57
|
+
* 或 iconify 的 { body, width, height }。
|
|
58
|
+
* @param {string[]} opts.builtin 已知**一定在 CSS 里**的名字,跳过探测。可选,
|
|
59
|
+
* 纯性能优化——不给也不会错,探测会得出同样结论。
|
|
60
|
+
* @param {RegExp} opts.match 哪些名字归运行时管,默认 /^ri-[a-z0-9-]+$/
|
|
61
|
+
* @param {number} opts.timeout 单次 ensureIcons 最多等多少毫秒,0(默认)不设上限。
|
|
62
|
+
* 挂在渲染关键路径上时**必须设**,见 timeoutMs 处注释。
|
|
63
|
+
*/
|
|
64
|
+
export function configureIcons (opts = {}) {
|
|
65
|
+
if (opts.loader) loader = opts.loader
|
|
66
|
+
if (opts.match) match = opts.match
|
|
67
|
+
if (opts.builtin) for (const n of opts.builtin) ready.add(n)
|
|
68
|
+
if (opts.timeout !== undefined) {
|
|
69
|
+
if (!Number.isFinite(opts.timeout) || opts.timeout < 0) {
|
|
70
|
+
throw new Error(`[epx-icons] timeout 必须是非负数,收到 ${opts.timeout}`)
|
|
71
|
+
}
|
|
72
|
+
timeoutMs = opts.timeout
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 从任意配置结构里捞出图标名。
|
|
78
|
+
*
|
|
79
|
+
* 递归走完整个对象/数组,把**长得像图标名的字符串**收集起来。之所以不按固定字段名
|
|
80
|
+
* (configs.icon / iconCls / FONT_ICON)去取:配置形状随组件而变,写死字段名必然漏。
|
|
81
|
+
* 形状正则收得很紧(整串匹配,不是子串),误报的代价也只是一次查无此名的请求。
|
|
82
|
+
*
|
|
83
|
+
* class 属性里常有多个词(`epx-icon ri-rocket-line`),所以按空白切开逐个判。
|
|
84
|
+
*/
|
|
85
|
+
export function collectIconNames (config, out = new Set()) {
|
|
86
|
+
if (!config) return [...out]
|
|
87
|
+
if (typeof config === 'string') {
|
|
88
|
+
for (const token of config.split(/\s+/)) {
|
|
89
|
+
if (NAME_SHAPE.test(token)) out.add(token)
|
|
90
|
+
}
|
|
91
|
+
return [...out]
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(config)) {
|
|
94
|
+
for (const item of config) collectIconNames(item, out)
|
|
95
|
+
return [...out]
|
|
96
|
+
}
|
|
97
|
+
if (typeof config === 'object') {
|
|
98
|
+
for (const key of Object.keys(config)) collectIconNames(config[key], out)
|
|
99
|
+
return [...out]
|
|
100
|
+
}
|
|
101
|
+
return [...out]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 探测这批名字在当前 CSS 里是不是已经有图案了。
|
|
106
|
+
*
|
|
107
|
+
* 为什么用探测而不是构建期清单:清单会和产物**版本漂移**(消费方装的 epx-frame
|
|
108
|
+
* 与插件生成的 CSS 未必同一批),而探测直接问浏览器「这个 class 现在画得出东西吗」,
|
|
109
|
+
* 自证、零构建耦合,而且不在乎图案是平台 lib 给的、插件给的、还是上次注入的。
|
|
110
|
+
*
|
|
111
|
+
* 判据来自底座本身:宿主规则给了 `--epx-i: linear-gradient(#0000,#0000)` 作缺省
|
|
112
|
+
* (见 theme 的 icons/base.scss),所以
|
|
113
|
+
* maskImage 以 url( 开头 ⇒ 有图案
|
|
114
|
+
* 是那条透明渐变 ⇒ 没图案,要补
|
|
115
|
+
* content 是 none ⇒ **宿主规则整个没上**,补图案也没用(见下)
|
|
116
|
+
*
|
|
117
|
+
* 一次性建好全部探针再读,只触发一次重排。
|
|
118
|
+
*/
|
|
119
|
+
function probeMissing (names) {
|
|
120
|
+
if (!hasDom()) return names.slice()
|
|
121
|
+
const box = document.createElement('div')
|
|
122
|
+
box.setAttribute('aria-hidden', 'true')
|
|
123
|
+
box.style.cssText = 'position:absolute;left:-9999px;top:0;visibility:hidden;pointer-events:none'
|
|
124
|
+
const probes = names.map(name => {
|
|
125
|
+
const el = document.createElement('i')
|
|
126
|
+
el.className = name
|
|
127
|
+
box.appendChild(el)
|
|
128
|
+
return { name, el }
|
|
129
|
+
})
|
|
130
|
+
document.body.appendChild(box)
|
|
131
|
+
|
|
132
|
+
const missing = []
|
|
133
|
+
let hostMissing = false
|
|
134
|
+
for (const { name, el } of probes) {
|
|
135
|
+
const cs = getComputedStyle(el, '::before')
|
|
136
|
+
// 宿主规则没上:::before 压根没被生成,content 计算值是 none 而不是 ""
|
|
137
|
+
if (cs.content === 'none') { hostMissing = true; missing.push(name); continue }
|
|
138
|
+
const mask = cs.maskImage || cs.webkitMaskImage || ''
|
|
139
|
+
if (mask.startsWith('url(')) ready.add(name)
|
|
140
|
+
else missing.push(name)
|
|
141
|
+
}
|
|
142
|
+
document.body.removeChild(box)
|
|
143
|
+
|
|
144
|
+
// 这一条值得吵:宿主规则缺失时,补多少图案都画不出来(有变量没有盒子),
|
|
145
|
+
// 而表现是**静默空白、零报错**——本项目里最难查的那类故障。
|
|
146
|
+
if (hostMissing && !warnedHostMissing) {
|
|
147
|
+
warnedHostMissing = true
|
|
148
|
+
warn('宿主规则没有生效:[class^="ri-"]::before 不存在。\n' +
|
|
149
|
+
' 补图案也画不出来(有 --epx-i 变量但没有盒子)。检查:\n' +
|
|
150
|
+
' · 项目是否引了 epx-frame 的样式(461 个平台图标同理依赖它)\n' +
|
|
151
|
+
' · epx-frame 版本是否过旧(早期 lib 里没有 ri- 宿主选择器)\n' +
|
|
152
|
+
' · 或在 vite.config 里挂上 @epxing/icons 插件并 import "virtual:epx-icons.css"')
|
|
153
|
+
}
|
|
154
|
+
return missing
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function warn (msg) {
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.warn('[epx-icons] ' + msg)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 与构建期 vite-plugin.mjs 的 encodeSvgDataUri 同一套编码,产出的 data URI 逐字节可比。
|
|
163
|
+
// 两处**必须一致**:同一个图标从两条路进来时长相要完全相同,否则排查时会以为是两个东西。
|
|
164
|
+
function encodeSvgDataUri (svg) {
|
|
165
|
+
return svg.replace(/"/g, "'").replace(/%/g, '%25').replace(/#/g, '%23')
|
|
166
|
+
.replace(/</g, '%3C').replace(/>/g, '%3E').replace(/\s+/g, ' ')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// loader 返回值两种形状都认:完整 svg 字符串,或 iconify 的 { body, width, height }
|
|
170
|
+
function toSvg (value) {
|
|
171
|
+
if (!value) return null
|
|
172
|
+
if (typeof value === 'string') {
|
|
173
|
+
const svg = value.trim()
|
|
174
|
+
return svg.startsWith('<svg') ? svg : null
|
|
175
|
+
}
|
|
176
|
+
if (typeof value.body !== 'string') return null
|
|
177
|
+
const w = value.width || 24
|
|
178
|
+
const h = value.height || 24
|
|
179
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${value.body}</svg>`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function inject (rules) {
|
|
183
|
+
if (!rules.length || !hasDom()) return
|
|
184
|
+
let style = document.head.querySelector(`style[${STYLE_ATTR}]`)
|
|
185
|
+
if (!style) {
|
|
186
|
+
style = document.createElement('style')
|
|
187
|
+
style.setAttribute(STYLE_ATTR, '')
|
|
188
|
+
document.head.appendChild(style)
|
|
189
|
+
}
|
|
190
|
+
// 追加而不是重建:已注入的规则保持原位,避免每次补图标都让浏览器重算整块样式表
|
|
191
|
+
style.appendChild(document.createTextNode(rules.join('\n')))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 确保这批图标在 CSS 里有图案,缺的取回来补上。
|
|
196
|
+
*
|
|
197
|
+
* 幂等、可并发:同一个名字重复调用不会重复请求(in-flight 去重 + ready 记账)。
|
|
198
|
+
* 任何失败都**不抛**——图标缺失不该让页面崩掉——但都会有明确的 console 警告,
|
|
199
|
+
* 绝不静默。
|
|
200
|
+
*
|
|
201
|
+
* @param {string[]} names
|
|
202
|
+
* @returns {Promise<{ready: string[], missing: string[]}>}
|
|
203
|
+
*/
|
|
204
|
+
export async function ensureIcons (names) {
|
|
205
|
+
const wanted = [...new Set((names || []).filter(Boolean))].filter(n => match.test(n))
|
|
206
|
+
if (!wanted.length) return { ready: [], missing: [] }
|
|
207
|
+
|
|
208
|
+
// 已判定可用的直接跳过;正在取的挂到同一个 Promise 上
|
|
209
|
+
const pending = []
|
|
210
|
+
const todo = []
|
|
211
|
+
for (const name of wanted) {
|
|
212
|
+
if (ready.has(name)) continue
|
|
213
|
+
const running = inflight.get(name)
|
|
214
|
+
if (running) pending.push(running)
|
|
215
|
+
else todo.push(name)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (todo.length) {
|
|
219
|
+
const missing = probeMissing(todo)
|
|
220
|
+
if (missing.length) {
|
|
221
|
+
if (!loader) {
|
|
222
|
+
warn(`${missing.length} 个图标不在 CSS 里,又没有配 loader —— 会是空白:\n` +
|
|
223
|
+
` ${missing.join(', ')}\n` +
|
|
224
|
+
' 用 configureIcons({ loader }) 配一个取数方式,或把它们登记进构建。')
|
|
225
|
+
missing.forEach(n => inflight.delete(n))
|
|
226
|
+
} else {
|
|
227
|
+
const job = fetchAndInject(missing)
|
|
228
|
+
missing.forEach(n => inflight.set(n, job))
|
|
229
|
+
pending.push(job)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (pending.length) {
|
|
235
|
+
const all = Promise.all(pending)
|
|
236
|
+
if (timeoutMs > 0) {
|
|
237
|
+
let timer
|
|
238
|
+
const bell = new Promise(resolve => { timer = setTimeout(() => resolve('timeout'), timeoutMs) })
|
|
239
|
+
const who = await Promise.race([all.then(() => 'done'), bell])
|
|
240
|
+
clearTimeout(timer)
|
|
241
|
+
if (who === 'timeout') {
|
|
242
|
+
warn(`取图标超过 ${timeoutMs}ms 还没回来,先不等了(请求没取消,晚到的数据仍会注入)。`)
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
await all
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const stillMissing = wanted.filter(n => !ready.has(n))
|
|
249
|
+
return { ready: wanted.filter(n => ready.has(n)), missing: stillMissing }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** ensureIcons 的单个版本,给「运行时才出现一个图标」的场景(如 <epx-icon> 的 name 变化)。 */
|
|
253
|
+
export function ensureIcon (name) {
|
|
254
|
+
return ensureIcons([name])
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function fetchAndInject (names) {
|
|
258
|
+
try {
|
|
259
|
+
const data = await loader(names)
|
|
260
|
+
const rules = []
|
|
261
|
+
const bad = []
|
|
262
|
+
for (const name of names) {
|
|
263
|
+
// loader 可以按裸名(rocket-line)或全名(ri-rocket-line)返回,两种都认
|
|
264
|
+
const raw = data && (data[name] !== undefined ? data[name] : data[name.replace(/^ri-/, '')])
|
|
265
|
+
const svg = toSvg(raw)
|
|
266
|
+
if (!svg) { bad.push(name); continue }
|
|
267
|
+
rules.push(`.${name}::before{--epx-i:url("data:image/svg+xml,${encodeSvgDataUri(svg)}")}`)
|
|
268
|
+
ready.add(name)
|
|
269
|
+
}
|
|
270
|
+
inject(rules)
|
|
271
|
+
if (bad.length) {
|
|
272
|
+
warn(`loader 没给回这 ${bad.length} 个图标的数据,它们会是空白:\n ${bad.join(', ')}\n` +
|
|
273
|
+
' 多半是名字拼错了(去 https://remixicon.com 核对),或后端图标库里没有这一项。')
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
warn(`取图标失败,这批会是空白:${names.join(', ')}\n ${err && err.message}`)
|
|
277
|
+
} finally {
|
|
278
|
+
names.forEach(n => inflight.delete(n))
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 批量接口 loader:`GET {url}?names=ri-a,ri-b` → `{ "ri-a": "<svg…>", … }`
|
|
284
|
+
*
|
|
285
|
+
* 推荐形态。图标内容永不变,后端配 `Cache-Control: immutable` 之后二次访问零请求。
|
|
286
|
+
* names 很多时自动分批,免得 URL 超长(保守取 1500 字符)。
|
|
287
|
+
*/
|
|
288
|
+
export function createEndpointLoader (opts = {}) {
|
|
289
|
+
const { url, batchSize = 60, fetchOptions } = opts
|
|
290
|
+
if (!url) throw new Error('[epx-icons] createEndpointLoader 需要 url')
|
|
291
|
+
return async (names) => {
|
|
292
|
+
const out = {}
|
|
293
|
+
for (let i = 0; i < names.length; i += batchSize) {
|
|
294
|
+
const part = names.slice(i, i + batchSize)
|
|
295
|
+
const res = await fetch(`${url}?names=${encodeURIComponent(part.join(','))}`, fetchOptions)
|
|
296
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
|
297
|
+
Object.assign(out, await res.json())
|
|
298
|
+
}
|
|
299
|
+
return out
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 静态分片 loader:不想动后端时用。分片由构建期插件的 `shards` 选项产出。
|
|
305
|
+
*
|
|
306
|
+
* 目录形如:
|
|
307
|
+
* {base}/manifest.json { prefix, shards, hash, total }
|
|
308
|
+
* {base}/0.json … {base}/{shards-1}.json { prefix, width, height, icons: {裸名: {body,…}} }
|
|
309
|
+
*
|
|
310
|
+
* ⚠️ 片数**从 manifest 读,不从选项读**。写成选项的话,构建期把 count 从 64 改成 32
|
|
311
|
+
* 之后运行时仍按 64 算,会稳定地去错误的片里找 —— 表现是图标空白加一条「loader
|
|
312
|
+
* 没给回数据」的警告,极难往哈希函数上想。让 manifest 做唯一事实来源,这类不一致
|
|
313
|
+
* 在结构上就不存在了。
|
|
314
|
+
*
|
|
315
|
+
* manifest 的 hash 同时用来做缓存失效:分片 URL 带上 `?v={hash}`,
|
|
316
|
+
* 图标集没变时 URL 不变(可长缓存),变了就自动换 URL。
|
|
317
|
+
*/
|
|
318
|
+
export function createShardLoader (opts = {}) {
|
|
319
|
+
const { base, fetchOptions } = opts
|
|
320
|
+
if (!base) throw new Error('[epx-icons] createShardLoader 需要 base')
|
|
321
|
+
|
|
322
|
+
let manifestPromise = null
|
|
323
|
+
const getManifest = () => {
|
|
324
|
+
if (!manifestPromise) {
|
|
325
|
+
manifestPromise = fetch(`${base}/manifest.json`, fetchOptions).then(r => {
|
|
326
|
+
if (!r.ok) throw new Error(`manifest.json ${r.status} ${r.statusText}`)
|
|
327
|
+
return r.json()
|
|
328
|
+
}).then(m => {
|
|
329
|
+
if (!m || !Number.isInteger(m.shards) || m.shards < 1) {
|
|
330
|
+
throw new Error(`${base}/manifest.json 里没有合法的 shards 字段`)
|
|
331
|
+
}
|
|
332
|
+
return m
|
|
333
|
+
}).catch(err => {
|
|
334
|
+
manifestPromise = null // 别把失败缓存住,下次还能重试
|
|
335
|
+
throw err
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
return manifestPromise
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const cache = new Map()
|
|
342
|
+
const load = (n, hash) => {
|
|
343
|
+
if (!cache.has(n)) {
|
|
344
|
+
const url = `${base}/${n}.json${hash ? `?v=${hash}` : ''}`
|
|
345
|
+
cache.set(n, fetch(url, fetchOptions).then(r => {
|
|
346
|
+
if (!r.ok) throw new Error(`${n}.json ${r.status} ${r.statusText}`)
|
|
347
|
+
return r.json()
|
|
348
|
+
}).catch(err => { cache.delete(n); throw err }))
|
|
349
|
+
}
|
|
350
|
+
return cache.get(n)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return async (names) => {
|
|
354
|
+
const m = await getManifest()
|
|
355
|
+
// ⚠️ 按**裸名**算片号:构建期切片时用的就是裸名(rocket-line),
|
|
356
|
+
// 这里传 'ri-rocket-line' 会算出另一个片号,稳定地取错。
|
|
357
|
+
const bare = names.map(n => n.replace(/^ri-/, ''))
|
|
358
|
+
const need = new Set(bare.map(n => shardOf(n, m.shards)))
|
|
359
|
+
const parts = await Promise.all([...need].map(n => load(n, m.hash)))
|
|
360
|
+
// 分片文件是 { prefix, width, height, icons },要合的是 icons 那一层
|
|
361
|
+
return Object.assign({}, ...parts.map(p => (p && p.icons) || {}))
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 名字 → 片号。FNV-1a 的变体,够均匀且实现极短。
|
|
367
|
+
* ⚠️ 构建期切片时**必须用同一个函数**,否则运行时会去错误的片里找。
|
|
368
|
+
*/
|
|
369
|
+
export function shardOf (name, shards = 64) {
|
|
370
|
+
let h = 2166136261
|
|
371
|
+
for (let i = 0; i < name.length; i++) {
|
|
372
|
+
h ^= name.charCodeAt(i)
|
|
373
|
+
h = Math.imul(h, 16777619)
|
|
374
|
+
}
|
|
375
|
+
return (h >>> 0) % shards
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 仅供测试:清空运行时状态。 */
|
|
379
|
+
export function __reset () {
|
|
380
|
+
loader = null
|
|
381
|
+
match = DEFAULT_MATCH
|
|
382
|
+
timeoutMs = 0
|
|
383
|
+
ready.clear()
|
|
384
|
+
inflight.clear()
|
|
385
|
+
warnedHostMissing = false
|
|
386
|
+
if (hasDom()) {
|
|
387
|
+
const style = document.head.querySelector(`style[${STYLE_ATTR}]`)
|
|
388
|
+
if (style) style.remove()
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// epx-frame 图标插件(产品项目用):让消费方也能「直接写 ri-xxx」。
|
|
2
|
+
//
|
|
3
|
+
// 背景:461 个 el-icon-*/epx-icon-* 是**兼容层**,已经烤进 lib/assets/*.css 随包发布,
|
|
4
|
+
// 消费方拿到即用、不需要这个插件。但 Remix 那 3229 个是**按需**的——用到哪些只有消费方
|
|
5
|
+
// 自己的源码知道,平台没法替它决定,所以这部分必须在消费方的构建里生成。
|
|
6
|
+
//
|
|
7
|
+
// 为什么两层不会打架:名字空间完全不相交(el-icon-*/epx-icon-* vs ri-*)。
|
|
8
|
+
// 宿主规则(给盒子/mask/currentColor)本插件**自带**一份(见下面 RI_HOST),
|
|
9
|
+
// 不依赖消费方装的 epx-frame 版本里 base.scss 编出来的那份——两者等价,
|
|
10
|
+
// 同时存在只是重复声明。曾经依赖过,代价是版本一旧图标就静默消失。
|
|
11
|
+
//
|
|
12
|
+
// 用法(vite.config.js):
|
|
13
|
+
// import epxIcons from 'epx-frame/build/vite-plugin-icons.mjs'
|
|
14
|
+
// export default { plugins: [epxIcons()] }
|
|
15
|
+
// 然后在入口引一次(与 UnoCSS 的 `import 'uno.css'` 同一约定):
|
|
16
|
+
// import 'virtual:epx-icons.css'
|
|
17
|
+
//
|
|
18
|
+
// 之后写 <i class="ri-rocket-line"></i> 就能用,零配置。
|
|
19
|
+
// 扫不到的两种(值来自数据库、名字是拼出来的)用 include 登记,见下面的选项说明。
|
|
20
|
+
//
|
|
21
|
+
// ⚠️ @iconify-json/ri 是 **optional peer**:只有要用 Remix 图标的项目才需要装它。
|
|
22
|
+
// 没装时插件不报错、只是不产出任何规则(consumer 可能压根不用 ri-),
|
|
23
|
+
// 但**写了 ri- 却没装**会拿到一条明确的提示,而不是图标静默消失。
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import crypto from 'node:crypto'
|
|
27
|
+
import { createRequire } from 'node:module'
|
|
28
|
+
// ⚠️ 方向是单向的:Node 端的插件可以引浏览器端的 runtime(它零 import、无顶层 DOM
|
|
29
|
+
// 访问,在 Node 里 import 安全),反过来**绝对不行**——那会把 node:fs 拖进浏览器产物。
|
|
30
|
+
//
|
|
31
|
+
// 为什么非要引它而不是各写一份 shardOf:切片时用哪个函数决定图标落在哪一片,
|
|
32
|
+
// 运行时用哪个函数决定去哪一片找。两边只要有一点不一致,运行时就会在错误的片里
|
|
33
|
+
// 查无此图标 —— 而表现是**空白 + 一条"loader 没给回数据"的警告**,非常难往哈希
|
|
34
|
+
// 函数上想。共用同一份是结构性保证,比"写两份再测它们相等"强一档。
|
|
35
|
+
import { shardOf } from './runtime.mjs'
|
|
36
|
+
|
|
37
|
+
const VIRTUAL = 'virtual:epx-icons.css'
|
|
38
|
+
const RESOLVED = '\0' + VIRTUAL
|
|
39
|
+
const DIRECT = /\bri-[a-z0-9-]+\b/g
|
|
40
|
+
const DEFAULT_ROOTS = ['src']
|
|
41
|
+
const DEFAULT_EXT = /\.(vue|js|jsx|ts|tsx|scss|css)$/
|
|
42
|
+
|
|
43
|
+
function walk (dir, out = []) {
|
|
44
|
+
let items
|
|
45
|
+
try { items = fs.readdirSync(dir) } catch { return out }
|
|
46
|
+
for (const it of items) {
|
|
47
|
+
if (it === 'node_modules' || it.startsWith('.')) continue
|
|
48
|
+
const p = path.join(dir, it)
|
|
49
|
+
let st
|
|
50
|
+
try { st = fs.statSync(p) } catch { continue }
|
|
51
|
+
if (st.isDirectory()) walk(p, out)
|
|
52
|
+
else if (DEFAULT_EXT.test(p)) out.push(p)
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 与平台 build/icons/gen-icon-css.mjs 同一套编码,产出的 data URI 逐字节可比
|
|
58
|
+
function encodeSvgDataUri (svg) {
|
|
59
|
+
return svg.replace(/"/g, "'").replace(/%/g, '%25').replace(/#/g, '%23')
|
|
60
|
+
.replace(/</g, '%3C').replace(/>/g, '%3E').replace(/\s+/g, ' ')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 存量彩色图标的名字(随 lib 的彩色字体一起发布,固定不变)。
|
|
64
|
+
// 内联在这里只为一件事:项目新增的彩色图标**与这 24 个撞名会同时渲染两份**
|
|
65
|
+
// (字体的 content 叠上这里的 background),必须挡住。名单是从
|
|
66
|
+
// public/font/font_2612462/iconfont.css 现取的,字体换了要同步。
|
|
67
|
+
const FONT_COLOR_NAMES = new Set([
|
|
68
|
+
'epx-icon-color-a-404', 'epx-icon-color-a-500', 'epx-icon-color-caozuochenggong',
|
|
69
|
+
'epx-icon-color-daorukaohejieguo', 'epx-icon-color-faqiniandukaohe', 'epx-icon-color-feishulogo',
|
|
70
|
+
'epx-icon-color-kaohedengji', 'epx-icon-color-kaohefangan', 'epx-icon-color-kaohejieguo',
|
|
71
|
+
'epx-icon-color-kaohetongji', 'epx-icon-color-kaohezhuti', 'epx-icon-color-kong',
|
|
72
|
+
'epx-icon-color-kong1', 'epx-icon-color-liulanqibuzhichi', 'epx-icon-color-mubiaozhiding',
|
|
73
|
+
'epx-icon-color-nianduhuibao', 'epx-icon-color-tab', 'epx-icon-color-tianjiabaobiao',
|
|
74
|
+
'epx-icon-color-tianjiazujian', 'epx-icon-color-tingyongtishi', 'epx-icon-color-yejishenbao',
|
|
75
|
+
'epx-icon-color-yichuli', 'epx-icon-color-yidongduankaohe', 'epx-icon-color-yinyong'
|
|
76
|
+
])
|
|
77
|
+
const COLOR_NAME_RE = /^epx-icon-color-[a-z0-9-]+$/
|
|
78
|
+
|
|
79
|
+
// ri- 的宿主规则(盒子 + mask + currentColor),两条,交给下面的 join 拼。
|
|
80
|
+
//
|
|
81
|
+
// 为什么插件要自带一份:base.scss 里有等价的一份、随 lib 发布,但**不能依赖它**——
|
|
82
|
+
// 消费方装的 epx-frame 可能比本插件旧(那个版本的 lib 里根本没有 ri- 宿主选择器),
|
|
83
|
+
// 届时 --epx-i 有值却没有盒子去画 = 图标静默消失,零报错。实测发生过:base.scss 的
|
|
84
|
+
// ri- 规则是后加的,早于它构建出来的 lib 里 ri- 命中数为 0。
|
|
85
|
+
// colorRules 从一开始就是自包含的(见其头注释),这里只是让 ri- 跟上同一个原则。
|
|
86
|
+
//
|
|
87
|
+
// 与 lib 里那份等价,同时存在也只是重复声明:CSS 幂等,重复串 gzip 压掉。
|
|
88
|
+
const RI_HOST = [
|
|
89
|
+
'[class^="ri-"],[class*=" ri-"]{display:inline-block;font-style:normal;' +
|
|
90
|
+
'-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}',
|
|
91
|
+
'[class^="ri-"]::before,[class*=" ri-"]::before{--epx-i:linear-gradient(#0000,#0000);' +
|
|
92
|
+
'content:"";display:inline-block;width:1em;height:1em;background-color:currentColor;' +
|
|
93
|
+
'-webkit-mask:var(--epx-i) no-repeat center/100%;mask:var(--epx-i) no-repeat center/100%;' +
|
|
94
|
+
'vertical-align:-0.125em}'
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
// 项目新增的彩色图标 → 规则。彩色走 background-image 不是 mask:
|
|
98
|
+
// mask + currentColor 产出的必然是单色剪影,多色 SVG 进去会被压成一个颜色。
|
|
99
|
+
// 规则必须自包含——base.scss 的宿主规则把 epx-icon-color-* 排除在外,拿不到那边的盒子样式。
|
|
100
|
+
function colorRules (color, projectRoot) {
|
|
101
|
+
const out = []
|
|
102
|
+
for (const [name, value] of Object.entries(color)) {
|
|
103
|
+
if (!COLOR_NAME_RE.test(name)) {
|
|
104
|
+
throw new Error(`[epx-icons] "${name}" 不是合法的彩色图标名——必须形如 epx-icon-color-xxx`)
|
|
105
|
+
}
|
|
106
|
+
if (FONT_COLOR_NAMES.has(name)) {
|
|
107
|
+
throw new Error(`[epx-icons] "${name}" 与平台存量彩色图标同名——会同时渲染两份(字体的 content 叠上这里的 background)。换个名字。`)
|
|
108
|
+
}
|
|
109
|
+
let svg = String(value).trim()
|
|
110
|
+
if (!svg.startsWith('<svg')) {
|
|
111
|
+
const file = path.resolve(projectRoot, svg)
|
|
112
|
+
if (!fs.existsSync(file)) {
|
|
113
|
+
throw new Error(`[epx-icons] "${name}" 指向的文件不存在:${file}(值不以 <svg 开头时按相对项目根解析)`)
|
|
114
|
+
}
|
|
115
|
+
svg = fs.readFileSync(file, 'utf8').trim()
|
|
116
|
+
}
|
|
117
|
+
if (!/^<svg[\s>]/.test(svg)) throw new Error(`[epx-icons] "${name}" 的内容不是 SVG`)
|
|
118
|
+
if (!/viewBox\s*=/.test(svg)) {
|
|
119
|
+
throw new Error(`[epx-icons] "${name}" 的 SVG 缺 viewBox——没有它就不会随 font-size 缩放,会静默走形。`)
|
|
120
|
+
}
|
|
121
|
+
out.push(`.${name}::before{content:'';display:inline-block;width:1em;height:1em;` +
|
|
122
|
+
`background:url("data:image/svg+xml,${encodeSvgDataUri(svg)}") no-repeat center/100%;` +
|
|
123
|
+
'vertical-align:-0.125em}')
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 切片:把 Remix 全量数据切成 N 份静态 JSON,供运行时按需取。
|
|
129
|
+
//
|
|
130
|
+
// 定位要说清楚——这些**不进 JS/CSS bundle**,是躺在产物目录里的独立文件。
|
|
131
|
+
// 磁盘上约 1MB,下载量取决于用户实际见到多少个长尾图标:一片约 4KB gzip,
|
|
132
|
+
// 一个片都没用到就一个字节都不下。「热门内联 + 长尾按需」里,这是长尾那一半。
|
|
133
|
+
//
|
|
134
|
+
// 为什么切全量而不是「全量减去已内联的」:后者会让分片内容随「这次构建内联了哪些」
|
|
135
|
+
// 变化 —— 加一个内联图标就让一堆分片的内容位移、缓存全废。切全量则内容只随
|
|
136
|
+
// Remix 版本变,稳定得多;已内联的那些运行时探测直接放行,永远不会去取。
|
|
137
|
+
//
|
|
138
|
+
// 幂等:内容没变就不写盘(与本插件其余部分同一约定)。
|
|
139
|
+
function writeShards (opts, data, projectRoot, log) {
|
|
140
|
+
const dir = path.resolve(projectRoot, opts.dir)
|
|
141
|
+
// ⚠️ 不能写 `opts.count || 64`:0 是 falsy,会被静默换成 64,
|
|
142
|
+
// 于是「非法值」变成「默认值」,下面那道校验永远拦不到 0。
|
|
143
|
+
const count = opts.count === undefined ? 64 : opts.count
|
|
144
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
145
|
+
throw new Error(`[epx-icons] shards.count 必须是正整数,收到 ${count}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const names = Object.keys(data.icons)
|
|
149
|
+
const buckets = Array.from({ length: count }, () => ({}))
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
// ⚠️ 这里用的是运行时导出的同一个 shardOf(见文件头 import 处的说明)。
|
|
152
|
+
// 切片按裸名(rocket-line)算,运行时也按裸名算 —— 两边必须同一口径。
|
|
153
|
+
buckets[shardOf(name, count)][name] = data.icons[name]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const payloads = buckets.map((icons, i) => JSON.stringify({
|
|
157
|
+
prefix: data.prefix || 'ri',
|
|
158
|
+
width: data.width || 24,
|
|
159
|
+
height: data.height || 24,
|
|
160
|
+
icons
|
|
161
|
+
}))
|
|
162
|
+
const hash = crypto.createHash('sha256')
|
|
163
|
+
.update(payloads.join('')).digest('hex').slice(0, 8)
|
|
164
|
+
const manifest = JSON.stringify({ prefix: data.prefix || 'ri', shards: count, hash, total: names.length })
|
|
165
|
+
|
|
166
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
167
|
+
|
|
168
|
+
// 清掉上一次留下的、这一次不再产出的分片:count 从 64 改成 32 时,
|
|
169
|
+
// 32~63 号文件会**留在产物里继续被服务**,运行时按新 count 算出的片号
|
|
170
|
+
// 永远取不到它们,但它们又确实存在 —— 只删形如 <数字>.json 的,不碰别的文件。
|
|
171
|
+
let removed = 0
|
|
172
|
+
for (const f of fs.readdirSync(dir)) {
|
|
173
|
+
const m = /^(\d+)\.json$/.exec(f)
|
|
174
|
+
if (m && Number(m[1]) >= count) { fs.unlinkSync(path.join(dir, f)); removed++ }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let written = 0
|
|
178
|
+
const put = (file, content) => {
|
|
179
|
+
const abs = path.join(dir, file)
|
|
180
|
+
let prev = null
|
|
181
|
+
try { prev = fs.readFileSync(abs, 'utf8') } catch { /* 不存在 */ }
|
|
182
|
+
if (prev === content) return
|
|
183
|
+
fs.writeFileSync(abs, content)
|
|
184
|
+
written++
|
|
185
|
+
}
|
|
186
|
+
payloads.forEach((p, i) => put(`${i}.json`, p))
|
|
187
|
+
put('manifest.json', manifest)
|
|
188
|
+
|
|
189
|
+
const bytes = payloads.reduce((s, p) => s + Buffer.byteLength(p), 0)
|
|
190
|
+
log(`[epx-icons] 分片 ${count} 份、图标 ${names.length} 个、磁盘 ${(bytes / 1024 / 1024).toFixed(2)}MB` +
|
|
191
|
+
`(写 ${written} 个文件${removed ? `,清理陈旧 ${removed} 个` : ''})→ ${opts.dir}`)
|
|
192
|
+
return { count, hash, total: names.length }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 单独产出分片,不经过插件。
|
|
197
|
+
*
|
|
198
|
+
* 给**有自己构建管线、不挂本插件**的消费方用——平台仓库就是这样:它的 461 条规则由
|
|
199
|
+
* build/icons/gen-icon-css.mjs 直接生成,vite.config.mjs 里没有 epxIcons 插件。让它
|
|
200
|
+
* 为了切片而挂一个「只写文件、CSS 产物没人用」的插件是本末倒置。
|
|
201
|
+
*
|
|
202
|
+
* @param {object} opts 同插件的 options.shards
|
|
203
|
+
* @param {string} opts.dir 写到哪(相对 cwd 或 opts.root)
|
|
204
|
+
* @param {number} [opts.count=64] 切几片
|
|
205
|
+
* @param {string} [opts.root] 项目根,默认 process.cwd()
|
|
206
|
+
* @param {function} [opts.log] 日志出口,默认 console.log
|
|
207
|
+
*/
|
|
208
|
+
export function emitShards (opts = {}) {
|
|
209
|
+
if (!opts.dir) throw new Error('[epx-icons] emitShards 需要 dir')
|
|
210
|
+
const root = opts.root || process.cwd()
|
|
211
|
+
let data
|
|
212
|
+
try {
|
|
213
|
+
data = createRequire(path.join(root, 'package.json'))('@iconify-json/ri/icons.json')
|
|
214
|
+
} catch {
|
|
215
|
+
throw new Error('[epx-icons] emitShards 需要 @iconify-json/ri,没装切不出分片。\n' +
|
|
216
|
+
' 装上 @iconify-json/ri(npm i -D / pnpm add -D)。')
|
|
217
|
+
}
|
|
218
|
+
// eslint-disable-next-line no-console
|
|
219
|
+
return writeShards(opts, data, root, opts.log || ((m) => console.log(m)))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {object} [options]
|
|
224
|
+
* @param {string[]} [options.roots='src'] 扫描目录,相对项目根
|
|
225
|
+
* @param {string[]} [options.include] 显式登记的图标类名(如 'ri-rocket-line')。
|
|
226
|
+
* 给扫描看不见的用:值来自数据库的(菜单图标、按钮 iconCls、页面配置 icon),
|
|
227
|
+
* 以及名字是拼出来的(`'ri-' + type`)。构建期没有库连接、配置又随时可改,
|
|
228
|
+
* 这两种在原理上就扫不到。
|
|
229
|
+
* @param {Record<string,string>} [options.color] 项目自己的**彩色**图标:名字 → SVG 路径(相对项目根)或内联 SVG。
|
|
230
|
+
* 平台那 24 个彩色插画已随 lib 发布、不用管;这里只加你自己的。撞名会响亮失败。
|
|
231
|
+
* @param {boolean} [options.failOnUnknown=true] 名字在 Remix 里查无此名时是否中断构建。
|
|
232
|
+
* 默认中断——静默跳过等于让拼错的图标悄悄变空白,很久之后才被用户发现。
|
|
233
|
+
* @param {object} [options.shards] 产出静态分片,供运行时按需取长尾图标。不给就不产出。
|
|
234
|
+
* @param {string} [options.shards.dir] 写到哪(相对项目根)。建议 'public/epx-icons'——
|
|
235
|
+
* Vite 会把 public/ 原样拷进产物、dev 下也直接可访问,URL 因此是可预测的,
|
|
236
|
+
* 运行时才能「按名字算片号 → 直接拼 URL」,不需要额外的索引文件。
|
|
237
|
+
* @param {number} [options.shards.count=64] 切几片。与运行时无需对齐:运行时从
|
|
238
|
+
* manifest.json 里读,改了这个值不会造成两边不一致。
|
|
239
|
+
*/
|
|
240
|
+
export default function epxIcons (options = {}) {
|
|
241
|
+
const {
|
|
242
|
+
roots = DEFAULT_ROOTS,
|
|
243
|
+
include = [],
|
|
244
|
+
color = {},
|
|
245
|
+
failOnUnknown = true,
|
|
246
|
+
shards = null
|
|
247
|
+
} = options
|
|
248
|
+
|
|
249
|
+
let projectRoot = process.cwd()
|
|
250
|
+
let css = ''
|
|
251
|
+
|
|
252
|
+
const loadRi = () => {
|
|
253
|
+
try {
|
|
254
|
+
return createRequire(path.join(projectRoot, 'package.json'))('@iconify-json/ri/icons.json')
|
|
255
|
+
} catch {
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const build = () => {
|
|
261
|
+
const names = new Set()
|
|
262
|
+
for (const n of include) names.add(n)
|
|
263
|
+
for (const r of roots) {
|
|
264
|
+
for (const abs of walk(path.resolve(projectRoot, r))) {
|
|
265
|
+
for (const m of fs.readFileSync(abs, 'utf8').matchAll(DIRECT)) names.add(m[0])
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// 彩色图标与 ri- 无关,任何情况下都要产出
|
|
269
|
+
const cRules = colorRules(color, projectRoot)
|
|
270
|
+
const done = (riRules, unknown = []) => {
|
|
271
|
+
// 有 ri- 规则才带宿主:一个 ri- 都没用的项目,虚拟 CSS 里不该多出无用选择器
|
|
272
|
+
const all = (riRules.length ? RI_HOST : []).concat(riRules, cRules)
|
|
273
|
+
css = all.join('\n') + (all.length ? '\n' : '')
|
|
274
|
+
return { count: riRules.length, colorCount: cRules.length, unknown }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!names.size) return done([])
|
|
278
|
+
|
|
279
|
+
const data = loadRi()
|
|
280
|
+
if (!data) {
|
|
281
|
+
const msg = `[epx-icons] 源码里用到了 ${names.size} 个 ri- 图标,但没装 @iconify-json/ri。\n` +
|
|
282
|
+
' 装上 @iconify-json/ri(npm i -D / pnpm add -D)之后重新构建。\n' +
|
|
283
|
+
' 不装的话这些图标不会有任何样式规则——是空白,不会报错。'
|
|
284
|
+
if (failOnUnknown) throw new Error(msg)
|
|
285
|
+
// eslint-disable-next-line no-console
|
|
286
|
+
console.warn(msg)
|
|
287
|
+
return done([])
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const rules = []
|
|
291
|
+
const unknown = []
|
|
292
|
+
for (const cls of [...names].sort()) {
|
|
293
|
+
const ic = data.icons[cls.slice(3)]
|
|
294
|
+
if (!ic) { unknown.push(cls); continue }
|
|
295
|
+
const w = ic.width || data.width || 24
|
|
296
|
+
const h = ic.height || data.height || 24
|
|
297
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${ic.body}</svg>`
|
|
298
|
+
rules.push(`.${cls}::before{--epx-i:url("data:image/svg+xml,${encodeSvgDataUri(svg)}")}`)
|
|
299
|
+
}
|
|
300
|
+
if (unknown.length) {
|
|
301
|
+
const msg = `[epx-icons] ${unknown.length} 个 ri- 图标名在 Remix 里查无此名:${unknown.join(', ')}\n` +
|
|
302
|
+
' 去 https://remixicon.com 核对拼写。名字对不上就不会生成规则,图标是空白、不报错。'
|
|
303
|
+
if (failOnUnknown) throw new Error(msg)
|
|
304
|
+
// eslint-disable-next-line no-console
|
|
305
|
+
console.warn(msg)
|
|
306
|
+
}
|
|
307
|
+
return done(rules, unknown)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
name: 'epx-icons',
|
|
312
|
+
configResolved (cfg) { projectRoot = cfg.root || process.cwd() },
|
|
313
|
+
buildStart () {
|
|
314
|
+
const { count, colorCount } = build()
|
|
315
|
+
// eslint-disable-next-line no-console
|
|
316
|
+
const log = (msg) => console.log(msg)
|
|
317
|
+
log(`[epx-icons] Remix 图标 ${count} 个(扫描 ${roots.join(', ')}` +
|
|
318
|
+
`${include.length ? ` + 登记 ${include.length}` : ''})` +
|
|
319
|
+
`${colorCount ? `;项目彩色图标 ${colorCount} 个` : ''}`)
|
|
320
|
+
|
|
321
|
+
if (!shards) return
|
|
322
|
+
if (!shards.dir) throw new Error('[epx-icons] shards 需要 dir(建议 "public/epx-icons")')
|
|
323
|
+
// 切片**不看扫描结果**:长尾图标的名字本来就来自数据库、源码里一个都没有,
|
|
324
|
+
// 按扫描结果切等于把要按需的那部分排除在外,正好切反了。
|
|
325
|
+
const data = loadRi()
|
|
326
|
+
if (!data) {
|
|
327
|
+
throw new Error('[epx-icons] 配了 shards 但没装 @iconify-json/ri,切不出分片。\n' +
|
|
328
|
+
' 装上 @iconify-json/ri(npm i -D / pnpm add -D)。')
|
|
329
|
+
}
|
|
330
|
+
writeShards(shards, data, projectRoot, log)
|
|
331
|
+
},
|
|
332
|
+
resolveId (id) { return id === VIRTUAL ? RESOLVED : null },
|
|
333
|
+
load (id) { return id === RESOLVED ? css : null },
|
|
334
|
+
// dev 下改了源码要能立刻反映新图标:源文件变更时重扫并让虚拟模块失效
|
|
335
|
+
handleHotUpdate ({ file, server }) {
|
|
336
|
+
if (!DEFAULT_EXT.test(file)) return
|
|
337
|
+
const before = css
|
|
338
|
+
build()
|
|
339
|
+
if (before === css) return
|
|
340
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED)
|
|
341
|
+
if (mod) server.moduleGraph.invalidateModule(mod)
|
|
342
|
+
server.ws.send({ type: 'full-reload' })
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface IconRuntimeOptions {
|
|
2
|
+
loader?: (names: string[]) => Promise<unknown>
|
|
3
|
+
timeout?: number
|
|
4
|
+
[key: string]: unknown
|
|
5
|
+
}
|
|
6
|
+
export declare function configureIcons(options?: IconRuntimeOptions): void
|
|
7
|
+
export declare function collectIconNames(config: unknown, out?: Set<string>): string[]
|
|
8
|
+
export interface IconResult { ready: string[]; missing: string[] }
|
|
9
|
+
export declare function ensureIcon(name: string): Promise<IconResult>
|
|
10
|
+
export declare function ensureIcons(names: Iterable<string>): Promise<IconResult>
|
|
11
|
+
export declare function createEndpointLoader(options?: Record<string, unknown>): (names: string[]) => Promise<unknown>
|
|
12
|
+
export declare function createShardLoader(options?: Record<string, unknown>): (names: string[]) => Promise<unknown>
|
|
13
|
+
export declare function shardOf(name: string, shards?: number): number
|
|
14
|
+
/** @internal 仅供测试:清空图标运行时状态。 */
|
|
15
|
+
export declare function __reset(): void
|