skinsuite 0.1.0
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/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/dist/constants.d.ts +93 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +93 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/mode.d.ts +24 -0
- package/dist/mode.d.ts.map +1 -0
- package/dist/mode.js +33 -0
- package/dist/mode.js.map +1 -0
- package/dist/tokens.d.ts +68 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +117 -0
- package/dist/tokens.js.map +1 -0
- package/dist/tone.d.ts +43 -0
- package/dist/tone.d.ts.map +1 -0
- package/dist/tone.js +51 -0
- package/dist/tone.js.map +1 -0
- package/dist/types.d.ts +226 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/views/params.d.ts +56 -0
- package/dist/views/params.d.ts.map +1 -0
- package/dist/views/params.js +36 -0
- package/dist/views/params.js.map +1 -0
- package/package.json +59 -0
- package/src/constants.ts +100 -0
- package/src/index.ts +65 -0
- package/src/mode.ts +38 -0
- package/src/tokens.ts +140 -0
- package/src/tone.ts +56 -0
- package/src/types.ts +226 -0
- package/src/views/params.ts +75 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const DEVICE_SCOPED_PARAMS = ['view', 'theme'];
|
|
2
|
+
/** 值"存在且非空" —— 空串 / 空数组视为**未指定**,不顶掉兜底键 */
|
|
3
|
+
function isPresent(value) {
|
|
4
|
+
if (value === undefined || value === null)
|
|
5
|
+
return false;
|
|
6
|
+
if (typeof value === 'string')
|
|
7
|
+
return value.trim() !== '';
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return value.some(v => typeof v === 'string' && v.trim() !== '');
|
|
10
|
+
}
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 按**设备**读取版式 / 配色参数
|
|
15
|
+
*
|
|
16
|
+
* @param query 路由 query(`route.query`)
|
|
17
|
+
* @param name 参数名(`'view'` / `'theme'`)
|
|
18
|
+
* @param device 当前设备 —— 容器用自己的设备常量传进来(文件位置即身份)
|
|
19
|
+
* @returns `query['<name>.<device>']` 优先;缺席或空值时退 `query[name]`;
|
|
20
|
+
* 两者都没有则返回 undefined,表示"**这台设备**没有被显式指定"
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ 只决定"取哪个键",**不做合法性校验** —— 校验属于各页 `pick*ViewId` 与配色注册表
|
|
23
|
+
* (本函数也拿不到"当前包里有没有这套版式")。返回值可能是数组(`?view=a&view=b`),
|
|
24
|
+
* 由下游按"非字符串即非法"处理,与改前的行为一致。
|
|
25
|
+
* ⚠️ 空串视为"未指定":`?view.standard=` 不该把 `?view=compact` 顶掉 ——
|
|
26
|
+
* 部署方常把参数留空当作"这台设备不特殊指定"。
|
|
27
|
+
* ⚠️ 设备名是**宿主**的词汇表(本包不认识具体取值),所以这里只把它当字符串拼键;
|
|
28
|
+
* 「这个名字认不认得」由宿主的设备清单归一(`asThemeDevice`)。
|
|
29
|
+
*/
|
|
30
|
+
export function readDeviceParam(query, name, device) {
|
|
31
|
+
const scoped = query?.[`${name}.${device}`];
|
|
32
|
+
if (isPresent(scoped))
|
|
33
|
+
return scoped;
|
|
34
|
+
return query?.[name];
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=params.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"params.js","sourceRoot":"","sources":["../../src/views/params.ts"],"names":[],"mappings":"AAqCA,MAAM,CAAC,MAAM,oBAAoB,GAAiC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEpF,2CAA2C;AAC3C,SAAS,SAAS,CAAC,KAAc;IAC/B,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IACxD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;IAC1D,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACnE,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,eAAe,CAC7B,KAA0C,EAC1C,IAAuB,EACvB,MAAc;IAEd,MAAM,MAAM,GAAG,KAAK,EAAE,CAAC,GAAG,IAAI,IAAI,MAAM,EAAE,CAAC,CAAC;IAC5C,IAAI,SAAS,CAAC,MAAM,CAAC;QAAE,OAAO,MAAM,CAAC;IACrC,OAAO,KAAK,EAAE,CAAC,IAAI,CAAC,CAAC;AACvB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skinsuite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "多主题 / 多版式机制的纯内核(零运行时依赖):目录名与默认值的白名单常量、主题包契约类型、明暗 mode 与色系 tone、CSS 变量 token 的安全校验、设备维度 URL 参数读取。不碰 DOM / 构建器 / 路由,可在任意前端(Vue / React / Node / SSR)复用。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.build.json",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
30
|
+
"test": "npm run build && node --test",
|
|
31
|
+
"prepack": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"theme",
|
|
35
|
+
"design-token",
|
|
36
|
+
"css-variables",
|
|
37
|
+
"multi-theme",
|
|
38
|
+
"multi-layout",
|
|
39
|
+
"dark-mode",
|
|
40
|
+
"theme-switch",
|
|
41
|
+
"vue",
|
|
42
|
+
"react",
|
|
43
|
+
"zero-dependency",
|
|
44
|
+
"typescript"
|
|
45
|
+
],
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/yijiu2025/theme.git"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/yijiu2025/theme#readme",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/yijiu2025/theme/issues"
|
|
53
|
+
},
|
|
54
|
+
"author": "qirly",
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"typescript": "^6.0.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题内核的**常量与白名单** —— 设备 / 页面 / 包 / 配色的取值集合
|
|
3
|
+
*
|
|
4
|
+
* 这些值原先散在 `oauth21/src/theme/index.ts`,2026-09-26 抽包 Stage 1 移到这里。
|
|
5
|
+
* 它们是**跨层共享的词汇表**:配色注册表、版式注册表工厂、参数读取、store、
|
|
6
|
+
* 以及目录口径关卡(`.tmp-probe/verify-theme-dirs.mjs`)全部按这几个常量对齐,
|
|
7
|
+
* 所以必须**只有一个来源** —— 改这里,其余各处跟着变。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ 本文件**零依赖**(不 import 任何东西),也不碰 DOM / 构建器 / 路由:
|
|
10
|
+
* 这是 `skinsuite` 能被任意前端复用的前提(见
|
|
11
|
+
* docs/frontend/theme-package-extraction.md 的「〇、一条原则」)。
|
|
12
|
+
*
|
|
13
|
+
* @author yijiu2025
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 目录名 / id 的白名单正则
|
|
18
|
+
*
|
|
19
|
+
* 同时作用于**主题包名**、**配色 id**、**版式 id**、**页面名** —— 它们都会进
|
|
20
|
+
* `data-*` 属性与 CSS 属性选择器,因此只允许小写字母、数字、短横线。
|
|
21
|
+
* 不合规的目录**静默跳过**(一个写坏的目录不该让整页起不来),所以排查
|
|
22
|
+
* "加了主题没生效"时,第一件事就是核这条正则。
|
|
23
|
+
*/
|
|
24
|
+
export const THEME_ID_RE = /^[a-z0-9-]+$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 空记录(registered 目录尚未产出任何记录)的占位配色 id
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ 它**不再**表示"默认配色":自 2026-09-24 起黑白是各设备下**具名的正式配色**
|
|
30
|
+
* (`<页面>/colors/{black,white}/`)。**默认配色**是下面的 `DEFAULT_THEME_COLOR`。
|
|
31
|
+
* 这里只用于空记录占位。
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_THEME_ID = 'default';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 内置默认**主题包** id
|
|
37
|
+
*
|
|
38
|
+
* 它用在版式侧:该包的版式由容器**静态引入**(首屏零请求),其它包才需要惰性加载。
|
|
39
|
+
* 见 `theme/views/registry.ts`。
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_THEME_PACKAGE = 'default';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 设备维度:**取值集合由宿主的设备清单声明**,内核不再自带一份
|
|
45
|
+
*
|
|
46
|
+
* === 为什么从这里搬走(2026-09-27)===
|
|
47
|
+
* 内核原先写着 `THEME_DEVICES = ['mobile','standard','mini']` —— 那是**某个应用的**
|
|
48
|
+
* 词汇表,不是机制的一部分。它带来的实际代价是"加一种设备要改好几处",而其中一处
|
|
49
|
+
* 在包里、另外几处在宿主(判定、容器、路由、面板),漏一处就是静默的半生效。
|
|
50
|
+
* 现在:
|
|
51
|
+
* • 内核只留「设备名是个 `[a-z0-9-]` 字符串」这一条**形态**约定;
|
|
52
|
+
* • 取值集合、中文名、判定规则、容器位置,全部在宿主的设备清单里声明
|
|
53
|
+
* (本仓 = `oauth21/src/theme/devices.ts`)。
|
|
54
|
+
* 内核因此可以在任何前端被复用 —— 那个前端有几台设备、叫什么,与内核无关。
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ 内核里所有"需要知道设备取值集合"的函数都已改成**接收它**(而不是查常量):
|
|
57
|
+
* 白名单校验在宿主(`asThemeDevice`),设备维度参数读取只需要"当前设备是谁"。
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 兜底页面:注册表类函数在调用点没给页面时的默认值
|
|
62
|
+
*
|
|
63
|
+
* 取 `login` 是因为它是认证流程的默认入口(未指定页面时最可能问的就是它)。
|
|
64
|
+
* 有明确页面的调用点(容器、面板)都应显式传 `page`,不要依赖这个兜底。
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_THEME_PAGE = 'login';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 兜底**配色** id:该范围内没有更具体的指定时用它(2026-09-26 定)
|
|
70
|
+
*
|
|
71
|
+
* 用户口径:**默认版式 = `default` 包,默认配色 = `white`**。
|
|
72
|
+
* 配色注册表先找它;只有该范围里根本没有这套色(如以后某个版式只提供一套自命名的
|
|
73
|
+
* 配色)才退回「该范围 id 字母序第一」。
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ 在它之前,兜底口径是"字母序第一",而 `black < blue < cyan < rainbow < white`
|
|
76
|
+
* → "默认外观"实际落在**黑系**,再靠首屏系别对齐纠正成白系。"两步走"有两个坑:
|
|
77
|
+
* ① 语义撕裂:基线 SCSS 的 `:root` 是浅底,兜底却是深色配色;
|
|
78
|
+
* ② **静默事故**:新增一个比 `black` 更靠前的配色 id(如 `amber`)会改掉默认外观。
|
|
79
|
+
* 写死成 `white` 后,"默认"是一个**可读的显式值**,不再依赖命名巧合。
|
|
80
|
+
*/
|
|
81
|
+
export const DEFAULT_THEME_COLOR = 'white';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 兜底**暗系**配色 id:明暗联动切到「暗」时的标配
|
|
85
|
+
*
|
|
86
|
+
* 与 `DEFAULT_THEME_COLOR` 成对(用户 2026-09-25 定:切「明」→白系、切「暗」→黑系)。
|
|
87
|
+
* 该范围内没有它时退回**该系别**字母序第一套,绝不跨系别硬凑。
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_THEME_DARK_COLOR = 'black';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 基础版式的**逻辑** id(与版式注册表工厂的默认 `baseId` 是同一约定值)
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ 它**不是**配色注册表里的 view 值:配色记录里的 view 段恒等于包名
|
|
95
|
+
* (一个主题包 = 一种版式),`'base'` 永远不会出现在配色键里。
|
|
96
|
+
* 外部 `?view=base` 由版式选择器归一化到当前包名,到不了配色注册表。
|
|
97
|
+
*
|
|
98
|
+
* 保留它只是为了版式工厂的默认值;新增代码不要拿它当配色查找的 view 参数。
|
|
99
|
+
*/
|
|
100
|
+
export const BASE_VIEW_ID = 'base';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skinsuite` 的**唯一公开出口**
|
|
3
|
+
*
|
|
4
|
+
* === 这里装什么 ===
|
|
5
|
+
* 主题机制里**与业务、与框架都无关**的那一半:取值白名单、契约类型、系别/明暗取值、
|
|
6
|
+
* 设备维度参数读取、token 形态与校验。它是纯函数 + 数据,可以在任何前端(Vue / React /
|
|
7
|
+
* 单测 / 将来的 SSR)里被同一套代码驱动。
|
|
8
|
+
*
|
|
9
|
+
* ### 不在这里装什么(重要)
|
|
10
|
+
* • **DOM 与注入**(`documentElement`、`localStorage`、`matchMedia`)→ 由宿主适配器提供
|
|
11
|
+
* • **构建器耦合**(`import.meta.glob` / `import.meta.env`)→ 由宿主适配器提供
|
|
12
|
+
* • **路由**(vue-router)→ 应用自己的世界观
|
|
13
|
+
* • **业务资产**(`themes/**` 的版式与配色实现)→ 随应用走
|
|
14
|
+
* 见 `docs/frontend/theme-package-extraction.md` 的「〇、一条原则」与「五、四个注入点契约」。
|
|
15
|
+
*
|
|
16
|
+
* === 为什么用显式具名导出,而不是 `export *` ===
|
|
17
|
+
* 立项文档 §四 有一张"公开 API 面"的表,本文件就是那张表的**落地**:表里没有的名字
|
|
18
|
+
* 就不该从这里出去(如内部用的正则、解析器)。用 `export *` 会让"包到底承诺了什么"
|
|
19
|
+
* 变成一份隐式清单,加一个内部导出就悄悄扩大了对外承诺。
|
|
20
|
+
* 代价是新增导出要手工补一行 —— 这是刻意的摩擦。
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ 相对 import **必须带 `.js` 后缀**(`from './tone.js'` 而不是 `'./tone'`):
|
|
23
|
+
* 包以 `moduleResolution: NodeNext` 编译到 `dist/`,TS 会把 `.js` 映射回 `.ts` 源码,
|
|
24
|
+
* 而**产物**里的相对说明符是原样保留的 —— 不带后缀的话浏览器与 Node 的原生 ESM
|
|
25
|
+
* 都解析不到。Vite / webpack 侧的别名消费源码时同样认这条 TS 约定,所以不用改两遍。
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ 公开面一旦发出就是承诺:改名/删名要先想清楚下游(当前只有 `oauth21` 一个消费者,
|
|
28
|
+
* 所以现在改是免费的,见立项文档 §八)。
|
|
29
|
+
*
|
|
30
|
+
* @author yijiu2025
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
THEME_ID_RE,
|
|
35
|
+
DEFAULT_THEME_ID,
|
|
36
|
+
DEFAULT_THEME_PACKAGE,
|
|
37
|
+
DEFAULT_THEME_PAGE,
|
|
38
|
+
DEFAULT_THEME_COLOR,
|
|
39
|
+
DEFAULT_THEME_DARK_COLOR,
|
|
40
|
+
BASE_VIEW_ID
|
|
41
|
+
} from './constants.js';
|
|
42
|
+
|
|
43
|
+
export { THEME_TONES, TONE_LABELS, isThemeTone, normalizeTone } from './tone.js';
|
|
44
|
+
export type { ThemeTone } from './tone.js';
|
|
45
|
+
|
|
46
|
+
export { MODE_CYCLE, MODE_LABELS, isThemeMode, normalizeMode } from './mode.js';
|
|
47
|
+
export type { ThemeMode } from './mode.js';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
isSafeTokenName,
|
|
51
|
+
isSafeTokenValue,
|
|
52
|
+
isSafeTokenEntry,
|
|
53
|
+
sanitizeOverrides
|
|
54
|
+
} from './tokens.js';
|
|
55
|
+
export type { ThemeTokenOverrides, SanitizeResult, ThemeTokenLayers } from './tokens.js';
|
|
56
|
+
|
|
57
|
+
export type {
|
|
58
|
+
MauthThemeMeta,
|
|
59
|
+
MauthThemePackage,
|
|
60
|
+
MauthThemeColor,
|
|
61
|
+
MauthThemeRecord
|
|
62
|
+
} from './types.js';
|
|
63
|
+
|
|
64
|
+
export { DEVICE_SCOPED_PARAMS, readDeviceParam } from './views/params.js';
|
|
65
|
+
export type { DeviceScopedParam } from './views/params.js';
|
package/src/mode.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 明暗意图(mode)的取值、循环顺序与展示文案
|
|
3
|
+
*
|
|
4
|
+
* 🔴 明暗 ≡ 色系(2026-09-25 定):「明暗切换就是切换白色和黑色这个色系,不是另一套规则」。
|
|
5
|
+
* mode 不是独立于配色的第二个维度,而是「切到哪一系别」的意图:
|
|
6
|
+
* light —— 明 = 白系
|
|
7
|
+
* dark —— 暗 = 黑系
|
|
8
|
+
* system —— 跟随系统偏好(系统暗→黑系、亮→白系)
|
|
9
|
+
* 最终明暗由**当前配色的 tone** 决定(见 `oauth21/src/stores/theme.ts` 的 `isDark`)。
|
|
10
|
+
* 点色卡同步 mode(点黑卡=dark、点白卡=light),切 mode 联动配色 —— 永远一致。
|
|
11
|
+
*
|
|
12
|
+
* @author yijiu2025
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 明暗三态:'system' 显式跟随系统(旧版只有布尔值,切过就回不来) */
|
|
16
|
+
export type ThemeMode = 'system' | 'light' | 'dark';
|
|
17
|
+
|
|
18
|
+
/** 切换按钮的循环顺序:跟随系统 → 浅色 → 深色 → 跟随系统 */
|
|
19
|
+
export const MODE_CYCLE: readonly ThemeMode[] = ['system', 'light', 'dark'];
|
|
20
|
+
|
|
21
|
+
/** 界面与无障碍标签用的中文文案(明=白系 / 暗=黑系 / 跟随系统) */
|
|
22
|
+
export const MODE_LABELS: Record<ThemeMode, string> = {
|
|
23
|
+
system: '跟随系统',
|
|
24
|
+
light: '明',
|
|
25
|
+
dark: '暗'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** 是否是合法的明暗取值(用于校验 URL 参数、后端下发这类不可信输入) */
|
|
29
|
+
export function isThemeMode(value: unknown): value is ThemeMode {
|
|
30
|
+
return value === 'system' || value === 'light' || value === 'dark';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 归一化外部输入:大小写与首尾空白容忍,非法返回 null(由调用方决定回退) */
|
|
34
|
+
export function normalizeMode(raw: unknown): ThemeMode | null {
|
|
35
|
+
if (typeof raw !== 'string') return null;
|
|
36
|
+
const v = raw.trim().toLowerCase();
|
|
37
|
+
return isThemeMode(v) ? v : null;
|
|
38
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* token 的**数据形态**与**白名单校验** —— 主题变量能不能被写进页面的唯一判据
|
|
3
|
+
*
|
|
4
|
+
* 这些内容原先混在 `oauth21/src/theme/runtime.ts` 里(那份文件还兼着"往 DOM 写
|
|
5
|
+
* inline style"的活),2026-09-26 抽包 Stage 1 把**纯校验**这一半移到这里。
|
|
6
|
+
* 留在 `runtime.ts` 的是注入动作(`applyThemeLayers` 等),它反过来 import 本文件。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 本文件**零依赖、零副作用**:不读 DOM、不 import 任何模块,是一串纯函数 + 常量。
|
|
9
|
+
* 这是 `skinsuite` 能被任意前端(webpack / 单测 / SSR 预备)复用的前提。
|
|
10
|
+
*
|
|
11
|
+
* === 为什么必须校验 ===
|
|
12
|
+
* CSS 自定义属性是能直接改变渲染的输入。未校验就 `setProperty` 等于把 CSS 注入的
|
|
13
|
+
* 口子交给后端或父页面:
|
|
14
|
+
* • 值里塞一个指向第三方的 url() → 触发外发请求(可用于探测/追踪)
|
|
15
|
+
* • 值里塞一段能闭合规则再起新规则的文本 → 注入任意样式
|
|
16
|
+
* 所以这里 token 名与取值**都走白名单**,而不是黑名单过滤。
|
|
17
|
+
*
|
|
18
|
+
* 主题包自己的 theme.scss 不走这条通道 —— 那是构建期就在仓库里的受信代码,
|
|
19
|
+
* 由 Vite 编译打包,不存在运行期注入问题(见 types.ts 的分界说明)。
|
|
20
|
+
*
|
|
21
|
+
* === tokens 是**一组扁平值**,不分明暗档(2026-09-25 改)===
|
|
22
|
+
* 早先 `tokens` 是 `{ light, dark }` 两档,注入时按明暗取「light」或「light ∪ dark」。
|
|
23
|
+
* 用户 2026-09-25 明确要求取消这个分档:
|
|
24
|
+
*
|
|
25
|
+
* > 没有深浅两档了,深和浅就是两种颜色配置。
|
|
26
|
+
* > 蓝色没有白蓝和黑蓝之分,底色由蓝色自己选择设置。
|
|
27
|
+
*
|
|
28
|
+
* @author yijiu2025
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** 只放行移动端认证页的主题变量,避免外部配置误伤全局(--background 等 shadcn 变量) */
|
|
32
|
+
const TOKEN_NAME_RE = /^--mauth-[a-z0-9-]+$/;
|
|
33
|
+
|
|
34
|
+
/** 单条取值长度上限:正常颜色/长度都远小于此,超长基本是要撑爆样式表 */
|
|
35
|
+
const MAX_VALUE_LENGTH = 120;
|
|
36
|
+
|
|
37
|
+
/** 字体族 token 单独放宽:字体栈天然含空格、逗号、引号 */
|
|
38
|
+
const FONT_FAMILY_TOKEN = '--mauth-font-family';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 允许的取值形态(白名单,逐条匹配其一即可)
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ 刻意**不支持 CSS 颜色名**(`red` / `blue` 之类):
|
|
44
|
+
* 一是颜色名上百个、维护白名单容易漏;二是若放宽成「任意字母」,
|
|
45
|
+
* 会连带放行 `expression` 这类历史攻击向量。
|
|
46
|
+
* 配置品牌色请用 #hex / rgb() / hsl()。
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ 刻意**不支持 `url()`**:这是外发请求的唯一入口,也是背景图必须走
|
|
49
|
+
* theme.scss 的原因(见 types.ts 的分界说明)。
|
|
50
|
+
*/
|
|
51
|
+
const VALUE_PATTERNS: RegExp[] = [
|
|
52
|
+
/^#[0-9a-fA-F]{3,8}$/, // #fff / #ffffff / #ffffffcc
|
|
53
|
+
/^rgba?\(\s*[\d.,%\s/]+\)$/, // rgb(30 41 59) / rgba(30,41,59,0.12)
|
|
54
|
+
/^hsla?\(\s*[\d.,%\s/]+(deg|rad|turn)?[\d.,%\s/]*\)$/, // hsl(210 40% 96%)
|
|
55
|
+
/^-?\d+(\.\d+)?(px|rem|em|%|vh|vw|ch)$/, // 12px / 1.5rem / 100%
|
|
56
|
+
/^var\(--mauth-[a-z0-9-]+\)$/, // 引用另一个主题变量
|
|
57
|
+
/^(transparent|none|auto|inherit|currentcolor|initial|0)$/i // 关键字
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 字体栈:字母数字、空格、逗号、连字符、下划线、单双引号。
|
|
62
|
+
* 不含括号,因此写不出任何函数;配合 FORBIDDEN_RE 已足够安全。
|
|
63
|
+
*/
|
|
64
|
+
const FONT_VALUE_RE = /^[a-zA-Z0-9 ,'"_-]{1,120}$/;
|
|
65
|
+
|
|
66
|
+
/** 明确拒绝的危险片段(白名单之外的兜底,双保险) */
|
|
67
|
+
const FORBIDDEN_RE = /[;{}<>\\]|url\s*\(|expression\s*\(|\/\*|\*\/|@import/i;
|
|
68
|
+
|
|
69
|
+
/** 校验 token 名:必须是 --mauth- 前缀的小写短横线命名 */
|
|
70
|
+
export function isSafeTokenName(name: unknown): name is string {
|
|
71
|
+
return typeof name === 'string' && TOKEN_NAME_RE.test(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 校验 token 取值:命中白名单且不含危险片段(不含字体栈特例,见 isSafeTokenEntry) */
|
|
75
|
+
export function isSafeTokenValue(value: unknown): value is string {
|
|
76
|
+
if (typeof value !== 'string') return false;
|
|
77
|
+
const v = value.trim();
|
|
78
|
+
if (v.length === 0 || v.length > MAX_VALUE_LENGTH) return false;
|
|
79
|
+
if (FORBIDDEN_RE.test(v)) return false;
|
|
80
|
+
return VALUE_PATTERNS.some(re => re.test(v));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 校验一条 token 键值对
|
|
85
|
+
*
|
|
86
|
+
* 单独抽出来的原因:**取值白名单要依赖 token 名**(字体族是唯一的例外),
|
|
87
|
+
* 只看值无法判断 `Inter, sans-serif` 是否合法。
|
|
88
|
+
*/
|
|
89
|
+
export function isSafeTokenEntry(name: unknown, value: unknown): boolean {
|
|
90
|
+
if (!isSafeTokenName(name)) return false;
|
|
91
|
+
if (typeof value !== 'string') return false;
|
|
92
|
+
const v = value.trim();
|
|
93
|
+
if (v.length === 0 || v.length > MAX_VALUE_LENGTH) return false;
|
|
94
|
+
if (FORBIDDEN_RE.test(v)) return false;
|
|
95
|
+
if (name === FONT_FAMILY_TOKEN) return FONT_VALUE_RE.test(v);
|
|
96
|
+
return VALUE_PATTERNS.some(re => re.test(v));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 一组扁平 token 覆写表(主题包与后端下发共用的数据形态)
|
|
101
|
+
*
|
|
102
|
+
* ⚠️ 不分 `light` / `dark` 两档(2026-09-25 改)—— 见文件头说明。
|
|
103
|
+
* 键是 `--mauth-<角色>`,值是白名单内的取值。
|
|
104
|
+
*/
|
|
105
|
+
export type ThemeTokenOverrides = Record<string, string>;
|
|
106
|
+
|
|
107
|
+
/** 校验结果:通过白名单的项 + 被拒绝的项(后者供调用方上报,便于发现配置写错) */
|
|
108
|
+
export interface SanitizeResult {
|
|
109
|
+
tokens: Record<string, string>;
|
|
110
|
+
rejected: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 参与注入的分层:主题包在前,外部覆写在后(后者同名覆盖前者) */
|
|
114
|
+
export interface ThemeTokenLayers {
|
|
115
|
+
/** 主题包自带 token(来自 `themes/<包>/<设备>/<页面>/colors/<配色>/`) */
|
|
116
|
+
theme?: ThemeTokenOverrides | null;
|
|
117
|
+
/** 外部覆写 token(后端下发 / 父应用同步) */
|
|
118
|
+
external?: ThemeTokenOverrides | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 逐条校验覆写表,丢弃不安全的条目
|
|
123
|
+
*
|
|
124
|
+
* 不采用「有一条非法就整体拒绝」的策略:配置里混入一条写错的色值时,
|
|
125
|
+
* 其余合法配色仍应生效,否则改一个字就整站退回默认配色,排查成本很高。
|
|
126
|
+
* 被拒绝的条目会返回给调用方,便于上报告警。
|
|
127
|
+
*/
|
|
128
|
+
export function sanitizeOverrides(input: ThemeTokenOverrides | null | undefined): SanitizeResult {
|
|
129
|
+
const result: SanitizeResult = { tokens: {}, rejected: [] };
|
|
130
|
+
if (!input || typeof input !== 'object') return result;
|
|
131
|
+
|
|
132
|
+
for (const [name, value] of Object.entries(input)) {
|
|
133
|
+
if (!isSafeTokenEntry(name, value)) {
|
|
134
|
+
result.rejected.push(`${name}=${String(value).slice(0, 40)}`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
result.tokens[name] = value.trim();
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
package/src/tone.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配色的「明暗系别」(tone)
|
|
3
|
+
*
|
|
4
|
+
* 与 `mode`(明暗偏好)是两个不同的维度,本模块负责把它们**关联起来做联动**:
|
|
5
|
+
* • tone —— 一套配色**自带**的属性:它的底是浅色还是深色
|
|
6
|
+
* 'light' 白系:浅底深字(white / blue / cyan / rainbow)
|
|
7
|
+
* 'dark' 黑系:深底浅字(black)
|
|
8
|
+
* • mode —— 用户的明暗偏好('system' | 'light' | 'dark',见 ./mode)
|
|
9
|
+
*
|
|
10
|
+
* === 联动规则(用户 2026-09-25 定)===
|
|
11
|
+
* 🔴 明暗 ≡ 色系:「明暗切换就是切换白色和黑色这个色系,不是另一套规则」。
|
|
12
|
+
* 切「暗」→ 落到黑系配色、切「明」→ 落到白系配色,两侧各自记住上一次的选择,
|
|
13
|
+
* 切回来即恢复。例如:白系用 blue,切「暗」→ 自动变 black,切「明」→ 回到 blue。
|
|
14
|
+
* 实现见 `oauth21/src/stores/theme.ts` 的「双侧记忆槽」。
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ 它**不是 CSS 的一部分**:不是 token,不会写成 `--mauth-*` 变量,
|
|
17
|
+
* 也不参与渲染。唯一用途是让 store 判断"当前配色与目标明暗是否一致"。
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ 与 tokens 的分档无关:自 2026-09-25 起 `tokens` 是一组扁平值,
|
|
20
|
+
* 一套配色内部不再分深浅两档 —— tone 描述的是**配色之间**的关系,
|
|
21
|
+
* 不是"一套配色内部的明暗两档"。别再把它理解成旧版的 `light`/`dark` 两块。
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ 明暗与配色**保持一致**:点色卡同步把明暗设成该配色的系别(点黑卡=暗、
|
|
24
|
+
* 点白卡=明),切明暗联动把配色切到目标系别 —— 两条路径互相咬合,不撕裂。
|
|
25
|
+
*
|
|
26
|
+
* @author yijiu2025
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** 明暗系别:配色自带的底色深浅 */
|
|
30
|
+
export type ThemeTone = 'light' | 'dark';
|
|
31
|
+
|
|
32
|
+
/** 全部系别(遍历 / 校验用;顺序与界面展示一致) */
|
|
33
|
+
export const THEME_TONES: readonly ThemeTone[] = ['light', 'dark'];
|
|
34
|
+
|
|
35
|
+
/** 界面与日志用的中文文案 */
|
|
36
|
+
export const TONE_LABELS: Record<ThemeTone, string> = {
|
|
37
|
+
light: '白系',
|
|
38
|
+
dark: '黑系'
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 是否是合法的系别取值(用于校验外部输入这一类不可信来源) */
|
|
42
|
+
export function isThemeTone(value: unknown): value is ThemeTone {
|
|
43
|
+
return value === 'light' || value === 'dark';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 归一化外部输入:容忍大小写与首尾空白,非法返回 null(由调用方决定回退)
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ 与 `mode` 不同,这里**没有** `'system'` —— 系别是配色自带的属性,
|
|
50
|
+
* 不存在"跟随系统"这回事。
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeTone(raw: unknown): ThemeTone | null {
|
|
53
|
+
if (typeof raw !== 'string') return null;
|
|
54
|
+
const v = raw.trim().toLowerCase();
|
|
55
|
+
return isThemeTone(v) ? v : null;
|
|
56
|
+
}
|