@xiaobuyu/nodesign 0.0.1 → 0.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.
Files changed (282) hide show
  1. package/README.md +156 -106
  2. package/bin/nodesign.js +0 -0
  3. package/package.json +1 -1
  4. package/server/api/README.md +45 -45
  5. package/server/api/_guard.js +75 -75
  6. package/server/api/admin.js +187 -187
  7. package/server/api/assets/docx-page.js +103 -103
  8. package/server/api/assets/helpers.js +22 -22
  9. package/server/api/assets/notes.js +144 -144
  10. package/server/api/assets.js +896 -896
  11. package/server/api/board.js +57 -57
  12. package/server/api/browse.js +138 -138
  13. package/server/api/canvas.js +444 -444
  14. package/server/api/chatai.js +324 -324
  15. package/server/api/exports/build-standalone.js +980 -980
  16. package/server/api/exports/cards.js +118 -118
  17. package/server/api/exports/docx-pdf.js +46 -46
  18. package/server/api/exports/export-page.js +142 -142
  19. package/server/api/exports/handoff.js +147 -147
  20. package/server/api/exports.js +690 -690
  21. package/server/api/instruction.js +75 -75
  22. package/server/api/me.js +116 -116
  23. package/server/api/memory.js +185 -185
  24. package/server/api/pending-changes.js +366 -366
  25. package/server/api/plugins.js +164 -164
  26. package/server/api/projects.js +220 -220
  27. package/server/api/publish.js +64 -64
  28. package/server/api/recent.js +73 -73
  29. package/server/api/sessions.js +589 -589
  30. package/server/api/skills.js +77 -77
  31. package/server/api/standalone-fit.js +168 -168
  32. package/server/api/turn-compose.js +180 -180
  33. package/server/api/turn-inflight.js +44 -44
  34. package/server/auth/README.md +17 -17
  35. package/server/auth/middleware.js +138 -138
  36. package/server/auth/origin-guard.js +71 -71
  37. package/server/auth/session.js +108 -108
  38. package/server/auth/tier.js +113 -113
  39. package/server/auth/users-store.js +348 -348
  40. package/server/edit/README.md +29 -29
  41. package/server/engine/README.md +113 -113
  42. package/server/engine/agent/agent-shared.js +550 -550
  43. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
  44. package/server/engine/agent/auto-mode-rules.js +78 -78
  45. package/server/engine/agent/context.js +418 -418
  46. package/server/engine/agent/events.js +464 -464
  47. package/server/engine/agent/hooks/canvas-validate.js +199 -199
  48. package/server/engine/agent/hooks/failure.js +120 -120
  49. package/server/engine/agent/hooks/file-events.js +75 -75
  50. package/server/engine/agent/hooks/lifecycle.js +233 -233
  51. package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
  52. package/server/engine/agent/hooks/post-guidance.js +206 -206
  53. package/server/engine/agent/hooks/post-subagent-report.js +104 -104
  54. package/server/engine/agent/hooks/post-trim.js +58 -58
  55. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
  56. package/server/engine/agent/hooks/pre-defaults.js +84 -84
  57. package/server/engine/agent/hooks/pre-injectors.js +277 -277
  58. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
  59. package/server/engine/agent/hooks/pre-starter-files.js +61 -61
  60. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
  61. package/server/engine/agent/hooks/site-validate.js +94 -94
  62. package/server/engine/agent/hooks/tool-prompts.js +31 -31
  63. package/server/engine/agent/hooks/turn-state-memory.js +56 -56
  64. package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
  65. package/server/engine/agent/hooks.js +354 -354
  66. package/server/engine/agent/init-contract.js +69 -69
  67. package/server/engine/agent/isolation.js +159 -159
  68. package/server/engine/agent/model-table.js +292 -292
  69. package/server/engine/agent/plugin-loader.js +266 -266
  70. package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
  71. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
  72. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
  73. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
  74. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
  75. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
  76. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
  77. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
  78. package/server/engine/agent/prompts/tools/site-reference.md +106 -106
  79. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
  80. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
  81. package/server/engine/agent/session-loop.js +1024 -1024
  82. package/server/engine/agent/session-model.js +164 -164
  83. package/server/engine/agent/skill.js +204 -204
  84. package/server/engine/agent/task-events.js +78 -78
  85. package/server/engine/agents/ds-extractor.md +132 -132
  86. package/server/engine/agents/explorer.md +190 -190
  87. package/server/engine/agents/index.js +230 -230
  88. package/server/engine/agents/schemas/design-system.json +156 -156
  89. package/server/engine/agents/schemas/tweak-schema.json +113 -113
  90. package/server/engine/agents/tweak-proposer.md +127 -127
  91. package/server/engine/agents/vision-checker.md +254 -254
  92. package/server/engine/browse/capture.js +422 -422
  93. package/server/engine/browse/card.js +137 -137
  94. package/server/engine/browse/handover.js +59 -59
  95. package/server/engine/browse/page-digest.js +119 -119
  96. package/server/engine/browse/refs.js +196 -196
  97. package/server/engine/browse/registry.js +307 -307
  98. package/server/engine/browse/screencast.js +170 -170
  99. package/server/engine/browse/state.js +121 -121
  100. package/server/engine/chatai/chat-log.js +79 -79
  101. package/server/engine/chatai/index.js +193 -193
  102. package/server/engine/chatai/openai-compat.js +152 -152
  103. package/server/engine/chatai/orchestrate.js +309 -309
  104. package/server/engine/chatai/perform.js +40 -40
  105. package/server/engine/chatai/summarize.js +79 -79
  106. package/server/engine/mcp/capability-gate.js +52 -52
  107. package/server/engine/mcp/index.js +339 -339
  108. package/server/engine/mcp/param-sanitizer.js +142 -142
  109. package/server/engine/mcp/tools/arrange-on-board.js +124 -124
  110. package/server/engine/mcp/tools/artifact-session.js +278 -278
  111. package/server/engine/mcp/tools/browse-computer.js +421 -421
  112. package/server/engine/mcp/tools/browse-find-batch.js +183 -183
  113. package/server/engine/mcp/tools/browse-screenshot.js +157 -157
  114. package/server/engine/mcp/tools/browse.js +480 -480
  115. package/server/engine/mcp/tools/build-docx.js +101 -101
  116. package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
  117. package/server/engine/mcp/tools/create-on-board.js +155 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/explain-style.js +237 -237
  120. package/server/engine/mcp/tools/export-handoff.js +133 -133
  121. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  122. package/server/engine/mcp/tools/generate-image.js +842 -842
  123. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  124. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  125. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  126. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  127. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  128. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  129. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  130. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  131. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  132. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  133. package/server/engine/mcp/tools/highlight.js +68 -68
  134. package/server/engine/mcp/tools/list-pages.js +222 -222
  135. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  136. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  137. package/server/engine/mcp/tools/organize-board.js +64 -64
  138. package/server/engine/mcp/tools/paint-still.js +434 -434
  139. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  140. package/server/engine/mcp/tools/preview-deck.js +130 -130
  141. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  142. package/server/engine/mcp/tools/publish-site.js +102 -102
  143. package/server/engine/mcp/tools/query-elements.js +195 -195
  144. package/server/engine/mcp/tools/read-board.js +103 -103
  145. package/server/engine/mcp/tools/read-document.js +93 -93
  146. package/server/engine/mcp/tools/read-page.js +264 -264
  147. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  148. package/server/engine/mcp/tools/record-decision.js +140 -140
  149. package/server/engine/mcp/tools/relate-on-board.js +110 -110
  150. package/server/engine/mcp/tools/report-issue.js +115 -115
  151. package/server/engine/mcp/tools/roll-film.js +266 -266
  152. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  153. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  154. package/server/engine/mcp/tools/screenshot.js +583 -583
  155. package/server/engine/mcp/tools/tier-gate.js +96 -96
  156. package/server/engine/mcp/tools/trace-motion.js +215 -215
  157. package/server/engine/mcp/tools/web-search.js +548 -548
  158. package/server/engine/motion/inventory.js +349 -349
  159. package/server/engine/perception/session.js +235 -235
  160. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  161. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
  162. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  163. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  164. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  165. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  169. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
  170. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  171. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  172. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
  173. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
  174. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  175. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
  176. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  177. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  178. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  179. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  180. package/server/engine/runs/active-runs.js +698 -698
  181. package/server/engine/runs/live-turn.js +252 -252
  182. package/server/engine/runs/store.js +364 -364
  183. package/server/engine/runs/turn-relay.js +217 -217
  184. package/server/engine/runtime/workspace.js +164 -164
  185. package/server/lib/artifact-file-path.js +80 -80
  186. package/server/lib/artifact-target.js +284 -284
  187. package/server/lib/asset-refs.js +239 -239
  188. package/server/lib/async-queue.js +104 -104
  189. package/server/lib/auto-relations.js +130 -130
  190. package/server/lib/binding-types.js +60 -60
  191. package/server/lib/board-kind-sizes.js +62 -62
  192. package/server/lib/board-relations.js +133 -133
  193. package/server/lib/browse-proxy.js +180 -180
  194. package/server/lib/canvas-id.js +36 -36
  195. package/server/lib/cover.js +227 -227
  196. package/server/lib/danbooru-tags.js +291 -291
  197. package/server/lib/doc-extract.js +156 -156
  198. package/server/lib/docx/build-from-source.js +260 -260
  199. package/server/lib/docx/build.js +490 -490
  200. package/server/lib/docx/dump-styles.js +287 -287
  201. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  202. package/server/lib/docx/merge-runs.js +129 -129
  203. package/server/lib/docx/numbering.js +218 -218
  204. package/server/lib/docx/order.js +152 -152
  205. package/server/lib/docx/rawzip.js +172 -172
  206. package/server/lib/docx/render.js +92 -92
  207. package/server/lib/docx/text-lint.js +225 -225
  208. package/server/lib/docx/tokens.js +335 -335
  209. package/server/lib/docx/units.js +29 -29
  210. package/server/lib/docx/xml.js +291 -291
  211. package/server/lib/docx-pages.js +187 -187
  212. package/server/lib/export-collect.js +289 -289
  213. package/server/lib/export-package.js +210 -210
  214. package/server/lib/html-srcset.js +145 -145
  215. package/server/lib/image-variant.js +460 -460
  216. package/server/lib/ingress/forward-openai-chat.js +305 -305
  217. package/server/lib/ingress/openai-chat.js +491 -491
  218. package/server/lib/ingress/session-notice.js +66 -66
  219. package/server/lib/ingress/session-routes.js +68 -68
  220. package/server/lib/ingress/slot-probe.js +130 -130
  221. package/server/lib/ingress/upstream-billing.js +52 -52
  222. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  223. package/server/lib/ingress/upstream-truncation.js +48 -48
  224. package/server/lib/issues-store.js +182 -182
  225. package/server/lib/kinds/deck.js +72 -72
  226. package/server/lib/kinds/docx.js +283 -283
  227. package/server/lib/kinds/file-kinds.js +113 -113
  228. package/server/lib/kinds/index.js +202 -202
  229. package/server/lib/kinds/site.js +161 -161
  230. package/server/lib/model-ingress.js +543 -543
  231. package/server/lib/moderation.js +255 -255
  232. package/server/lib/notice-store.js +103 -103
  233. package/server/lib/plugin-install.js +104 -104
  234. package/server/lib/plugin-validator.js +721 -721
  235. package/server/lib/publish-store.js +115 -115
  236. package/server/lib/quick-summary.js +52 -52
  237. package/server/lib/quota.js +270 -270
  238. package/server/lib/rate-window.js +29 -29
  239. package/server/lib/reference-assets.js +92 -92
  240. package/server/lib/region-shot.js +135 -135
  241. package/server/lib/safe-path.js +73 -73
  242. package/server/lib/sdk-session.js +33 -33
  243. package/server/lib/showcase-store.js +92 -92
  244. package/server/lib/site-publish.js +465 -465
  245. package/server/lib/ssrf-guard.js +432 -432
  246. package/server/lib/task-scan.js +158 -158
  247. package/server/lib/tavern-json.js +154 -154
  248. package/server/lib/video-variant.js +237 -237
  249. package/server/lib/workspace-path.js +33 -33
  250. package/server/ops/fix-sdk-musl.mjs +40 -40
  251. package/server/ops/install-macos-fonts.sh +114 -114
  252. package/server/ops/macos-fonts.conf +336 -336
  253. package/server/ops/sandbox-shim/bwrap +57 -57
  254. package/server/projects/assets-summary.js +119 -119
  255. package/server/projects/auto-name.js +53 -53
  256. package/server/projects/board-store.js +640 -640
  257. package/server/projects/move-entry.js +103 -103
  258. package/server/projects/store.js +270 -270
  259. package/server/projects/ui-config.js +54 -54
  260. package/server/projects/workspace-templates.js +53 -53
  261. package/server/projects/workspace.js +1025 -1025
  262. package/server/runtime/local-config.js +177 -177
  263. package/server/runtime/profile.js +72 -72
  264. package/server/services/rembg-service.py +334 -334
  265. package/server/shared/README.md +25 -25
  266. package/server/shared/deck.js +54 -54
  267. package/server/shared/time.js +49 -49
  268. package/server/style-pipeline/README.md +53 -53
  269. package/server/ws/broker.js +36 -36
  270. package/server/ws/browse-channel.js +177 -177
  271. package/server/ws/index.js +386 -386
  272. package/web/README.md +89 -89
  273. package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
  274. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
  275. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
  276. package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
  277. package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
  278. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
  279. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
  280. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
  281. package/web/dist/index.html +17 -17
  282. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
