cardbuddy 0.14.2 → 0.15.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.
Files changed (68) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/{index-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
  3. package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/mcp/client.mjs +8 -1
  7. package/dist/vendored/server/core/ai-chat.js +1521 -124
  8. package/dist/vendored/server/core/browser.js +12 -3
  9. package/dist/vendored/server/core/cardFiles.js +3 -0
  10. package/dist/vendored/server/core/cardHistory.js +1 -0
  11. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  12. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  13. package/dist/vendored/server/core/cardSave.js +1 -0
  14. package/dist/vendored/server/core/cardSync.js +3 -0
  15. package/dist/vendored/server/core/codeBridge.js +5 -1
  16. package/dist/vendored/server/core/logStream.js +1 -0
  17. package/dist/vendored/server/core/sliceStatic.js +24 -4
  18. package/dist/vendored/server/core/sliceUpload.js +1 -0
  19. package/dist/vendored/server/core/turnJournal.js +6 -0
  20. package/dist/vendored/skills/card-skill-trainer/SKILL.md +196 -195
  21. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  22. package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
  23. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
  24. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
  25. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
  26. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  27. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
  28. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  29. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +14 -0
  30. package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
  31. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  33. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +4 -3
  34. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  36. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
  37. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
  38. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
  41. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -0
  42. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  43. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +644 -42
  46. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  49. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  50. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5443 -0
  51. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
  52. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
  53. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
  54. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
  55. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  56. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
  57. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
  58. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  59. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +186 -0
  60. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
  61. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
  62. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
  63. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
  64. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
  65. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
  66. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
  67. package/package.json +1 -1
  68. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
@@ -0,0 +1,131 @@
1
+ /**
2
+ * geometry_audit.mjs —— 用**纯几何事实**判「节点是不是看不见 / 跑出画布 / 被折成一堆行」。
3
+ *
4
+ * 为什么需要它(2026-09-21 复盘):上一轮建卡跑了 30 分钟不收敛,直接原因是模型**看不见渲染结果**
5
+ * (card_screenshot 只给人看、不进模型上下文),可观测输入只剩 match / heightDelta / bands 三个数字,
6
+ * 于是全程盲调参。而那两个致命缺陷(Row 内横向容器默认 100% 吃掉整行 → 文本被压成 0 宽、
7
+ * 兄弟被顶到 x=356 卡外)其实**不需要看图就能判定**:它们全是几何事实。
8
+ *
9
+ * 本模块只判可由 `getBoundingClientRect()` + computedStyle 直接证实的三类,不做任何猜测:
10
+ * 1. collapsed(**error**):在流里、有可见内容、却被压成零尺寸 → 用户看不见它,千真万确。
11
+ * 2. outOfCard(**error**):部分或全部落在卡片矩形之外 → 卡片根节 overflow:hidden,必然被裁。
12
+ * 3. overWrapped(**warn**):文本高度 > 2 行(h / lineHeight > 2)→ 多半是被挤窄后逐字换行,
13
+ * 也可能是设计稿本来就是多行文本,故只提示不拦门禁。
14
+ *
15
+ * 判定阈值都用「1x 设计像素」,与 iterate 的 match/heightDelta 同一坐标系。
16
+ */
17
+
18
+ /** 被视为「图形内容」的组件类:零尺寸时一定是缺陷(文本另有 text 判据)。 */
19
+ const GRAPHIC_CLASSES = new Set(['Image', 'Icon', 'Video', 'Map', 'Svgsymbols', 'SvgSymbols', 'MultiText'])
20
+
21
+ /** 文本节点折行阈值:h / lineHeight 超过这个倍数即提示(2 = 两行,>2 即三行以上)。 */
22
+ const OVERWRAP_RATIO = 2
23
+
24
+ const round1 = (n) => Math.round(Number(n) * 10) / 10
25
+
26
+ /**
27
+ * @param {object} opts
28
+ * @param {Array<object>} opts.elements render_card 产出的 elementBoxes(正常尺寸节点)
29
+ * @param {Array<object>} [opts.degenerate] render_card 产出的 elementBoxesDegenerate(零尺寸节点)
30
+ * @param {number} opts.cardWidth 卡片逻辑宽(1x,= `.phone__screen` 实测宽)
31
+ * @param {number} [opts.tolerance] 可接受误差(px),默认 1
32
+ * @returns {{collapsed:Array,outOfCard:Array,overWrapped:Array,counts:object}}
33
+ */
34
+ export function auditGeometry({ elements, degenerate, cardWidth, tolerance = 1 }) {
35
+ const collapsed = []
36
+ const outOfCard = []
37
+ const overWrapped = []
38
+ const width = Number(cardWidth) || 0
39
+
40
+ for (const el of degenerate || []) {
41
+ const hasContent = Boolean(String(el.text || '').trim()) || GRAPHIC_CLASSES.has(el.cls)
42
+ if (!hasContent) continue // 空容器被压扁不报警(它本来就不显示东西)
43
+ collapsed.push({
44
+ cid: el.cid,
45
+ cls: el.cls,
46
+ text: String(el.text || '').slice(0, 24),
47
+ x: round1(el.x),
48
+ y: round1(el.y),
49
+ w: round1(el.w),
50
+ h: round1(el.h),
51
+ reason: Number(el.w) < tolerance
52
+ ? '宽被压成 0(同排兄弟把整行吃光了)→ 文本每字一行、实际不可读'
53
+ : '高被压成 0(父容器或兄弟把高度挤没了)',
54
+ })
55
+ }
56
+
57
+ for (const el of elements || []) {
58
+ const w = Number(el.w) || 0
59
+ const h = Number(el.h) || 0
60
+ if (w < tolerance || h < tolerance) {
61
+ // 正常集合里出现零尺寸(例如被 1px 容差漏过的边界情况)→ 同样按压扁处理
62
+ const hasContent = Boolean(String(el.text || '').trim()) || GRAPHIC_CLASSES.has(el.cls)
63
+ if (hasContent) collapsed.push({ cid: el.cid, cls: el.cls, text: String(el.text || '').slice(0, 24), x: round1(el.x), y: round1(el.y), w: round1(w), h: round1(h), reason: '尺寸被压到 1px 以下' })
64
+ continue
65
+ }
66
+ if (width > 0) {
67
+ const overRight = round1(el.x + w - width)
68
+ const overLeft = round1(-el.x)
69
+ if (overRight > tolerance || overLeft > tolerance) {
70
+ outOfCard.push({
71
+ cid: el.cid,
72
+ cls: el.cls,
73
+ text: String(el.text || '').slice(0, 24),
74
+ x: round1(el.x),
75
+ y: round1(el.y),
76
+ w: round1(w),
77
+ h: round1(h),
78
+ overflowRight: overRight > tolerance ? overRight : 0,
79
+ overflowLeft: overLeft > tolerance ? overLeft : 0,
80
+ reason: overRight > tolerance
81
+ ? `右缘超出卡片 ${overRight}px(卡宽 ${width})→ 被根节点 overflow:hidden 裁掉`
82
+ : `左缘超出卡片 ${overLeft}px → 被裁掉`,
83
+ })
84
+ }
85
+ }
86
+ const lh = Number(el.lh) || 0
87
+ const text = String(el.text || '').trim()
88
+ if (text && lh > 0 && h / lh > OVERWRAP_RATIO) {
89
+ overWrapped.push({
90
+ cid: el.cid,
91
+ cls: el.cls,
92
+ text: text.slice(0, 24),
93
+ x: round1(el.x),
94
+ y: round1(el.y),
95
+ w: round1(w),
96
+ h: round1(h),
97
+ lines: Math.round(h / lh),
98
+ reason: `约占 ${Math.round(h / lh)} 行(h=${round1(h)} / 行高 ${round1(lh)})→ 检查是否被挤窄后逐字换行;设计稿本来就是多行文本可忽略`,
99
+ })
100
+ }
101
+ }
102
+
103
+ return {
104
+ collapsed,
105
+ outOfCard,
106
+ overWrapped,
107
+ counts: { collapsed: collapsed.length, outOfCard: outOfCard.length, overWrapped: overWrapped.length },
108
+ }
109
+ }
110
+
111
+ /** 把审计结果压成一行给人/模型看的结论;无异常返回 null。 */
112
+ export function describeGeometry(audit) {
113
+ if (!audit) return null
114
+ const parts = []
115
+ if (audit.collapsed.length) {
116
+ parts.push(
117
+ `被压成零尺寸(用户完全看不见)${audit.collapsed.length} 个:` +
118
+ audit.collapsed.slice(0, 4).map((c) => `${c.cls} ${c.cid}${c.text ? ` "${c.text}"` : ''}`).join('、'),
119
+ )
120
+ }
121
+ if (audit.outOfCard.length) {
122
+ parts.push(
123
+ `跑出卡片被裁掉 ${audit.outOfCard.length} 个:` +
124
+ audit.outOfCard.slice(0, 4).map((c) => `${c.cls} ${c.cid}${c.text ? ` "${c.text}"` : ''}(x=${c.x} w=${c.w})`).join('、'),
125
+ )
126
+ }
127
+ if (audit.overWrapped.length) {
128
+ parts.push(`疑似折行异常 ${audit.overWrapped.length} 个:` + audit.overWrapped.slice(0, 3).map((c) => `${c.cls} ${c.cid} ≈${c.lines} 行`).join('、'))
129
+ }
130
+ return parts.length ? parts.join(';') : null
131
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * layout_rules.mjs —— 布局类硬契约的共享判定(lint_card.mjs / preflight.mjs 单一来源)。
3
+ *
4
+ * 为什么要有这个模块:同一条布局规则在 lint(迭代门禁)与 preflight(生成前检查)里各写一遍,
5
+ * 迟早会打架——一处改了、另一处没改,模型就会看到「同一张卡两个门禁结论不同」。R25/R31/R32 已
6
+ * 经是双写的,这里不再复制第三份。
7
+ *
8
+ * 本模块只放**从渲染器契约推导出来的、可静态判定**的事实,不猜设计意图:
9
+ * ① 宽度基线:component-meta.json 里声明了 slots 的组件(=容器)默认 `rect.width:"100%"`,
10
+ * 且该默认**只在 rect 组整体缺失(或没有 width 子项)时生效**;写了 `width:""` 就是 auto;
11
+ * ② 主轴方向:CSS 强制 `.ce_root/.ce_column/.ce_singlecolumn` 为 column,其余默认 row。
12
+ */
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+
16
+ import { metaDir } from './paths.mjs'
17
+
18
+ /** CSS 强制 flex-direction:column 的容器类(card-editor/styles/components.css)。 */
19
+ export const COLUMN_FLOW_CLASSES = new Set(['Root', 'Column', 'SingleColumn'])
20
+
21
+ let _metaCache = null
22
+ function componentMeta() {
23
+ if (_metaCache === null) {
24
+ try {
25
+ _metaCache = JSON.parse(fs.readFileSync(path.join(metaDir, 'component-meta.json'), 'utf8')).components || {}
26
+ } catch {
27
+ _metaCache = {}
28
+ }
29
+ }
30
+ return _metaCache
31
+ }
32
+
33
+ /** 容器类 = component-meta 里声明了 slots(能装子节点)的组件。 */
34
+ export function containerClasses() {
35
+ const out = new Set()
36
+ for (const [cls, def] of Object.entries(componentMeta())) {
37
+ if ((def?.slots || []).length > 0) out.add(cls)
38
+ }
39
+ // meta 读不到时的兜底(保持规则可用,不至于静默失效)
40
+ if (!out.size) for (const c of ['Row', 'Column', 'MultiColumn', 'ListLayout', 'GridLayout', 'AbsoluteLayout', 'Canvas', 'ViewContainer']) out.add(c)
41
+ return out
42
+ }
43
+
44
+ /**
45
+ * 容器的宽度基线是不是「填满父容器」(渲染器默认 100%)。
46
+ * 未写 rect.width 的容器会拿到 component-meta 的默认值,实测渲染为 100%。
47
+ */
48
+ export function defaultWidth100Classes() {
49
+ const out = new Set()
50
+ for (const cls of containerClasses()) {
51
+ const w = componentMeta()[cls]?.styleState?.rect?.width
52
+ if (w === '100%') out.add(cls)
53
+ }
54
+ if (!out.size) for (const c of ['Row', 'Column', 'MultiColumn', 'ListLayout', 'GridLayout', 'AbsoluteLayout', 'Canvas', 'ViewContainer']) out.add(c)
55
+ return out
56
+ }
57
+
58
+ /** 取 style.<group>.<key> 的字面值与来源开关(itemValueFrom)。 */
59
+ export function styleChild(node, group, key) {
60
+ for (const g of node?.style?.properties || []) {
61
+ if (g.itemKey !== group) continue
62
+ for (const c of g.itemChildren?.properties || []) {
63
+ if (c.itemKey === key) return { value: String(c.itemValue ?? '').trim(), from: String(c.itemValueFrom ?? '0') }
64
+ }
65
+ }
66
+ return null
67
+ }
68
+
69
+ /** 父容器的主轴是横向(row)吗?CSS 默认按类定,schema 显式写 flexDirection 才覆盖。 */
70
+ export function isHorizontalFlow(parent) {
71
+ if (!parent) return false
72
+ const explicit = styleChild(parent, 'flex', 'flexDirection')?.value
73
+ if (COLUMN_FLOW_CLASSES.has(parent.className)) return explicit === 'row'
74
+ return (explicit || 'row') === 'row'
75
+ }
76
+
77
+ /**
78
+ * R22b:横向流父容器里的**容器类子节点**,`rect.width` 缺失且 `flexShrink:0`。
79
+ *
80
+ * 渲染契约(2026-09-21 实测定标,见下):
81
+ * | rect 写法 | 容器实测渲染宽 | 同排兄弟 |
82
+ * |---------------------|------------------|-----------------------|
83
+ * | rect 组缺失 / 无 width 子项 | **100%(吃满整行)** | 长文本被压成 w=0 → 每字一行、不可见 ❌ |
84
+ * | `width: ""` | auto(自然宽) | 正常 ✓ |
85
+ * | `width: "100%"` | 100% | 被压成 0 ❌(R22 管这一半) |
86
+ * | `width: "60"` | 60px | 正常 ✓ |
87
+ * 叠加 `flexShrink:0` = 锁死不让位,于是「吃满整行」那个必然把兄弟挤没或顶出卡外。
88
+ *
89
+ * 与 R22 的关系:R22 拦的是 schema **显式写了** `width:"100%"` 那一半;本规则补的是
90
+ * **rect.width 整个缺失**(渲染等价于 100%)却完全没人管的另一半 ——
91
+ * 实测 demo/helloCard 的 6 个致命节点全落在这个盲区。
92
+ *
93
+ * 注意 `width: ""` **不算缺失**:它是这套 schema 的「auto」表示法,实测渲染为自然宽,安全。
94
+ *
95
+ * 仅在父容器有 ≥2 个子节点时成立(独子占满整行是合法用法)。
96
+ *
97
+ * @returns {{className:string,widthVal:null,shrinkVal:string}|null} 命中时返回证据
98
+ */
99
+ export function rowChildContainerNoWidth({ node, parent, siblingCount }) {
100
+ if (!node || !parent) return null
101
+ if (siblingCount < 2) return null
102
+ if (!containerClasses().has(node.className)) return null
103
+ if (!defaultWidth100Classes().has(node.className)) return null
104
+ if (!isHorizontalFlow(parent)) return null
105
+ // width 子项「存在」就算已显式指定(含空串 "":auto 表示法,实测安全);
106
+ // 只有整组缺失 / 没有 width 子项才会落到 component-meta 默认 "100%"。
107
+ if (styleChild(node, 'rect', 'width')) return null
108
+ const shrink = styleChild(node, 'flex', 'flexShrink')
109
+ if (shrink?.value !== '0') return null
110
+ return { className: node.className, widthVal: null, shrinkVal: shrink.value }
111
+ }
112
+
113
+ /** 默认插槽直接子节点(兼容 Root.components 与普通节点 slots[0].components)。 */
114
+ export function directChildren(node) {
115
+ if (!node) return []
116
+ const slot = (node.slots || [])[0]
117
+ if (slot && Array.isArray(slot.components)) return slot.components
118
+ return Array.isArray(node.components) ? node.components : []
119
+ }
120
+
121
+ /**
122
+ * 在 schema 树里收集全部 R22b 命中(供 lint / preflight 共用)。
123
+ * @param {object} view schema.view
124
+ * @returns {Array<{componentId:string,className:string,parentId:string,parentClass:string,siblingCount:number,message:string}>}
125
+ */
126
+ export function collectRowChildNoWidth(view) {
127
+ const hits = []
128
+ // Root(view)自身是 column 流,其直接子节点不参与本规则 ⇒ 传 null 当作「非横向父容器」。
129
+ const visit = (node, isRoot) => {
130
+ const siblings = directChildren(node)
131
+ for (const child of siblings) {
132
+ const result = rowChildContainerNoWidth({
133
+ node: child,
134
+ parent: isRoot ? null : node,
135
+ siblingCount: siblings.length,
136
+ })
137
+ if (result) {
138
+ const parentLabel = `${node.className || 'Root'} "${node.componentId || '(root)'}"`
139
+ hits.push({
140
+ componentId: child.componentId || '',
141
+ className: result.className,
142
+ parentId: node.componentId || '(root)',
143
+ parentClass: node.className || 'Root',
144
+ siblingCount: siblings.length,
145
+ message:
146
+ `${result.className} "${child.componentId || '?'}" 是横向流 ${parentLabel} 的并列子节点(共 ${siblings.length} 个),` +
147
+ '它的 rect.width 整个缺失 —— 渲染器对「没有 width 子项」会回落到 component-meta 默认「100%」(吃满整行),' +
148
+ '又叠了 flexShrink:0 锁死不让位:同排兄弟会被压成 0 宽(文本每字一行、高度爆炸)或被顶出卡片右缘裁掉(R22b)。' +
149
+ '按设计稿给这个容器补一个明确宽度:实测 px(如 rect.width:"146")、或 `rect.width:"60%"`;' +
150
+ '要「按内容自适应」就写 rect.width:""(空串 = auto,实测渲染为自然宽);' +
151
+ '要「占满剩余空间」用 flexGrow:1 并把 flexShrink 改 1,不要靠缺失 + 锁死。',
152
+ })
153
+ }
154
+ visit(child, false)
155
+ }
156
+ }
157
+ visit(view, true)
158
+ return hits
159
+ }
160
+
@@ -13,6 +13,7 @@
13
13
  * (如 "99.切图/位图/卡片/详情/顶部-弥散@3x.png")前缀不同,仅 basename 一致,
14
14
  * 故用 basename(去 @Nx 后缀)匹配,而非整体字符串相等。
15
15
  */
16
+ import { sanitizeSliceFileName } from './slice_dedupe.mjs'
16
17
 
17
18
  /**
18
19
  * 归一化切片 basename,用于元素名 ↔ 导出文件名 匹配:
@@ -72,10 +73,21 @@ export function sliceFlatName(exportRelPath) {
72
73
  return String(exportRelPath || '').replace(/\//g, '_')
73
74
  }
74
75
 
75
- /** 导出相对路径 → schema 引用路径 "/slices/<扁平名>" */
76
+ /**
77
+ * 导出相对路径 → schema 引用路径 `/slices/<扁平名>`。
78
+ *
79
+ * **引用名 = 落盘名(单一实际来源)**:产出前一律过 `sanitizeSliceFileName()`
80
+ * (`lib/slice_dedupe.mjs`),与 `crop_slices.mjs` / `expand_ir.syncReferencedSlices` /
81
+ * `generate_cards.collectSlicesToSrcSlices` 的落盘名同源 —— 这条等式是 `r31c` 能被机械验证的前提。
82
+ *
83
+ * ⚠️ 历史实现(2026-09-21 废弃)曾把 `#` / `?` 编码成 `%23` / `%3F`,走「磁盘留字面 `#`、
84
+ * 靠服务端 decodeURIComponent 桥接」的老路。它有三个致命伤:
85
+ * ① `sliceNameFromRef()` 会先 decode、`%23` 解码回 `#`,照样被 `r31b` 拦(自己产自己拦);
86
+ * ② 引用名(`%23999`)与磁盘名(`#999`)**不同源**,任何一层不做 decode(CDN / 其他静态服务)
87
+ * 立刻 404,且盘上仍留着字面 `#` 这种到处出问题的文件名;
88
+ * ③ 字面 `%` 还会被二次编码成 `%2523`。
89
+ * 故统一为「禁用保留字符 + 消毒改名」:`警示-#999` → `警示-999`(语义化场景应为 `iconWarning@2x`)。
90
+ */
76
91
  export function sliceRefPath(exportRelPath) {
77
- return '/slices/' + sliceFlatName(exportRelPath)
78
- .replace(/ /g, '_')
79
- .replace(/#/g, '%23')
80
- .replace(/\?/g, '%3F')
92
+ return '/slices/' + sanitizeSliceFileName(sliceFlatName(exportRelPath))
81
93
  }
@@ -49,6 +49,68 @@ export function checkSliceName(name) {
49
49
  return { semantic: true, reason: '' }
50
50
  }
51
51
 
52
+ /* ── URL 保留字符:切图「静默不显示」的头号根因(2026-09-21 实测坐实)────────────
53
+ *
54
+ * 事故:卡片引用 `/slices/警示-#999.png`(名字沿用设计稿图层名)。`#` 在 URL 里是 fragment
55
+ * 分隔符,浏览器实际发出的是 `/slices/警示-`(percent-encoded),`#999.png` 根本没出网。
56
+ * 又因为切图未命中时静态中间件 `next()` 落到 SPA 兜底 → **HTTP 200 + HTML**,于是
57
+ * 「图片空白 + 网络面板全 200 + 零报错 + 门禁全绿」,30 分钟无人看出。
58
+ *
59
+ * 该集合只放「会改变 URL 解析语义」的字符 —— 中文名**不在**其中(浏览器会正常 percent-encode):
60
+ * # 之后整段是 fragment,不发服务器
61
+ * ? 之后整段是 query,不发服务器
62
+ * % 触发 percent-decode,非法编码会 400 / 名字被改写
63
+ * & 在 query 语义下是参数分隔符
64
+ * + 在 query 语义下被解码成空格
65
+ */
66
+ export const SLICE_URL_RESERVED_CHARS = '#?%&+'
67
+
68
+ /** 命中 URL 保留字符 → 返回命中的字符数组(去重);否则 null。 */
69
+ export function findUrlReservedChars(name) {
70
+ const hit = [...new Set([...String(name || '')].filter((c) => SLICE_URL_RESERVED_CHARS.includes(c)))]
71
+ return hit.length ? hit : null
72
+ }
73
+
74
+ /**
75
+ * 把切图文件名消毒成 URL 安全形态(保留扩展名;中文/语义化命名不动)。
76
+ *
77
+ * **调用方必须用返回名重写 schema 引用**:落盘名与 `/slices/` 引用名必须逐字一致,
78
+ * 只改名不改引用 = 从「假 200」换成「真 404」,图还是不出来。
79
+ *
80
+ * @param {string} name 原始文件名(可带或不带扩展名)
81
+ * @returns {string} URL 安全文件名(无扩展名时补 .png)
82
+ */
83
+ export function sanitizeSliceFileName(name) {
84
+ const raw = String(name || '').trim()
85
+ const ext = path.extname(raw)
86
+ const base = ext ? raw.slice(0, -ext.length) : raw
87
+ let safe = base
88
+ for (const ch of SLICE_URL_RESERVED_CHARS) {
89
+ if (!safe.includes(ch)) continue
90
+ safe = safe.split(ch).join(ch === '%' ? 'pct' : ch === '&' ? 'and' : ch === '+' ? 'plus' : '-')
91
+ }
92
+ // 空白同样会断裂 CSS url()(R31),一并收成连字符
93
+ safe = safe.replace(/\s+/g, '-')
94
+ // 控制字符与 Windows 文件名禁用字符:直接剔除
95
+ safe = safe.replace(/[\u0000-\u001f\u007f<>:"/\\|*]/g, '')
96
+ // 折叠连续连字符、去掉首尾连字符(`警示-#999` → `警示-999`)
97
+ safe = safe.replace(/-{2,}/g, '-').replace(/^[-.]+|[-.]+$/g, '')
98
+ return (safe || 'slice') + (ext || '.png')
99
+ }
100
+
101
+ /**
102
+ * 切图名同源判定:两侧都归一到消毒名后比较。
103
+ *
104
+ * 用途:落盘端按 **schema 里的引用名** 匹配源文件(`collectSlicesToSrcSlices` / `syncReferencedSlices`)。
105
+ * 引用名是消毒后的(`警示-999.png`),而要素包源文件仍是原名(`警示-#999.png`)——
106
+ * 直接字符串比较会**匹配不上 → 漏拷 → 引用悬空**(r31c)。归一后比较让两边都认得出来。
107
+ *
108
+ * `sanitizeSliceFileName` 幂等(无保留字符/空格的名字归一后不变),故可安全反复调用。
109
+ */
110
+ export function sliceNameMatches(candidate, wanted) {
111
+ return sanitizeSliceFileName(candidate) === sanitizeSliceFileName(wanted)
112
+ }
113
+
52
114
  /** 计算文件内容 sha256(去重的唯一权威判据)。 */
53
115
  export function sha256File(file) {
54
116
  return createHash('sha256').update(fs.readFileSync(file)).digest('hex')
@@ -145,3 +207,43 @@ export function sliceNameFromRef(ref) {
145
207
  try { name = decodeURIComponent(name) } catch { /* 非法编码保持原样 */ }
146
208
  return name
147
209
  }
210
+
211
+ /**
212
+ * 远端切图引用判定:已上传 FinMall(@filestore)、外链(http/https/protocol-relative)、内联 data URI。
213
+ * 这类引用天然没有本地文件,不能参与「本地 src/slices 里是否存在」的判定(否则满仓误报)。
214
+ */
215
+ export function isRemoteSliceRef(ref) {
216
+ const s = String(ref || '')
217
+ return /^(https?:)?\/\//i.test(s) || s.includes('@filestore') || s.startsWith('data:')
218
+ }
219
+
220
+ /** 文件名去扩展名(`icon@2x.png` → `icon@2x`)。 */
221
+ export function stemOf(file) {
222
+ const s = String(file || '')
223
+ return path.extname(s) ? s.slice(0, -path.extname(s).length) : s
224
+ }
225
+
226
+ /**
227
+ * 建切片目录的查找索引(一次 readdir,批量校验复用)。
228
+ * @returns {{ files: string[], names: Set<string>, stems: Set<string> }}
229
+ */
230
+ export function buildSliceFileIndex(dir) {
231
+ const files = dir && fs.existsSync(dir) ? fs.readdirSync(dir) : []
232
+ return { files, names: new Set(files), stems: new Set(files.map(stemOf)) }
233
+ }
234
+
235
+ /**
236
+ * `/slices/<name>` 引用在切片目录里是否有对应文件。
237
+ * 无扩展名引用(`/slices/iconArrowRight`)按「同名去扩展名」再匹配一轮 —— schema 允许这么写。
238
+ *
239
+ * 存在的意义:R31 拦「名字带空格」、R31b 拦「名字带 URL 保留字符」,都是「名字写了但用不了」;
240
+ * 这里补「这个名字在磁盘上就没有」——渲染时 404,图片位置空白且控制台零报错。
241
+ * 调用方负责先用 isRemoteSliceRef 过滤远端引用。
242
+ * @returns {{ resolved: boolean, name: string, stem: string }}
243
+ */
244
+ export function resolveSliceRefOnDisk(index, ref) {
245
+ const name = path.basename(sliceNameFromRef(ref))
246
+ const stem = stemOf(name)
247
+ if (!name) return { resolved: false, name, stem }
248
+ return { resolved: index.names.has(name) || index.stems.has(stem), name, stem }
249
+ }