dsh-fishpai 1.0.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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 上游运行时加载:在 vm context 里求值站点原版的三个 vendor 资产。
3
+ *
4
+ * 为什么必须用 vm 顶层求值:上游 `js/themes.js` 用顶层 `const themes = {...}` 声明,
5
+ * 不挂 `window`;直接 `import` 拿不到,必须 context 级顶层求值。
6
+ * (这是上游的既有行为,不是我们选择的实现方式——见 NOTICE.md)
7
+ *
8
+ * 全部懒加载并缓存:插件启动时不付这份解析代价。
9
+ */
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import vm from 'node:vm'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ export const VENDOR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'vendor')
16
+
17
+ let sandbox = null
18
+
19
+ function loadRuntime() {
20
+ const box = { console, document: undefined }
21
+ box.window = box
22
+ box.self = box
23
+ box.globalThis = box
24
+ vm.createContext(box)
25
+
26
+ const load = (f) => vm.runInContext(fs.readFileSync(path.join(VENDOR, f), 'utf8'), box, { filename: f })
27
+ load('markdown-it.min.js')
28
+ load('highlight.min.js')
29
+ load('themes.js')
30
+
31
+ // hljs 在 UMD 里挂到自己身上,取出来交给沙箱的全局
32
+ const hljs = box.hljs || (box.window && box.window.hljs)
33
+ if (hljs) box.hljs = hljs
34
+
35
+ return box
36
+ }
37
+
38
+ /** 在 vendor 沙箱里求值一个表达式('markdownit' / 'hljs' / 'themes' …)。 */
39
+ export function read(expr) {
40
+ if (sandbox === null) sandbox = loadRuntime()
41
+ return vm.runInContext(expr, sandbox)
42
+ }
43
+
44
+ export function themes() {
45
+ return read('themes')
46
+ }
47
+
48
+ export function colorPresets() {
49
+ return read('colorPresets')
50
+ }
51
+
52
+ /** 主题名/别名 -> 主题 key。允许用中文名、emoji 名或 key 指定。 */
53
+ export function resolveTheme(name) {
54
+ const THEMES = themes()
55
+ if (!name) return 'default'
56
+ if (THEMES[name]) return name
57
+ const norm = String(name).replace(/\s/g, '')
58
+ for (const [key, t] of Object.entries(THEMES)) {
59
+ if (key === norm || t.name.replace(/\s/g, '') === norm) return key
60
+ if (t.name.replace(/\s/g, '').includes(norm) || norm.includes(t.name.replace(/\s/g, ''))) return key
61
+ }
62
+ return null
63
+ }
64
+
65
+ /** 站点的字体预设(上游 app.js 的 fontMap);默认 fontFamily='sans' 会覆盖主题自带字体。 */
66
+ export const FONT_MAP = {
67
+ sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Noto Sans SC", sans-serif',
68
+ serif: '"Georgia", "Noto Serif SC", "Source Han Serif SC", serif',
69
+ mono: '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, "PingFang SC", monospace',
70
+ }
71
+
72
+ let HLJS_MAP = null
73
+
74
+ /** highlight.js token 的计算样式表(从真实浏览器导出,见 NOTICE.md)。 */
75
+ export function hljsMap() {
76
+ if (HLJS_MAP) return HLJS_MAP
77
+ try {
78
+ HLJS_MAP = JSON.parse(fs.readFileSync(path.join(VENDOR, 'hljs-map.json'), 'utf8'))
79
+ } catch {
80
+ HLJS_MAP = {}
81
+ }
82
+ return HLJS_MAP
83
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 主题能力标注:从主题定义本身推导"这个主题能做什么、粘进微信靠不靠谱"。
3
+ *
4
+ * 为什么要有这一层:工具栏里有些控件只对部分主题有效——
5
+ * - **主题色**靠替换主题样式里的 `{{PRIMARY}}` / `{{PRIMARY_BG}}` 生效,
6
+ * 实测只有 `default`(默认公众号)用了它,其余主题点了也不会变。
7
+ * - **渐变文字**(`background-clip: text` + 透明字色)依赖微信可能重写的属性,
8
+ * 作为公众号正文有风险。
9
+ *
10
+ * 所以这里把判断**写进代码**、由主题定义推导,而不是在 UI 里维护一份主题名单:
11
+ * 上游哪天改了主题,标注自动跟着变(相关单测会红,提醒复核)。
12
+ *
13
+ * 纯函数、零依赖,可单独单测。
14
+ */
15
+
16
+ /** 主题色占位符:`{{PRIMARY}}` 与它的浅色变体都算用了主题色。 */
17
+ const ACCENT_RE = /\{\{PRIMARY(?:_BG)?\}\}/
18
+
19
+ /** 渐变文字:把背景裁剪到文字上,字色本身就是透明的。 */
20
+ const TEXT_CLIP_RE = /(?:-webkit-)?background-clip:\s*text/i
21
+
22
+ /** 深色底判定阈值(sRGB 相对亮度)。 */
23
+ const DARK_LUMINANCE = 0.4
24
+
25
+ function stylesText(theme) {
26
+ const styles = theme && typeof theme === 'object' ? theme.styles : null
27
+ if (!styles || typeof styles !== 'object') return ''
28
+ return Object.values(styles)
29
+ .filter((v) => typeof v === 'string')
30
+ .join(';')
31
+ }
32
+
33
+ function parseColor(value) {
34
+ const raw = String(value || '').trim()
35
+ let m = /^#([0-9a-f]{3})$/i.exec(raw)
36
+ if (m) {
37
+ const [r, g, b] = m[1].split('').map((c) => parseInt(c + c, 16))
38
+ return { r, g, b }
39
+ }
40
+ m = /^#([0-9a-f]{6})$/i.exec(raw)
41
+ if (m) {
42
+ const n = parseInt(m[1], 16)
43
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
44
+ }
45
+ // rgb()/rgba():只取前三个通道;解析不了就返回 null(失效方向选"不标记")
46
+ m = /^rgba?\(\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})/i.exec(raw)
47
+ if (m) return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) }
48
+ return null
49
+ }
50
+
51
+ function luminance({ r, g, b }) {
52
+ const channel = (v) => {
53
+ const s = v / 255
54
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
55
+ }
56
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
57
+ }
58
+
59
+ /** 从 wrapper 样式里取背景色;`background:` 与 `background-color:` 都认。 */
60
+ export function wrapperBackground(theme) {
61
+ const styles = theme && typeof theme === 'object' ? theme.styles : null
62
+ const wrapper = styles && typeof styles.wrapper === 'string' ? styles.wrapper : ''
63
+ const m = /(?:^|;)\s*background(?:-color)?:\s*([^;]+)/i.exec(wrapper)
64
+ return m ? parseColor(m[1]) : null
65
+ }
66
+
67
+ /**
68
+ * 标注一个主题。
69
+ *
70
+ * @param {object} theme `themes[key]` 的定义(`{ name, emoji, desc, styles }`)
71
+ * @returns {{usesAccent: boolean, gradientText: boolean, darkWrapper: boolean, wechatSafe: boolean}}
72
+ */
73
+ export function classifyTheme(theme) {
74
+ const text = stylesText(theme)
75
+ const usesAccent = ACCENT_RE.test(text)
76
+ const gradientText = TEXT_CLIP_RE.test(text)
77
+ const bg = wrapperBackground(theme)
78
+ const darkWrapper = bg ? luminance(bg) < DARK_LUMINANCE : false
79
+ return { usesAccent, gradientText, darkWrapper, wechatSafe: !gradientText && !darkWrapper }
80
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * 模型自定义主题(**主题规格**):校验 + 合并。
3
+ *
4
+ * 为什么是数据而不是代码:内置主题本身就是"19 个样式槽位 → 一段 CSS 声明"的数据
5
+ * (`plugin/vendor/themes.js`),`makeStyler()` 只做 `{{PRIMARY}}` / `{{PRIMARY_BG}}` 占位符替换。
6
+ * 所以"让模型写主题"= 让模型写**同构的数据**,不需要求值任何模型给的代码。
7
+ *
8
+ * 两条硬约束,都在这里把关:
9
+ * 1. **必须在某个内置主题上做增量覆盖**。渲染器对缺失槽位的处理是"不加 style 属性"
10
+ * (`markdown.mjs`:`styles[key] ? ` style="…"` : ''`),所以"从零写一套"等于大部分元素没有样式。
11
+ * 合并后 19 个槽位必然齐备。
12
+ * 2. **属性白名单**。只放行内置主题实际用过的那批属性——那 11 套是真人实测能在公众号里活的;
13
+ * 名单之外的属性一律拒绝,免得模型写出一条微信会丢的声明,或者干脆是注入。
14
+ *
15
+ * 这一层是纯函数:不碰文件系统、不去求值。
16
+ */
17
+
18
+ /**
19
+ * 主题的样式槽位(与内置主题一一对应)。
20
+ * 加槽位要同时改 `plugin/vendor/themes.js` 与 `markdown.mjs` 的渲染规则,不是这里的自由项。
21
+ */
22
+ export const THEME_SLOTS = [
23
+ 'wrapper',
24
+ 'h1',
25
+ 'h2',
26
+ 'h3',
27
+ 'p',
28
+ 'blockquote',
29
+ 'code_inline',
30
+ 'code_block',
31
+ 'ul',
32
+ 'ol',
33
+ 'li',
34
+ 'img',
35
+ 'a',
36
+ 'table',
37
+ 'th',
38
+ 'td',
39
+ 'hr',
40
+ 'strong',
41
+ 'em',
42
+ ]
43
+
44
+ /**
45
+ * 允许出现在自定义主题里的 CSS 属性。
46
+ *
47
+ * **这份名单是统计出来的,不是挑出来的**:把 11 套内置主题的 19 个槽位全部拆开,
48
+ * 出现过的属性就是下面这 30 个(含 `-webkit-` 两个渐变文字用的)。要加属性请先给出
49
+ * "它在真实公众号里活下来"的证据,并同时补 `test/theme-spec.test.mjs` 的用例。
50
+ */
51
+ export const SPEC_PROPS = new Set([
52
+ 'margin',
53
+ 'color',
54
+ 'font-size',
55
+ 'padding',
56
+ 'background',
57
+ 'font-weight',
58
+ 'line-height',
59
+ 'border-radius',
60
+ 'border',
61
+ 'padding-left',
62
+ 'font-style',
63
+ 'text-align',
64
+ 'border-left',
65
+ 'font-family',
66
+ 'border-bottom',
67
+ 'letter-spacing',
68
+ 'overflow-x',
69
+ 'max-width',
70
+ 'display',
71
+ 'text-decoration',
72
+ 'width',
73
+ 'border-collapse',
74
+ 'height',
75
+ '-webkit-background-clip',
76
+ '-webkit-text-fill-color',
77
+ 'padding-bottom',
78
+ 'box-shadow',
79
+ 'text-indent',
80
+ 'border-image',
81
+ 'border-top',
82
+ ])
83
+
84
+ /**
85
+ * 主题色占位符:自定义主题照样能用它,于是面板的色板对它也有效。
86
+ *
87
+ * 两个形态是刻意的:`.test()` 用在**带 `g` 的正则**上会被 `lastIndex` 影响(上一轮匹配到哪,
88
+ * 下一轮就从哪开始),所以判定用不带 `g` 的那个,替换用带 `g` 的那个。
89
+ */
90
+ const PLACEHOLDER_RE = /\{\{PRIMARY(?:_BG)?\}\}/
91
+ const PLACEHOLDER_G = /\{\{PRIMARY(?:_BG)?\}\}/g
92
+
93
+ /**
94
+ * 明确不许出现的东西:注入面(`}` / `<` / **双引号**)、外链请求(`url(`)、脚本(`expression` / `javascript:`)、样式表指令。
95
+ *
96
+ * 双引号是必须禁的那一个:属性**一律由双引号包裹**(`markdown.mjs` 各槽位拼的就是 `style="…"`,
97
+ * `render.mjs` 的 wrapper 也是),值里出现 `"` 就闭合了属性、把后面的字节变成任意属性。
98
+ * 单引号放行:它在双引号包裹的属性里只是普通字符,闭合不了任何东西,而
99
+ * `font-family: 'Georgia', serif` 是合法且常见的写法,禁它只是白白挡住常见写法。
100
+ * (管线后段只有单向的 `"` → `'` 降级,没有反向把 `'` 变回 `"` 的路径,放行不会又被武装回来。)
101
+ *
102
+ * 判定前先把**合法占位符**摘掉:`{{PRIMARY}}` 自己就带花括号,不摘的话所有想用主题色的主题
103
+ * 都会被自己的注入检查拦下(占位符是这套机制的一部分,不是注入面)。
104
+ */
105
+ const FORBIDDEN_RE = /[{}<>"]|url\s*\(|expression\s*\(|@import|javascript:/i
106
+
107
+ function stripPlaceholders(text) {
108
+ return String(text).replace(PLACEHOLDER_G, 'P')
109
+ }
110
+
111
+ const MAX_NAME = 24
112
+ const MAX_DECLS = 24
113
+ const MAX_VALUE = 200
114
+
115
+ /**
116
+ * 规格只认这三个键。
117
+ *
118
+ * `emoji` / `icon` 之类**故意不收**:图标由鱼排统一提供。
119
+ * 合法图标名的名单只存在于浏览器那半(`@deepseek-ai/dsh-client-ui-primitives` 的 `Icon*` 导出),
120
+ * 而校验器在宿主这半——让模型选名字就得在宿主再抄一份名单并跟着 DSH 升级维护,
121
+ * 抄漏一个的后果是**图标静默消失且不报错**。统一图标只需客户端一行降级逻辑。
122
+ * 多写的键一律报错(而不是默默忽略):写了没作用,比不让写更容易骗人。
123
+ */
124
+ const SPEC_KEYS = ['name', 'base', 'styles']
125
+
126
+ function fail(message) {
127
+ throw new Error(message)
128
+ }
129
+
130
+ function cleanName(raw, fallback) {
131
+ const name = String(raw ?? '')
132
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
133
+ .replace(/\s+/g, ' ')
134
+ .trim()
135
+ if (!name) return fallback
136
+ if (name.length > MAX_NAME) fail(`主题名太长(最多 ${MAX_NAME} 个字):${name.slice(0, MAX_NAME)}…`)
137
+ return name
138
+ }
139
+
140
+ /** 把一段 CSS 声明拆成 `[prop, value]`,逐条过白名单;返回规范化后的声明串。 */
141
+ function parseDeclarations(slot, css, notes) {
142
+ const text = String(css ?? '')
143
+ if (/[\r\n]/.test(text)) fail(`槽位 ${slot} 的样式不能换行(写成一行,用 ; 分隔)`)
144
+ if (FORBIDDEN_RE.test(stripPlaceholders(text))) {
145
+ fail(`槽位 ${slot} 里出现了不允许的内容({}、<>、双引号、url()、expression、@import 都不能用;单引号可以;{{PRIMARY}} 这类占位符是允许的)`)
146
+ }
147
+ const out = []
148
+ for (const raw of text.split(';')) {
149
+ const decl = raw.trim()
150
+ if (!decl) continue
151
+ const m = /^([-a-zA-Z]+)\s*:\s*(\S[\s\S]*)$/.exec(decl)
152
+ if (!m) fail(`槽位 ${slot} 里有看不懂的声明:「${decl}」(要写成 "属性: 值")`)
153
+ const prop = m[1].toLowerCase()
154
+ let value = m[2].trim()
155
+ if (!SPEC_PROPS.has(prop)) {
156
+ fail(`槽位 ${slot} 用了不支持的属性「${prop}」。允许的属性见技能 fishpai 的自定义主题一节(内置主题用过的那批)`)
157
+ }
158
+ if (value.length > MAX_VALUE) fail(`槽位 ${slot} 的属性「${prop}」值太长(最多 ${MAX_VALUE} 字符)`)
159
+ // `!important` 在微信里留不住,留着只会让模型误以为"这样就能强制生效"
160
+ if (/!\s*important/i.test(value)) {
161
+ value = value.replace(/\s*!\s*important/gi, '')
162
+ notes.push(`槽位 ${slot} 的「${prop}」去掉了 !important(微信不保留它,留着会误判)`)
163
+ }
164
+ if (!value) fail(`槽位 ${slot} 的属性「${prop}」没有值`)
165
+ out.push(`${prop}: ${value}`)
166
+ if (out.length > MAX_DECLS) fail(`槽位 ${slot} 的声明太多(最多 ${MAX_DECLS} 条)`)
167
+ }
168
+ return out.join('; ') + (out.length ? ';' : '')
169
+ }
170
+
171
+ /** wrapper 上必须有这两条:面板的「字体」「字号」是靠替换它们生效的(漏了就变成死控件)。 */
172
+ const WRAPPER_REQUIRED = ['font-family', 'font-size']
173
+
174
+ function inheritDecl(baseWrapper, prop) {
175
+ const re = new RegExp(`(?:^|;)\\s*(${prop}\\s*:[^;]+)`, 'i')
176
+ const m = re.exec(String(baseWrapper || ''))
177
+ return m ? m[1].trim() : null
178
+ }
179
+
180
+ /**
181
+ * 校验一份主题规格。
182
+ *
183
+ * @param {object} spec `{ name?, base?, styles? }`
184
+ * @param {Record<string, object>} baseThemes 内置主题表(`runtime.themes()`)
185
+ * @returns {{name: string, base: string, styles: Record<string,string>, notes: string[]}}
186
+ * @throws {Error} 文案是给模型看的:说清哪个槽位、哪个属性、为什么
187
+ */
188
+ export function validateThemeSpec(spec, baseThemes) {
189
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
190
+ fail('theme_spec 必须是一个对象:{ name, base, styles }')
191
+ }
192
+ const extra = Object.keys(spec).filter((k) => !SPEC_KEYS.includes(k))
193
+ if (extra.length) {
194
+ fail(`主题规格只认 ${SPEC_KEYS.join(' / ')},多了:${extra.join(' / ')}(图标与 emoji 由鱼排统一给,不用你选)`)
195
+ }
196
+ const notes = []
197
+ const base = String(spec.base ?? 'default')
198
+ const baseTheme = baseThemes && baseThemes[base]
199
+ if (!baseTheme) {
200
+ fail(`base 必须是已有的主题 key,没有「${base}」。可选:${Object.keys(baseThemes || {}).join(' / ')}`)
201
+ }
202
+ const name = cleanName(spec.name, `${baseTheme.name}·自定义`)
203
+
204
+ const rawStyles = spec.styles
205
+ if (rawStyles === undefined || rawStyles === null) {
206
+ fail('styles 不能为空:至少要写一个想改的槽位(例如 {"p": "line-height: 2;"})')
207
+ }
208
+ if (typeof rawStyles !== 'object' || Array.isArray(rawStyles)) fail('styles 必须是对象:{ 槽位: "CSS 声明" }')
209
+ const keys = Object.keys(rawStyles)
210
+ if (!keys.length) fail('styles 里一个槽位都没有:至少要写一个想改的槽位')
211
+
212
+ const styles = {}
213
+ for (const slot of keys) {
214
+ if (!THEME_SLOTS.includes(slot)) {
215
+ fail(`不认识的槽位「${slot}」。可用槽位:${THEME_SLOTS.join(' / ')}`)
216
+ }
217
+ const css = rawStyles[slot]
218
+ if (typeof css !== 'string') fail(`槽位 ${slot} 必须是字符串(形如 "font-size: 18px; color: #333;")`)
219
+ const normalized = parseDeclarations(slot, css, notes)
220
+ if (!normalized) fail(`槽位 ${slot} 是空的:要么写内容,要么别写这个键`)
221
+ styles[slot] = normalized
222
+ }
223
+
224
+ // wrapper 是唯一的硬性缺口:模型很容易只写 line-height / color,漏掉字体字号,
225
+ // 结果面板上「字体」「字号」两个控件点了没反应——那正是最不该出现的"死控件"。
226
+ if (styles.wrapper) {
227
+ const missing = WRAPPER_REQUIRED.filter((prop) => !inheritDecl(styles.wrapper, prop))
228
+ if (missing.length) {
229
+ const parts = []
230
+ for (const prop of missing) {
231
+ const inherited = inheritDecl(baseTheme.styles && baseTheme.styles.wrapper, prop)
232
+ if (!inherited) fail(`槽位 wrapper 缺少 ${prop},且 base 主题里也没有,无法补全`)
233
+ parts.push(inherited)
234
+ }
235
+ styles.wrapper = `${styles.wrapper} ${parts.join('; ')};`.replace(/\s+/g, ' ')
236
+ notes.push(
237
+ `wrapper 自动补上了 ${missing.join(' / ')}(从 base 继承)——漏了它们,面板的「字体」「字号」会变成点了没反应的死控件`,
238
+ )
239
+ }
240
+ }
241
+
242
+ if (!PLACEHOLDER_RE.test(Object.values(styles).join(';'))) {
243
+ notes.push('没有用到 {{PRIMARY}}:面板的「主题色」色板对这个主题不会有效果(这没有错,只是提醒)')
244
+ }
245
+ return { name, base, styles, notes }
246
+ }
247
+
248
+ /**
249
+ * 把校验过的规格合并到 base 主题上,得到一份完整主题(19 个槽位齐备)。
250
+ *
251
+ * @param {object} baseTheme `baseThemes[spec.base]`
252
+ * @param {{name: string, styles: Record<string,string>}} spec `validateThemeSpec()` 的产物
253
+ * @returns {object} 可以直接交给 `render({theme})` 的主题对象
254
+ */
255
+ export function mergeTheme(baseTheme, spec) {
256
+ return {
257
+ key: 'custom',
258
+ name: spec.name,
259
+ emoji: '✳️',
260
+ desc: `自定义主题(基于「${baseTheme.name}」)`,
261
+ custom: true,
262
+ styles: { ...(baseTheme.styles || {}), ...spec.styles },
263
+ }
264
+ }
265
+
266
+ /** 一步到位:校验 + 合并。给工具与路由用。 */
267
+ export function buildCustomTheme(spec, baseThemes) {
268
+ const checked = validateThemeSpec(spec, baseThemes)
269
+ return { theme: mergeTheme(baseThemes[checked.base], checked), spec: checked }
270
+ }
271
+
272
+ /** 给模型看的一行说明(工具返回里带上,省得它去猜自己写了什么)。 */
273
+ export function describeThemeSpec(spec) {
274
+ const slots = Object.keys(spec.styles)
275
+ return `自定义主题「${spec.name}」(base=${spec.base})改了 ${slots.length} 个槽位:${slots.join('、')}`
276
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 图片处理:本地图片 → data URI(复制/导出时内嵌),以及给预览用的静态读取。
3
+ *
4
+ * 为什么在宿主侧做而不是浏览器 canvas:文档里的图片是**磁盘上的相对路径**
5
+ * (写文章的人本来就这么引图)。浏览器拿不到它,宿主能;而且不需要 CORS 与 canvas。
6
+ *
7
+ * 上游只对 blob:/data: 的图做内嵌(浏览器里粘贴进来的图)。鱼排多做一步:
8
+ * 相对路径的本地图片也能内嵌,粘进公众号编辑器时图片不会丢。
9
+ */
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import { IMAGE_EXTS, MAX_ASSET_BYTES, mimeFor, resolveInCwd } from './store.mjs'
13
+
14
+ /**
15
+ * 单张图内嵌上限:**与"能不能存进来"同一个上限**(`MAX_ASSET_BYTES`)。
16
+ * 两处各写一个数字的话,迟早出现"存得进来、却内嵌不了"的图,而面板只会说"需手动上传"。
17
+ */
18
+ export const MAX_EMBED_BYTES = MAX_ASSET_BYTES
19
+
20
+ function isRemote(src) {
21
+ return /^(https?:|data:|blob:|\/\/)/i.test(String(src || ''))
22
+ }
23
+
24
+ /**
25
+ * 造一个 `imageResolver` 给 `render()` 用:把 <img src> 换成 data URI。
26
+ *
27
+ * @param {{cwd: string, docPath: string, maxBytes?: number}} ctx
28
+ * @returns {(src: string) => string | null} 返回 null 表示保持原样
29
+ */
30
+ export function makeImageResolver({ cwd, docPath, maxBytes = MAX_EMBED_BYTES }) {
31
+ const baseDir = path.dirname(docPath)
32
+ return (src) => {
33
+ if (!src || isRemote(src)) return null
34
+ let abs
35
+ try {
36
+ abs = resolveInCwd(cwd, path.isAbsolute(src) ? src : path.join(baseDir, src), { exts: IMAGE_EXTS })
37
+ } catch {
38
+ return null // 越界或不是图片:原样保留,交给微信去处理
39
+ }
40
+ let stat
41
+ try {
42
+ stat = fs.statSync(abs)
43
+ } catch {
44
+ return null
45
+ }
46
+ if (!stat.isFile() || stat.size > maxBytes) return null
47
+ const data = fs.readFileSync(abs)
48
+ return `data:${mimeFor(abs)};base64,${data.toString('base64')}`
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 读一张本地图片给预览 iframe 用(`/fishpai/api/asset`)。
54
+ *
55
+ * @returns {{bytes: Buffer, mime: string} | null}
56
+ */
57
+ export function readAsset({ cwd, docPath, src, maxBytes = 20 * 1024 * 1024 }) {
58
+ if (!src || isRemote(src)) return null
59
+ const baseDir = path.dirname(docPath)
60
+ let abs
61
+ try {
62
+ abs = resolveInCwd(cwd, path.isAbsolute(src) ? src : path.join(baseDir, src), { exts: IMAGE_EXTS })
63
+ } catch {
64
+ return null
65
+ }
66
+ try {
67
+ const stat = fs.statSync(abs)
68
+ if (!stat.isFile() || stat.size > maxBytes) return null
69
+ return { bytes: fs.readFileSync(abs), mime: mimeFor(abs) }
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Markdown 里引用的**外链图**清单:这些是微信会拦掉的那一类("此图片来自…未经允许不可引用"),
77
+ * 复制过去也不显示,必须手动重新上传。本地图不走这里——它们会被内嵌成 base64 带过去。
78
+ */
79
+ export function listRemoteImages({ markdown }) {
80
+ const out = []
81
+ const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
82
+ let m
83
+ while ((m = re.exec(String(markdown || ''))) !== null) {
84
+ if (isRemote(m[1])) out.push({ src: m[1] })
85
+ }
86
+ return out
87
+ }
88
+
89
+ /**
90
+ * Markdown 里引用的本地图片清单,并标出**能不能被内嵌**。
91
+ *
92
+ * 只有 `embed: true` 的图会被 base64 内嵌进剪贴板、跟着粘贴一起进公众号编辑器;
93
+ * 其余的(文件不在、越界、超过大小上限)不会被内嵌,粘过去大概率不显示,需要手动上传。
94
+ * 面板与 `fishpai_read` 都靠 `embed` 这一列说人话。
95
+ */
96
+ export function listLocalImages({ markdown, cwd, docPath, maxBytes = MAX_EMBED_BYTES }) {
97
+ const baseDir = path.dirname(docPath)
98
+ const out = []
99
+ const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
100
+ let m
101
+ while ((m = re.exec(String(markdown || ''))) !== null) {
102
+ const src = m[1]
103
+ if (isRemote(src)) continue
104
+ let status = 'ok'
105
+ let size = null
106
+ try {
107
+ const abs = resolveInCwd(cwd, path.isAbsolute(src) ? src : path.join(baseDir, src), { exts: IMAGE_EXTS })
108
+ if (!fs.existsSync(abs)) {
109
+ status = 'missing'
110
+ } else {
111
+ size = fs.statSync(abs).size
112
+ if (size > maxBytes) status = 'too-large'
113
+ }
114
+ } catch {
115
+ status = 'outside'
116
+ }
117
+ out.push({ src, status, size, embed: status === 'ok' })
118
+ }
119
+ return out
120
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 工作目录级的**自定义主题**:一个工作目录只有一套,存在 `.fishpai/theme.json`。
3
+ *
4
+ * 为什么不是"主题库":面板上只留**一个**「自定义主题」占位,模型改一次就覆盖一次——
5
+ * 不需要命名、列表、删除这一堆管理面,用户也不必维护一个主题清单。
6
+ *
7
+ * 路径由宿主拼死(**不接受**调用方传路径),所以这里给 `resolveInCwd` 放行 `.json`
8
+ * 不会变成"任意 JSON 读写":`.fishpai/state/*.json` 那些仍然不可达。
9
+ */
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import { safeThemeKey } from '../core/render.mjs'
13
+ import { themes } from '../core/runtime.mjs'
14
+ import { buildCustomTheme } from '../core/theme-spec.mjs'
15
+ import { classifyTheme } from '../core/theme-info.mjs'
16
+ import { ensureFishpaiLayout, resolveInCwd } from './store.mjs'
17
+
18
+ /** 主题列表里那个占位的 key(面板按它选中)。 */
19
+ export const CUSTOM_THEME_KEY = 'custom'
20
+ /** 占位的显示名:固定一个,不跟着模型起的名字变(面板上只留一个占位)。 */
21
+ export const CUSTOM_THEME_LABEL = '自定义主题'
22
+
23
+ const STORE_VERSION = 1
24
+
25
+ function themePath(cwd) {
26
+ return resolveInCwd(cwd, path.join('.fishpai', 'theme.json'), { exts: ['.json'] })
27
+ }
28
+
29
+ /**
30
+ * 读当前工作目录的自定义主题。
31
+ *
32
+ * 坏文件/校验不过 → `null`(当"没有"处理):一份手改坏的 `theme.json` 不该让面板打不开,
33
+ * 也不该让一篇文档渲染不出来。`missing` 与"坏掉了"在这里是同一件事。
34
+ *
35
+ * @returns {{spec: object, theme: object} | null}
36
+ */
37
+ export function readCustomTheme(cwd) {
38
+ try {
39
+ const abs = themePath(cwd)
40
+ if (!fs.existsSync(abs)) return null
41
+ const raw = JSON.parse(fs.readFileSync(abs, 'utf8'))
42
+ const spec = raw && typeof raw === 'object' && raw.spec ? raw.spec : raw
43
+ return buildCustomTheme(spec, themes())
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 写自定义主题(覆盖那唯一一套)。
51
+ * @throws {Error} 规格不合法时抛出——文案是给模型看的,让它自己改对再来
52
+ */
53
+ export function writeCustomTheme({ cwd, spec }) {
54
+ ensureFishpaiLayout(cwd)
55
+ const built = buildCustomTheme(spec, themes())
56
+ const abs = themePath(cwd)
57
+ // 只存**数据**三件套:校验产物还带着 `notes`(那是给模型的运行时提示),
58
+ // 整个存下去的话,读回来会被"只认 name/base/styles"的规则拒掉——存了等于没存(测试抓到过)。
59
+ const saved = { name: built.spec.name, base: built.spec.base, styles: built.spec.styles }
60
+ // 临时文件 + rename:写一半留下坏 JSON 会让面板下次直接"没有自定义主题"
61
+ const tmp = `${abs}.${process.pid}.tmp`
62
+ fs.writeFileSync(tmp, `${JSON.stringify({ version: STORE_VERSION, spec: saved }, null, 2)}\n`, 'utf8')
63
+ fs.renameSync(tmp, abs)
64
+ return built
65
+ }
66
+
67
+ /** 移除自定义主题。返回是否真的删掉了东西。 */
68
+ export function clearCustomTheme(cwd) {
69
+ try {
70
+ const abs = themePath(cwd)
71
+ if (!fs.existsSync(abs)) return false
72
+ fs.unlinkSync(abs)
73
+ return true
74
+ } catch {
75
+ return false
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 把文档状态里的主题名解析成 `render()` 能用的东西。
81
+ *
82
+ * `'custom'` 是唯一需要查磁盘的 key:那套主题不在内置表里,找不到就**退回默认主题**
83
+ * (与 `safeThemeKey` 对未知主题的处理一致——别让一篇文档因为主题文件被删就打不开)。
84
+ *
85
+ * @returns {{theme: string|object, key: string, custom: boolean, missing: boolean}}
86
+ */
87
+ export function themeFor({ cwd, name }) {
88
+ if (name !== CUSTOM_THEME_KEY) {
89
+ const key = safeThemeKey(name)
90
+ return { theme: key, key, custom: false, missing: false }
91
+ }
92
+ const custom = readCustomTheme(cwd)
93
+ if (!custom) return { theme: 'default', key: 'default', custom: false, missing: true }
94
+ return { theme: custom.theme, key: CUSTOM_THEME_KEY, custom: true, missing: false }
95
+ }
96
+
97
+ /**
98
+ * 主题清单里的那一个占位项(没有自定义主题时返回 `null`,面板就不显示这一组——
99
+ * 宁可没有这一行,也不要一个点了没反应的灰项)。
100
+ *
101
+ * 能力标注照旧由 `classifyTheme()` 从主题定义推导:模型写了渐变文字/深色底,
102
+ * 面板就会把它归进「微信可能掉样式」那一组,跟内置主题一个待遇。
103
+ */
104
+ export function customCatalogEntry(cwd) {
105
+ const custom = readCustomTheme(cwd)
106
+ if (!custom) return null
107
+ return {
108
+ key: CUSTOM_THEME_KEY,
109
+ name: CUSTOM_THEME_LABEL,
110
+ emoji: custom.theme.emoji || '✳️',
111
+ desc: `模型生成:${custom.spec.name}(基于「${themes()[custom.spec.base].name}」)`,
112
+ ...classifyTheme(custom.theme),
113
+ }
114
+ }
115
+
116
+ /** 给工具/模型看的当前规格(`fishpai_theme` 的 show 用)。 */
117
+ export function describeCustomTheme(cwd) {
118
+ const custom = readCustomTheme(cwd)
119
+ if (!custom) return null
120
+ return { spec: custom.spec, theme: custom.theme }
121
+ }