@@ -1,289 +1,289 @@
1
- /**
2
- * lib/export-collect.js — 按「产物卡」收集要导出的东西(2026-08-17)
3
- *
4
- * 导出的寻址单位从「会话当前产物」换成**画布上的那张卡**:卡的类型决定收什么。
5
- * site 卡收站点那棵树、图片卡收那张图,以此类推。
6
- *
7
- * 为什么按卡 id 就够、不用再造一份枚举:
8
- * 卡 id 本身**就是地址**(`site:<产物根>` / `deck:<html路径>` / 文件卡是裸路径),
9
- * 前端 `BoardCanvas` 拼它、服务端 `board-store.mapId` 认它,用的是同一套前缀判据。
10
- * 这里只解析地址、落到磁盘上收,权威清单仍然只有 `/artifacts` 那一份。
11
- *
12
- * 素材只收**这份产物真正引用到的**(`asset-refs.js` 扫出来的),不再像旧交付包那样
13
- * 把整个项目级 `shared/assets` 一锅端 —— 那会把别的任务的图一起交出去,生产上最大的
14
- * 项目那个目录有 280MB。
15
- *
16
- * ── 2026-08-17 评审后修的四个洞(都有单测钉着)──
17
- * 1. **根级站点卡**(`site:`,rel 为空 —— 扁平化后「根 index.html = 一个站」是常态)
18
- * 从工作区根盲走,把 `assets/`、`exports/`、`board.json`、别的任务全打进包,
19
- * 280MB 病原样复活。
20
- * 2. **单页站点卡**(`site:任务/_drafts/试作.html`)整类 400 —— 它是画布上真实存在
21
- * 的一种卡,收集器却要求 site 必须是目录。
22
- * 3. **`.ndignore` 基准**从卡 id 字符串上切第一段,只有「深度 1 的任务文件夹」这一
23
- * 种布局碰巧对;根级构建站、嵌套站全取错,忽略规则静默失效。
24
- * 4. **软链逃逸**:`safeResolve` 只做词法检查,工作区里一个指向外面的软链能把
25
- * 工作区外的文件收进包(实测读出过 /tmp 下的文件内容)。
26
- */
27
-
28
- import path from 'node:path';
29
- import fs from 'node:fs/promises';
30
- import { walkTaskFiles, loadIgnore, RESERVED_DIRS, isReservedFile } from './task-scan.js';
31
- import { collectAssetRefs } from './asset-refs.js';
32
- import { kindDef, taskManifest, can } from './kinds/index.js';
33
- import { fileKindOfPath, fileKindDef } from './kinds/file-kinds.js';
34
- // 越界判据收在一处(原来这里有第二份实现,docx 页图路由抄它的形状时抄漏了 realpath)
35
- import { safeResolveRead as safeResolve } from './safe-path.js';
36
-
37
- /**
38
- * 卡 id → { kind, rel }。
39
- *
40
- * ⚠️ 前缀只认纯字母(`deck:` `site:`)—— 路径里本来就可能有冒号,判据必须跟
41
- * 前端 `stage.js` 的 `zoneOfObjectId` 和服务端 `board-store.mapId` 保持一致。
42
- * 没有合法前缀 = 文件卡,类型按路径推。
43
- * 认哪些前缀问注册表(kindDef)—— 原来是两个手写 Set,加形态时这里必漏,
44
- * 新形态的卡会被当成裸文件路径去解析(「写死表家族」第 5 处,2026-08-18 收)。
45
- *
46
- * ⚠️ 已知歧义:根层一个叫 `final:v2.png` 的文件,卡 id 就是裸路径,这里会把
47
- * `final` 当前缀剥掉。三处判据是**一致地**这样,改判据要三处一起改;collectCard
48
- * 里对这种情况有一次「整串当路径」的回退。
49
- */
50
- export function parseCardId(cardId) {
51
- if (!cardId || typeof cardId !== 'string') return null;
52
- const c = cardId.indexOf(':');
53
- if (c > 0 && /^[a-z]+$/.test(cardId.slice(0, c))) {
54
- const kind = cardId.slice(0, c);
55
- const rel = cardId.slice(c + 1).replace(/\\/g, '/');
56
- if (kindDef(kind)) return { kind, rel };
57
- return { kind: fileKindOfPath(rel), rel };
58
- }
59
- const rel = cardId.replace(/\\/g, '/');
60
- return { kind: fileKindOfPath(rel), rel };
61
- }
62
-
63
-
64
- /** 这种形态能导出成什么。single = 单页产物,没有「整站 zip」可言(跟 taskManifest 的 decorate 同一条剥除) */
65
- export function exportFormatsFor(kind, { single = false } = {}) {
66
- const list = kindDef(kind)?.exportFormats || fileKindDef(kind)?.exportFormats || [];
67
- return single ? list.filter(f => f !== 'site') : list;
68
- }
69
-
70
- /**
71
- * 忽略规则的基准目录 = **`.ndignore` 自己所在的那一层**,从产物根往上找第一个。
72
- *
73
- * 这跟 `.gitignore` 是同一条语义:规则文件的作用域就是它自己的目录。
74
- *
75
- * 曾经从卡 id 字符串上切第一段(`rel.split('/')[0]`)当基准,只有「深度 1 的任务
76
- * 文件夹」这一种布局碰巧对:根级构建站(`site:dist`)会切出 `dist`、嵌套站
77
- * (`site:客户/官网`)会切出 `客户`,两种情况下忽略规则都**静默失效**。
78
- * 也试过问 `taskManifest` 谁认领了这个产物根,但构建站的 `dist/` 自己看起来也像
79
- * 个站,从深往浅问会停在 `dist` 上 —— 而 `.ndignore` 明明在更外面。
80
- * 按规则文件自己的位置找,三种布局一次都不会错。
81
- */
82
- async function ignoreBaseFor(workspaceRoot, rel) {
83
- const segs = rel ? rel.split('/') : [];
84
- for (let i = segs.length; i >= 0; i--) {
85
- const cand = segs.slice(0, i).join('/');
86
- const dir = cand ? path.join(workspaceRoot, cand) : workspaceRoot;
87
- try {
88
- await fs.access(path.join(dir, '.ndignore'));
89
- return cand;
90
- } catch { /* 这层没有,往上找 */ }
91
- }
92
- return ''; // 一个都没有:基准取工作区根(规则为空,等价于不过滤)
93
- }
94
-
95
- /**
96
- * 收一张卡。
97
- *
98
- * @returns {Promise<{cardId, kind, rel, title, single, files, assets, missing, unresolved, exportFormats}>}
99
- * files 产物自身的文件(rel 相对工作区根)
100
- * assets 它真正引用到、且磁盘上确实存在的素材
101
- * missing 引用了但磁盘上找不到的(裂图预警)
102
- * unresolved 算不出来的引用(动态拼接 / 脚本字面量 / 树外引用 / `<base href>`)
103
- */
104
- export async function collectCard({ workspaceRoot, cardId }) {
105
- const parsed = parseCardId(cardId);
106
- if (!parsed) throw Object.assign(new Error(`认不出这张卡:${cardId}`), { status: 400 });
107
-
108
- let { kind, rel } = parsed;
109
- let abs = await safeResolve(workspaceRoot, rel);
110
- let stat = null;
111
- if (abs) { try { stat = await fs.stat(abs); } catch { /* 下面回退 */ } }
112
-
113
- // 前缀剥错了的回退:`final:v2.png` 这种文件名会被当成 `final:` 前缀
114
- if (!stat && cardId !== rel) {
115
- const whole = cardId.replace(/\\/g, '/');
116
- const absWhole = await safeResolve(workspaceRoot, whole);
117
- if (absWhole) {
118
- try {
119
- stat = await fs.stat(absWhole);
120
- abs = absWhole; rel = whole; kind = fileKindOfPath(whole);
121
- } catch { /* 仍然没有 */ }
122
- }
123
- }
124
- if (!abs) throw Object.assign(new Error(`路径越界或指向工作区外:${rel}`), { status: 400 });
125
- if (!stat) throw Object.assign(new Error(`产物不存在:${rel}`), { status: 404 });
126
-
127
- const files = [];
128
- // 「这种形态有没有目录型」问注册表(kind 条目声明 directory)。整树打包只对
129
- // browsable 的目录型成立(站点 = 一棵互相引用的树);word 文件夹也是目录型,
130
- // 但 .docx 成员各自自包含,卡级导出走下面挑主成员那条,不走树
131
- const dirKind = !!kindDef(kind)?.directory;
132
- // 单页站点(`_drafts/试作.html` 这类)也是 site 卡,只是它是一个文件不是一棵树
133
- const single = dirKind && can(kind, 'browsable') && stat.isFile();
134
- const isTree = dirKind && can(kind, 'browsable') && stat.isDirectory();
135
-
136
- const deepWarn = [];
137
- if (isTree && !rel) {
138
- // ⭐**根站不盲走。** 扁平化后「根 index.html = 一个站」是常态,而根站的「树」
139
- // 就是整个工作区 —— 盲走会把项目基础设施和**别的任务**一起卷进来。
140
- // 正解是问权威解析器要页面清单:`taskManifest` 的根站条目(site / 非 single /
141
- // 无 srcRoot,跟 assets.js 判 rootSite 同一口径)里的 `pages` 就是它认领了哪些页。
142
- // 别的任务的 index.html 不在这份 pages 里,天然被排除;页面引用到的 css/js/图
143
- // 由下面的 refs 收。这比我自己拿一张保留目录表去猜要靠谱 —— 谁属于这个站,
144
- // 只有解析器说了算。
145
- const m = await taskManifest(workspaceRoot).catch(() => null);
146
- const rootSite = (m?.artifacts || []).find(a => a.kind === 'site' && !a.single && !a.srcRoot);
147
- for (const pg of (rootSite?.pages || [])) {
148
- const pRel = String(pg).replace(/\\/g, '/');
149
- const pAbs = await safeResolve(workspaceRoot, pRel);
150
- if (!pAbs) continue;
151
- try { if ((await fs.stat(pAbs)).isFile()) files.push({ abs: pAbs, rel: pRel }); } catch { /* 页面刚被删 */ }
152
- }
153
- if (!files.length) {
154
- // 解析器认不出根站(还没写出 index.html 之类)→ 退回入口文件本身,
155
- // 绝不退回「盲走整个工作区」
156
- const entryAbs = await safeResolve(workspaceRoot, 'index.html');
157
- if (entryAbs) {
158
- try { if ((await fs.stat(entryAbs)).isFile()) files.push({ abs: entryAbs, rel: 'index.html' }); } catch { /* */ }
159
- }
160
- }
161
- } else if (isTree) {
162
- const ignoreBaseRel = await ignoreBaseFor(workspaceRoot, rel);
163
- const ignoreBaseAbs = (await safeResolve(workspaceRoot, ignoreBaseRel)) || workspaceRoot;
164
- const ignore = await loadIgnore(ignoreBaseAbs);
165
- const walked = await walkTaskFiles(abs, { maxDepth: 6, ignore, ignoreBase: ignoreBaseAbs });
166
- for (const f of walked) {
167
- const r = path.relative(workspaceRoot, f.abs).replace(/\\/g, '/');
168
- files.push({ abs: f.abs, rel: r });
169
- // maxDepth 撞顶是静默截断的(walkTaskFiles 不给信号)。贴着上限的文件说明
170
- // 这棵树很深,更深的东西可能没进包 —— 报出来,别让人以为齐了。
171
- // ⚠️ 深度要按 **walk 相对卡根** 的路径算(`f.rel`),不是工作区相对的 `r`:
172
- // 嵌套站 `客户/官网/a/b/c/x.png` 在工作区坐标下有 6 段,但 walk 只走了 4 层,
173
- // 离截断远着 —— 用错坐标系会往交付物里印假警报。
174
- if (f.rel.split('/').length >= 6) deepWarn.push(r);
175
- }
176
- } else if (kind === 'docx' && stat.isDirectory()) {
177
- // word 文件夹卡(`docx:报告`):地址是文件夹,导出对象是主成员 —— 窗里的
178
- // 导出按钮会点名具体成员(`docx:报告/文档v2.docx`),走上面的单文件分支;
179
- // 这条是卡级导出的兜底。挑主成员的判据在 kinds/docx.js,别在这儿抄第二份。
180
- let names = [];
181
- try { names = kindDef('docx').sortDocxNames(await fs.readdir(abs)); } catch { /* 下面报 404 */ }
182
- if (!names.length) throw Object.assign(new Error(`${rel} 里没有 .docx`), { status: 404 });
183
- rel = `${rel}/${names[0]}`;
184
- files.push({ abs: path.join(abs, names[0]), rel });
185
- } else {
186
- if (!stat.isFile()) throw Object.assign(new Error(`${rel} 不是文件`), { status: 400 });
187
- files.push({ abs, rel });
188
- }
189
-
190
- if (!files.length) {
191
- throw Object.assign(new Error(`${rel} 里没有可导出的文件`), { status: 400 });
192
- }
193
-
194
- // 树外引用只认 `assets/` 和产物自己的目录(旧 /exports/site 有这道闸,别丢)。
195
- // 根层产物的自有前缀是空串 = 不设闸:它的树本来就是整个工作区,这条认账。
196
- const ownPrefix = isTree ? (rel ? `${rel}/` : '') : (path.posix.dirname(rel) === '.' ? '' : `${path.posix.dirname(rel)}/`);
197
- // 只有**标记语言**产物需要跟着引用扫下去。`browsable` 正好是这条线:能用浏览器
198
- // 打开 = 由 html/css 组成 = 有外部引用要带。docx 是自包含的 zip(图片字体都在
199
- // 包里),拿二进制去跑引用扫描器是白费力气,还可能在压缩字节里撞出假路径。
200
- // 问注册表和能力,不问形态名 —— 加第四种形态时这行不用动。
201
- const scannable = !!kindDef(kind) && can(kind, 'browsable');
202
- let refs = []; let unresolved = []; let candidates = [];
203
- if (scannable) {
204
- // ⭐**传递引用要跟着扫下去。** html 引 css、css 再引图 —— 只扫产物自身的话,
205
- // 那张图既不进包也不进清单(用户拍的规矩里最坏的状态),解压即裂图且零预警。
206
- // deck 卡 / 单页站 / 根站三类全中,因为它们的 files 里只有页面本身。
207
- // 定点到不动为止,最多四轮(防环、也防超深链把单次导出拖垮)。
208
- const allow = ownPrefix === '' ? [] : [ownPrefix, 'assets/'];
209
- const scanned = new Set(files.map(f => f.rel));
210
- let batch = files;
211
- for (let round = 0; round < 4 && batch.length; round++) {
212
- const r = await collectAssetRefs({ files: batch, baseRoot: workspaceRoot, allowPrefixes: allow });
213
- refs.push(...r.refs); unresolved.push(...r.unresolved); candidates.push(...r.candidates);
214
- batch = [];
215
- for (const rr of r.refs) {
216
- if (scanned.has(rr) || !/\.(html?|css|m?js|cjs)$/i.test(rr)) continue;
217
- scanned.add(rr);
218
- const a = await safeResolve(workspaceRoot, rr);
219
- if (a) batch.push({ abs: a, rel: rr });
220
- }
221
- }
222
- refs = [...new Set(refs)];
223
- }
224
- if (deepWarn.length) {
225
- unresolved.push({
226
- from: rel || '(工作区根)',
227
- why: `目录深度贴到上限 6 层,更深的文件没有进包(命中 ${deepWarn.length} 个)`,
228
- snippet: deepWarn.slice(0, 3).join(' / '),
229
- });
230
- }
231
-
232
- const assets = []; const missing = [];
233
- const ownSet = new Set(files.map(f => f.rel));
234
- // 脚本里的完整字面量路径(画廊 / lightbox 那种):**磁盘上真有这个文件就不是猜**,
235
- // 提升成素材;解析不到的仍然只进清单。这不违反「扫不到的不多塞」——
236
- // 它扫得到,只是来源是脚本而不是标签。
237
- for (const c of candidates) {
238
- const a = await safeResolve(workspaceRoot, c.rel);
239
- if (!a || ownSet.has(c.rel)) continue;
240
- try {
241
- if ((await fs.stat(a)).isFile()) { refs.push(c.rel); continue; }
242
- } catch { /* 不存在 */ }
243
- unresolved.push({ from: c.from, why: '脚本里的字符串字面量(磁盘上没找到对应文件)', snippet: c.snippet });
244
- }
245
- refs = [...new Set(refs)].sort();
246
- for (const r of refs) {
247
- const a = await safeResolve(workspaceRoot, r);
248
- if (!a) continue; // 软链逃逸的引用直接不收
249
- let st = null;
250
- try { st = await fs.stat(a); } catch { missing.push(r); continue; }
251
- if (st.isFile()) { assets.push({ abs: a, rel: r }); continue; }
252
- if (st.isDirectory()) {
253
- // pretty-URL(`href="about/"`)指的是 `about/index.html`,不是裂图。
254
- // 不处理的话每个多页站导出都会带一堆假的缺失警报。
255
- const idxRel = `${r.replace(/\/$/, '')}/index.html`;
256
- if (ownSet.has(idxRel)) continue;
257
- const idxAbs = await safeResolve(workspaceRoot, idxRel);
258
- if (idxAbs) {
259
- try {
260
- if ((await fs.stat(idxAbs)).isFile()) { assets.push({ abs: idxAbs, rel: idxRel }); continue; }
261
- } catch { /* 落 missing */ }
262
- }
263
- }
264
- missing.push(r);
265
- }
266
-
267
- return {
268
- cardId, kind, rel, single,
269
- title: path.basename(rel) || rel || '产物',
270
- files, assets, missing, unresolved,
271
- exportFormats: exportFormatsFor(kind, { single }),
272
- };
273
- }
274
-
275
- /**
276
- * 按类型收一批卡(「导出全部图片」这种)。
277
- * 单张失败不拖累整批 —— 收不到的记进 skipped,让人看见少了什么。
278
- */
279
- export async function collectCards({ workspaceRoot, cardIds }) {
280
- const bundles = []; const skipped = [];
281
- for (const id of cardIds) {
282
- try {
283
- bundles.push(await collectCard({ workspaceRoot, cardId: id }));
284
- } catch (err) {
285
- skipped.push({ cardId: id, reason: err.message, status: err.status || 500 });
286
- }
287
- }
288
- return { bundles, skipped };
289
- }
1
+ /**
2
+ * lib/export-collect.js — 按「产物卡」收集要导出的东西(2026-08-17)
3
+ *
4
+ * 导出的寻址单位从「会话当前产物」换成**画布上的那张卡**:卡的类型决定收什么。
5
+ * site 卡收站点那棵树、图片卡收那张图,以此类推。
6
+ *
7
+ * 为什么按卡 id 就够、不用再造一份枚举:
8
+ * 卡 id 本身**就是地址**(`site:<产物根>` / `deck:<html路径>` / 文件卡是裸路径),
9
+ * 前端 `BoardCanvas` 拼它、服务端 `board-store.mapId` 认它,用的是同一套前缀判据。
10
+ * 这里只解析地址、落到磁盘上收,权威清单仍然只有 `/artifacts` 那一份。
11
+ *
12
+ * 素材只收**这份产物真正引用到的**(`asset-refs.js` 扫出来的),不再像旧交付包那样
13
+ * 把整个项目级 `shared/assets` 一锅端 —— 那会把别的任务的图一起交出去,生产上最大的
14
+ * 项目那个目录有 280MB。
15
+ *
16
+ * ── 2026-08-17 评审后修的四个洞(都有单测钉着)──
17
+ * 1. **根级站点卡**(`site:`,rel 为空 —— 扁平化后「根 index.html = 一个站」是常态)
18
+ * 从工作区根盲走,把 `assets/`、`exports/`、`board.json`、别的任务全打进包,
19
+ * 280MB 病原样复活。
20
+ * 2. **单页站点卡**(`site:任务/_drafts/试作.html`)整类 400 —— 它是画布上真实存在
21
+ * 的一种卡,收集器却要求 site 必须是目录。
22
+ * 3. **`.ndignore` 基准**从卡 id 字符串上切第一段,只有「深度 1 的任务文件夹」这一
23
+ * 种布局碰巧对;根级构建站、嵌套站全取错,忽略规则静默失效。
24
+ * 4. **软链逃逸**:`safeResolve` 只做词法检查,工作区里一个指向外面的软链能把
25
+ * 工作区外的文件收进包(实测读出过 /tmp 下的文件内容)。
26
+ */
27
+
28
+ import path from 'node:path';
29
+ import fs from 'node:fs/promises';
30
+ import { walkTaskFiles, loadIgnore, RESERVED_DIRS, isReservedFile } from './task-scan.js';
31
+ import { collectAssetRefs } from './asset-refs.js';
32
+ import { kindDef, taskManifest, can } from './kinds/index.js';
33
+ import { fileKindOfPath, fileKindDef } from './kinds/file-kinds.js';
34
+ // 越界判据收在一处(原来这里有第二份实现,docx 页图路由抄它的形状时抄漏了 realpath)
35
+ import { safeResolveRead as safeResolve } from './safe-path.js';
36
+
37
+ /**
38
+ * 卡 id → { kind, rel }。
39
+ *
40
+ * ⚠️ 前缀只认纯字母(`deck:` `site:`)—— 路径里本来就可能有冒号,判据必须跟
41
+ * 前端 `stage.js` 的 `zoneOfObjectId` 和服务端 `board-store.mapId` 保持一致。
42
+ * 没有合法前缀 = 文件卡,类型按路径推。
43
+ * 认哪些前缀问注册表(kindDef)—— 原来是两个手写 Set,加形态时这里必漏,
44
+ * 新形态的卡会被当成裸文件路径去解析(「写死表家族」第 5 处,2026-08-18 收)。
45
+ *
46
+ * ⚠️ 已知歧义:根层一个叫 `final:v2.png` 的文件,卡 id 就是裸路径,这里会把
47
+ * `final` 当前缀剥掉。三处判据是**一致地**这样,改判据要三处一起改;collectCard
48
+ * 里对这种情况有一次「整串当路径」的回退。
49
+ */
50
+ export function parseCardId(cardId) {
51
+ if (!cardId || typeof cardId !== 'string') return null;
52
+ const c = cardId.indexOf(':');
53
+ if (c > 0 && /^[a-z]+$/.test(cardId.slice(0, c))) {
54
+ const kind = cardId.slice(0, c);
55
+ const rel = cardId.slice(c + 1).replace(/\\/g, '/');
56
+ if (kindDef(kind)) return { kind, rel };
57
+ return { kind: fileKindOfPath(rel), rel };
58
+ }
59
+ const rel = cardId.replace(/\\/g, '/');
60
+ return { kind: fileKindOfPath(rel), rel };
61
+ }
62
+
63
+
64
+ /** 这种形态能导出成什么。single = 单页产物,没有「整站 zip」可言(跟 taskManifest 的 decorate 同一条剥除) */
65
+ export function exportFormatsFor(kind, { single = false } = {}) {
66
+ const list = kindDef(kind)?.exportFormats || fileKindDef(kind)?.exportFormats || [];
67
+ return single ? list.filter(f => f !== 'site') : list;
68
+ }
69
+
70
+ /**
71
+ * 忽略规则的基准目录 = **`.ndignore` 自己所在的那一层**,从产物根往上找第一个。
72
+ *
73
+ * 这跟 `.gitignore` 是同一条语义:规则文件的作用域就是它自己的目录。
74
+ *
75
+ * 曾经从卡 id 字符串上切第一段(`rel.split('/')[0]`)当基准,只有「深度 1 的任务
76
+ * 文件夹」这一种布局碰巧对:根级构建站(`site:dist`)会切出 `dist`、嵌套站
77
+ * (`site:客户/官网`)会切出 `客户`,两种情况下忽略规则都**静默失效**。
78
+ * 也试过问 `taskManifest` 谁认领了这个产物根,但构建站的 `dist/` 自己看起来也像
79
+ * 个站,从深往浅问会停在 `dist` 上 —— 而 `.ndignore` 明明在更外面。
80
+ * 按规则文件自己的位置找,三种布局一次都不会错。
81
+ */
82
+ async function ignoreBaseFor(workspaceRoot, rel) {
83
+ const segs = rel ? rel.split('/') : [];
84
+ for (let i = segs.length; i >= 0; i--) {
85
+ const cand = segs.slice(0, i).join('/');
86
+ const dir = cand ? path.join(workspaceRoot, cand) : workspaceRoot;
87
+ try {
88
+ await fs.access(path.join(dir, '.ndignore'));
89
+ return cand;
90
+ } catch { /* 这层没有,往上找 */ }
91
+ }
92
+ return ''; // 一个都没有:基准取工作区根(规则为空,等价于不过滤)
93
+ }
94
+
95
+ /**
96
+ * 收一张卡。
97
+ *
98
+ * @returns {Promise<{cardId, kind, rel, title, single, files, assets, missing, unresolved, exportFormats}>}
99
+ * files 产物自身的文件(rel 相对工作区根)
100
+ * assets 它真正引用到、且磁盘上确实存在的素材
101
+ * missing 引用了但磁盘上找不到的(裂图预警)
102
+ * unresolved 算不出来的引用(动态拼接 / 脚本字面量 / 树外引用 / `<base href>`)
103
+ */
104
+ export async function collectCard({ workspaceRoot, cardId }) {
105
+ const parsed = parseCardId(cardId);
106
+ if (!parsed) throw Object.assign(new Error(`认不出这张卡:${cardId}`), { status: 400 });
107
+
108
+ let { kind, rel } = parsed;
109
+ let abs = await safeResolve(workspaceRoot, rel);
110
+ let stat = null;
111
+ if (abs) { try { stat = await fs.stat(abs); } catch { /* 下面回退 */ } }
112
+
113
+ // 前缀剥错了的回退:`final:v2.png` 这种文件名会被当成 `final:` 前缀
114
+ if (!stat && cardId !== rel) {
115
+ const whole = cardId.replace(/\\/g, '/');
116
+ const absWhole = await safeResolve(workspaceRoot, whole);
117
+ if (absWhole) {
118
+ try {
119
+ stat = await fs.stat(absWhole);
120
+ abs = absWhole; rel = whole; kind = fileKindOfPath(whole);
121
+ } catch { /* 仍然没有 */ }
122
+ }
123
+ }
124
+ if (!abs) throw Object.assign(new Error(`路径越界或指向工作区外:${rel}`), { status: 400 });
125
+ if (!stat) throw Object.assign(new Error(`产物不存在:${rel}`), { status: 404 });
126
+
127
+ const files = [];
128
+ // 「这种形态有没有目录型」问注册表(kind 条目声明 directory)。整树打包只对
129
+ // browsable 的目录型成立(站点 = 一棵互相引用的树);word 文件夹也是目录型,
130
+ // 但 .docx 成员各自自包含,卡级导出走下面挑主成员那条,不走树
131
+ const dirKind = !!kindDef(kind)?.directory;
132
+ // 单页站点(`_drafts/试作.html` 这类)也是 site 卡,只是它是一个文件不是一棵树
133
+ const single = dirKind && can(kind, 'browsable') && stat.isFile();
134
+ const isTree = dirKind && can(kind, 'browsable') && stat.isDirectory();
135
+
136
+ const deepWarn = [];
137
+ if (isTree && !rel) {
138
+ // ⭐**根站不盲走。** 扁平化后「根 index.html = 一个站」是常态,而根站的「树」
139
+ // 就是整个工作区 —— 盲走会把项目基础设施和**别的任务**一起卷进来。
140
+ // 正解是问权威解析器要页面清单:`taskManifest` 的根站条目(site / 非 single /
141
+ // 无 srcRoot,跟 assets.js 判 rootSite 同一口径)里的 `pages` 就是它认领了哪些页。
142
+ // 别的任务的 index.html 不在这份 pages 里,天然被排除;页面引用到的 css/js/图
143
+ // 由下面的 refs 收。这比我自己拿一张保留目录表去猜要靠谱 —— 谁属于这个站,
144
+ // 只有解析器说了算。
145
+ const m = await taskManifest(workspaceRoot).catch(() => null);
146
+ const rootSite = (m?.artifacts || []).find(a => a.kind === 'site' && !a.single && !a.srcRoot);
147
+ for (const pg of (rootSite?.pages || [])) {
148
+ const pRel = String(pg).replace(/\\/g, '/');
149
+ const pAbs = await safeResolve(workspaceRoot, pRel);
150
+ if (!pAbs) continue;
151
+ try { if ((await fs.stat(pAbs)).isFile()) files.push({ abs: pAbs, rel: pRel }); } catch { /* 页面刚被删 */ }
152
+ }
153
+ if (!files.length) {
154
+ // 解析器认不出根站(还没写出 index.html 之类)→ 退回入口文件本身,
155
+ // 绝不退回「盲走整个工作区」
156
+ const entryAbs = await safeResolve(workspaceRoot, 'index.html');
157
+ if (entryAbs) {
158
+ try { if ((await fs.stat(entryAbs)).isFile()) files.push({ abs: entryAbs, rel: 'index.html' }); } catch { /* */ }
159
+ }
160
+ }
161
+ } else if (isTree) {
162
+ const ignoreBaseRel = await ignoreBaseFor(workspaceRoot, rel);
163
+ const ignoreBaseAbs = (await safeResolve(workspaceRoot, ignoreBaseRel)) || workspaceRoot;
164
+ const ignore = await loadIgnore(ignoreBaseAbs);
165
+ const walked = await walkTaskFiles(abs, { maxDepth: 6, ignore, ignoreBase: ignoreBaseAbs });
166
+ for (const f of walked) {
167
+ const r = path.relative(workspaceRoot, f.abs).replace(/\\/g, '/');
168
+ files.push({ abs: f.abs, rel: r });
169
+ // maxDepth 撞顶是静默截断的(walkTaskFiles 不给信号)。贴着上限的文件说明
170
+ // 这棵树很深,更深的东西可能没进包 —— 报出来,别让人以为齐了。
171
+ // ⚠️ 深度要按 **walk 相对卡根** 的路径算(`f.rel`),不是工作区相对的 `r`:
172
+ // 嵌套站 `客户/官网/a/b/c/x.png` 在工作区坐标下有 6 段,但 walk 只走了 4 层,
173
+ // 离截断远着 —— 用错坐标系会往交付物里印假警报。
174
+ if (f.rel.split('/').length >= 6) deepWarn.push(r);
175
+ }
176
+ } else if (kind === 'docx' && stat.isDirectory()) {
177
+ // word 文件夹卡(`docx:报告`):地址是文件夹,导出对象是主成员 —— 窗里的
178
+ // 导出按钮会点名具体成员(`docx:报告/文档v2.docx`),走上面的单文件分支;
179
+ // 这条是卡级导出的兜底。挑主成员的判据在 kinds/docx.js,别在这儿抄第二份。
180
+ let names = [];
181
+ try { names = kindDef('docx').sortDocxNames(await fs.readdir(abs)); } catch { /* 下面报 404 */ }
182
+ if (!names.length) throw Object.assign(new Error(`${rel} 里没有 .docx`), { status: 404 });
183
+ rel = `${rel}/${names[0]}`;
184
+ files.push({ abs: path.join(abs, names[0]), rel });
185
+ } else {
186
+ if (!stat.isFile()) throw Object.assign(new Error(`${rel} 不是文件`), { status: 400 });
187
+ files.push({ abs, rel });
188
+ }
189
+
190
+ if (!files.length) {
191
+ throw Object.assign(new Error(`${rel} 里没有可导出的文件`), { status: 400 });
192
+ }
193
+
194
+ // 树外引用只认 `assets/` 和产物自己的目录(旧 /exports/site 有这道闸,别丢)。
195
+ // 根层产物的自有前缀是空串 = 不设闸:它的树本来就是整个工作区,这条认账。
196
+ const ownPrefix = isTree ? (rel ? `${rel}/` : '') : (path.posix.dirname(rel) === '.' ? '' : `${path.posix.dirname(rel)}/`);
197
+ // 只有**标记语言**产物需要跟着引用扫下去。`browsable` 正好是这条线:能用浏览器
198
+ // 打开 = 由 html/css 组成 = 有外部引用要带。docx 是自包含的 zip(图片字体都在
199
+ // 包里),拿二进制去跑引用扫描器是白费力气,还可能在压缩字节里撞出假路径。
200
+ // 问注册表和能力,不问形态名 —— 加第四种形态时这行不用动。
201
+ const scannable = !!kindDef(kind) && can(kind, 'browsable');
202
+ let refs = []; let unresolved = []; let candidates = [];
203
+ if (scannable) {
204
+ // ⭐**传递引用要跟着扫下去。** html 引 css、css 再引图 —— 只扫产物自身的话,
205
+ // 那张图既不进包也不进清单(用户拍的规矩里最坏的状态),解压即裂图且零预警。
206
+ // deck 卡 / 单页站 / 根站三类全中,因为它们的 files 里只有页面本身。
207
+ // 定点到不动为止,最多四轮(防环、也防超深链把单次导出拖垮)。
208
+ const allow = ownPrefix === '' ? [] : [ownPrefix, 'assets/'];
209
+ const scanned = new Set(files.map(f => f.rel));
210
+ let batch = files;
211
+ for (let round = 0; round < 4 && batch.length; round++) {
212
+ const r = await collectAssetRefs({ files: batch, baseRoot: workspaceRoot, allowPrefixes: allow });
213
+ refs.push(...r.refs); unresolved.push(...r.unresolved); candidates.push(...r.candidates);
214
+ batch = [];
215
+ for (const rr of r.refs) {
216
+ if (scanned.has(rr) || !/\.(html?|css|m?js|cjs)$/i.test(rr)) continue;
217
+ scanned.add(rr);
218
+ const a = await safeResolve(workspaceRoot, rr);
219
+ if (a) batch.push({ abs: a, rel: rr });
220
+ }
221
+ }
222
+ refs = [...new Set(refs)];
223
+ }
224
+ if (deepWarn.length) {
225
+ unresolved.push({
226
+ from: rel || '(工作区根)',
227
+ why: `目录深度贴到上限 6 层,更深的文件没有进包(命中 ${deepWarn.length} 个)`,
228
+ snippet: deepWarn.slice(0, 3).join(' / '),
229
+ });
230
+ }
231
+
232
+ const assets = []; const missing = [];
233
+ const ownSet = new Set(files.map(f => f.rel));
234
+ // 脚本里的完整字面量路径(画廊 / lightbox 那种):**磁盘上真有这个文件就不是猜**,
235
+ // 提升成素材;解析不到的仍然只进清单。这不违反「扫不到的不多塞」——
236
+ // 它扫得到,只是来源是脚本而不是标签。
237
+ for (const c of candidates) {
238
+ const a = await safeResolve(workspaceRoot, c.rel);
239
+ if (!a || ownSet.has(c.rel)) continue;
240
+ try {
241
+ if ((await fs.stat(a)).isFile()) { refs.push(c.rel); continue; }
242
+ } catch { /* 不存在 */ }
243
+ unresolved.push({ from: c.from, why: '脚本里的字符串字面量(磁盘上没找到对应文件)', snippet: c.snippet });
244
+ }
245
+ refs = [...new Set(refs)].sort();
246
+ for (const r of refs) {
247
+ const a = await safeResolve(workspaceRoot, r);
248
+ if (!a) continue; // 软链逃逸的引用直接不收
249
+ let st = null;
250
+ try { st = await fs.stat(a); } catch { missing.push(r); continue; }
251
+ if (st.isFile()) { assets.push({ abs: a, rel: r }); continue; }
252
+ if (st.isDirectory()) {
253
+ // pretty-URL(`href="about/"`)指的是 `about/index.html`,不是裂图。
254
+ // 不处理的话每个多页站导出都会带一堆假的缺失警报。
255
+ const idxRel = `${r.replace(/\/$/, '')}/index.html`;
256
+ if (ownSet.has(idxRel)) continue;
257
+ const idxAbs = await safeResolve(workspaceRoot, idxRel);
258
+ if (idxAbs) {
259
+ try {
260
+ if ((await fs.stat(idxAbs)).isFile()) { assets.push({ abs: idxAbs, rel: idxRel }); continue; }
261
+ } catch { /* 落 missing */ }
262
+ }
263
+ }
264
+ missing.push(r);
265
+ }
266
+
267
+ return {
268
+ cardId, kind, rel, single,
269
+ title: path.basename(rel) || rel || '产物',
270
+ files, assets, missing, unresolved,
271
+ exportFormats: exportFormatsFor(kind, { single }),
272
+ };
273
+ }
274
+
275
+ /**
276
+ * 按类型收一批卡(「导出全部图片」这种)。
277
+ * 单张失败不拖累整批 —— 收不到的记进 skipped,让人看见少了什么。
278
+ */
279
+ export async function collectCards({ workspaceRoot, cardIds }) {
280
+ const bundles = []; const skipped = [];
281
+ for (const id of cardIds) {
282
+ try {
283
+ bundles.push(await collectCard({ workspaceRoot, cardId: id }));
284
+ } catch (err) {
285
+ skipped.push({ cardId: id, reason: err.message, status: err.status || 500 });
286
+ }
287
+ }
288
+ return { bundles, skipped };
289
+ }