dsh-design-studio 0.9.0 → 0.11.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 (95) hide show
  1. package/README.en.md +6 -4
  2. package/README.md +6 -4
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-sm.jpg +0 -0
  5. package/cordis.patch.yml +5 -0
  6. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/_shots/menu.jpg +0 -0
  7. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/app.css +64 -0
  8. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/cart.html +120 -0
  9. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/design.json +56 -0
  10. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/menu.html +200 -0
  11. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/order.html +135 -0
  12. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/tokens.css +43 -0
  13. package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/_shots/email.jpg +0 -0
  14. package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/design.json +20 -0
  15. package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/email.html +268 -0
  16. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/_shots/courses.jpg +0 -0
  17. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/courses.html +364 -0
  18. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/design.json +49 -0
  19. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/lesson.html +196 -0
  20. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/tokens.css +55 -0
  21. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/ui.css +255 -0
  22. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/_shots/poster.jpg +0 -0
  23. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/design.json +21 -0
  24. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/poster.html +165 -0
  25. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/tokens.css +35 -0
  26. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/_shots/home.jpg +0 -0
  27. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/app.css +155 -0
  28. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/booking.html +232 -0
  29. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/design.json +57 -0
  30. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/health.html +221 -0
  31. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/home.html +225 -0
  32. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/tokens.css +54 -0
  33. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/_shots/overview.jpg +0 -0
  34. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/app.css +79 -0
  35. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/design.json +51 -0
  36. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/entries.html +161 -0
  37. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/icons.svg +30 -0
  38. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/overview.html +181 -0
  39. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/tokens.css +50 -0
  40. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/_shots/home.jpg +0 -0
  41. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/book.html +279 -0
  42. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/design.json +21 -0
  43. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/home.html +348 -0
  44. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/style.css +297 -0
  45. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/tokens.css +49 -0
  46. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/01-cover.html +133 -0
  47. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/02-highlights.html +67 -0
  48. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/03-growth.html +89 -0
  49. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/04-milestones.html +92 -0
  50. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/05-next.html +99 -0
  51. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/06-thanks.html +61 -0
  52. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/_shots/01-cover.jpg +0 -0
  53. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/deck.css +67 -0
  54. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/design.json +25 -0
  55. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/tokens.css +30 -0
  56. package/gallery-seed/index.json +1 -0
  57. package/gallery-seed/thumbs/orderg-x__alley-noodles.webp +0 -0
  58. package/gallery-seed/thumbs/orderg-x__autumn-sale.webp +0 -0
  59. package/gallery-seed/thumbs/orderg-x__little-painter.webp +0 -0
  60. package/gallery-seed/thumbs/orderg-x__moon-fair.webp +0 -0
  61. package/gallery-seed/thumbs/orderg-x__paw-diary.webp +0 -0
  62. package/gallery-seed/thumbs/orderg-x__pocket-ledger.webp +0 -0
  63. package/gallery-seed/thumbs/orderg-x__time-bookshop.webp +0 -0
  64. package/gallery-seed/thumbs/orderg-x__year-review.webp +0 -0
  65. package/lib/browser.js +23 -9
  66. package/lib/bundle.js +12 -3
  67. package/lib/client.js +3106 -111
  68. package/lib/core.js +44 -11
  69. package/lib/gallery/css.js +230 -0
  70. package/lib/gallery/html.js +313 -0
  71. package/lib/gallery/index.js +22 -0
  72. package/lib/gallery/market-routes.js +306 -0
  73. package/lib/gallery/market.js +623 -0
  74. package/lib/gallery/pack.js +311 -0
  75. package/lib/gallery/policy.js +1560 -0
  76. package/lib/gallery/qrfind.js +181 -0
  77. package/lib/gallery/render.js +148 -0
  78. package/lib/gallery/routes.js +360 -0
  79. package/lib/gallery/tar.js +118 -0
  80. package/lib/gallery/use.js +125 -0
  81. package/lib/gallery/zip.js +212 -0
  82. package/lib/handoff.js +291 -29
  83. package/lib/history.js +3 -1
  84. package/lib/index.js +118 -24
  85. package/lib/inspire.js +142 -0
  86. package/lib/offline.js +17 -0
  87. package/lib/picker.js +425 -32
  88. package/lib/serve.js +88 -11
  89. package/lib/works.js +238 -0
  90. package/locale/en.json +2 -1
  91. package/locale/offline/en.json +6 -0
  92. package/locale/offline/zh.json +6 -0
  93. package/locale/zh.json +2 -1
  94. package/package.json +10 -3
  95. package/skills/ui-design/SKILL.md +9 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * 设计目录 → 设计包(打 zip 给朋友、以后分享到市场都从这里出)
3
+ *
4
+ * collectDesign(root) 挑要打进包的文件:只要白名单扩展名;跳过 refs/ exports/ handoff/ drafts/、_ 开头的草稿、隐藏文件(packDesign 再去掉别的 JSON)
5
+ * buildManifest(...) 没有 design.json 就生成一份(类型、尺寸读每屏的 meta,产品名从 <title> 拆),有就补齐屏列表
6
+ * packDesign(root, …) 规范化(去注释、站外链接改成 #、去表单地址)→ 图片去掉拍摄信息 → 用和机器人同一份规则查 → 出 zip
7
+ *
8
+ * 规则在 policy.js;这里只做「从用户的设计目录拿东西」这一层
9
+ */
10
+
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import { checkPack, normalizeHtml, titleOf, metaKind, message, LIMITS, FORMAT, pctBytes, imageInfo } from './policy.js'
14
+ import { writeZip } from './zip.js'
15
+
16
+ const PACK_EXT = /\.(html|css|svg|png|jpe?g|webp|gif|avif|json|md)$/i
17
+ const SKIP_DIRS = new Set(['refs', 'exports', 'handoff', 'drafts', 'node_modules', 'dist', 'build'])
18
+ const SKIP_FILES = new Set() // ATTRIBUTION.md 要跟着走:CC BY 的署名转手也不能丢
19
+ const MAX_SCAN = 2000
20
+
21
+ /** 目录里要打进包的文件 → { files: [{ path, abs }], skipped: [相对路径], tooBig: bool } */
22
+ export function collectDesign(root) {
23
+ const files = []
24
+ const skipped = []
25
+ let scanned = 0
26
+ let tooBig = false
27
+ const walk = (dir, rel, depth) => {
28
+ let list
29
+ try { list = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
30
+ list.sort((a, b) => a.name.localeCompare(b.name))
31
+ for (const d of list) {
32
+ if (++scanned > MAX_SCAN) { tooBig = true; return }
33
+ const r = rel ? `${rel}/${d.name}` : d.name
34
+ if (d.name.startsWith('.')) continue
35
+ if (d.isDirectory()) {
36
+ if (SKIP_DIRS.has(d.name.toLowerCase()) || d.name.startsWith('_')) continue
37
+ if (depth >= LIMITS.depth) { skipped.push(r + '/'); continue }
38
+ walk(path.join(dir, d.name), r, depth + 1)
39
+ continue
40
+ }
41
+ if (!d.isFile()) { skipped.push(r); continue } // 软链接之类不跟
42
+ if (d.name.startsWith('_') || SKIP_FILES.has(d.name.toLowerCase())) continue
43
+ if (!PACK_EXT.test(d.name)) { skipped.push(r); continue }
44
+ files.push({ path: r, abs: path.join(dir, d.name) })
45
+ }
46
+ }
47
+ walk(root, '', 0)
48
+ return { files, skipped, tooBig }
49
+ }
50
+
51
+ const CJK = /[㐀-鿿豈-﫿]/
52
+ const oneLine = (s, n) => [...String(s || '').replace(/[\x00-\x1f\x7f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim()].slice(0, n).join('')
53
+ const textOf = (s, n) => { const v = oneLine(s, n); return v ? (CJK.test(v) ? { zh: v } : { en: v }) : null }
54
+ /** 填的字和原来那种语言的一样(预填了没改)→ 用原来的(中英都在);改了 → 用填的 */
55
+ const typed = (v, old) => (v && old && typeof old === 'object' && !Array.isArray(old) && Object.entries(v).every(([k, x]) => old[k] === x) ? old : v)
56
+
57
+ /** 「首页 · 晨光咖啡」→ { screen: '首页', product: '晨光咖啡' } */
58
+ export function splitTitle(title) {
59
+ const m = /^(.*?)\s*[·•||]\s*(.+)$/.exec(String(title || '').trim())
60
+ return m ? { screen: m[1].trim(), product: m[2].trim() } : { screen: String(title || '').trim(), product: '' }
61
+ }
62
+
63
+ /** dsh-design meta 里的尺寸、系统(poster 1080x1920、desktop windows) */
64
+ function metaExtra(html) {
65
+ const tag = /<meta\b[^>]*\bname\s*=\s*["']?dsh-design["']?[^>]*>/i.exec(html.slice(0, 65536))
66
+ const c = tag && /\bcontent\s*=\s*(["'])([^"']*)\1/i.exec(tag[0])
67
+ const words = c ? c[2].toLowerCase().split(/[\s,;]+/) : []
68
+ const sz = words.map((w) => /^(\d{3,4})[x×*](\d{3,4})$/.exec(w)).find(Boolean)
69
+ return { size: sz ? `${sz[1]}x${sz[2]}` : '', os: words.includes('windows') ? 'windows' : words.includes('mac') ? 'mac' : '' }
70
+ }
71
+
72
+ /**
73
+ * 包的清单:有 design.json 就在它的基础上补(屏列表以现在目录里的为准,顺序先照清单),没有就从页面里读出来
74
+ * screens:listScreens 排好的顶层页面文件名;htmls:文件名 → 内容
75
+ */
76
+ export function buildManifest({ existing = null, screens, htmls, version = '', license = '', author = '', title = '', description = '', shareHash = '' }) {
77
+ const m = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...existing } : {}
78
+ const first = htmls.get(screens[0]) || ''
79
+ const kinds = screens.map((f) => metaKind(htmls.get(f) || '')).filter(Boolean)
80
+ const titles = new Map(screens.map((f) => [f, splitTitle(titleOf(htmls.get(f) || ''))]))
81
+ const product = [...titles.values()].map((x) => x.product).find(Boolean) || ''
82
+ const extra = metaExtra(first)
83
+ const out = {
84
+ format: FORMAT,
85
+ type: 'template',
86
+ // 分享时填的名字、介绍优先(改了就只留填的那种语言,免得另一种还是旧名字;没改就原样留着中英两种)
87
+ title: typed(textOf(title, 24), m.title) || (m.title && typeof m.title === 'object' ? m.title : textOf(product || titleOf(first), 24) || { zh: '我的设计' }),
88
+ ...(typed(textOf(description, 120), m.description) ? { description: typed(textOf(description, 120), m.description) } : m.description ? { description: m.description } : {}),
89
+ kind: m.kind || kinds[0] || 'web',
90
+ ...(m.size || extra.size ? { size: m.size || extra.size } : {}),
91
+ ...(m.os || extra.os ? { os: m.os || extra.os } : {}),
92
+ screens: screens.map((f) => {
93
+ const old = Array.isArray(m.screens) ? m.screens.find((s) => s && s.file === f) : null
94
+ const t = old && old.title ? old.title : textOf(titles.get(f).screen, 24)
95
+ return t ? { file: f, title: t } : { file: f }
96
+ }),
97
+ }
98
+ for (const k of ['style', 'categories', 'tags', 'lang', 'dark', 'assets']) if (m[k] !== undefined) out[k] = m[k]
99
+ out.license = license || m.license || 'CC0-1.0'
100
+ // 署名:这次填了名字就用它;没填沿用清单里原来的(从市场装的模板改的,原作者在 derivedFrom 里,不在这)
101
+ const who = oneLine(author, 40)
102
+ if (who && out.license === 'CC-BY-4.0') out.author = { name: who } // CC0 不用署名
103
+ else if (m.author && typeof m.author === 'object' && !m.from) out.author = m.author
104
+ out.ai = { generated: true, tool: `dsh-design-studio${version ? '@' + version : ''}` }
105
+ // 从别人的模板改的:带上原作(作者__名字@版本)
106
+ const from = m.from && typeof m.from === 'object' ? m.from : null
107
+ if (m.derivedFrom) out.derivedFrom = m.derivedFrom
108
+ else if (from && typeof from.id === 'string' && typeof from.rev === 'string') out.derivedFrom = `${from.id}@${from.rev}`.slice(0, 90)
109
+ if (/^[0-9a-f]{32}$/.test(shareHash)) out.shareHash = shareHash
110
+ return out
111
+ }
112
+
113
+ /**
114
+ * 网页 / 样式里内嵌的图片(data:image/…;base64,…)也去掉拍摄信息;改了就记一笔。
115
+ * 只改「一整段连续 base64、后面紧跟引号或右括号」的标准写法;中间夹了换行、实体、百分号的不动(改坏了图就打不开),
116
+ * 那种带信息的由检查拦下(imgMetaInline),让作者换成单独的图片文件
117
+ */
118
+ export function stripInline(text, file = '', notes = null) {
119
+ let changed = false
120
+ // 格式按字节认(写成 webp 的 PNG 也按 PNG 去)
121
+ const typeOf = (buf) => { const i = imageInfo(buf); return i ? i.type : '' }
122
+ const out = String(text).replace(/(data:image\/([a-z0-9.+-]+)(?:;[a-z0-9=._-]+)*;base64,)([A-Za-z0-9+/=]+)(?=["')]|&quot;|&#39;|&apos;)/gi, (all, head, t, b64) => {
123
+ const buf = Buffer.from(b64, 'base64')
124
+ const ty = typeOf(buf)
125
+ if (!ty) return all
126
+ const clean = stripMeta(buf, ty)
127
+ if (clean === buf || clean.length === buf.length) return all
128
+ changed = true
129
+ return head + clean.toString('base64')
130
+ })
131
+ // 不带 base64 的百分号写法(data:image/png,%89PNG…):解出字节、去掉信息,改写成标准的 base64 写法
132
+ const out2 = out.replace(/data:image\/([a-z0-9.+-]+)(?:;(?!base64)[a-z0-9=._-]+)*,((?:%[0-9A-Fa-f]{2}|[A-Za-z0-9!$*+.\/:;=?@_~-])+)(?=["')]|&quot;|&#39;|&apos;)/gi, (all, t, body) => {
133
+ const buf = pctBytes(body)
134
+ const ty = typeOf(buf)
135
+ if (!ty) return all
136
+ const clean = stripMeta(buf, ty)
137
+ if (clean === buf || clean.length === buf.length) return all
138
+ changed = true
139
+ return `data:image/${ty === 'jpg' ? 'jpeg' : ty};base64,${clean.toString('base64')}`
140
+ })
141
+ if (changed && notes) notes.push({ code: 'exif', x: file })
142
+ return changed ? out2 : text
143
+ }
144
+
145
+ /** JPEG / PNG / WebP / GIF 去掉拍摄信息(EXIF、GPS、XMP、文字块、注释);看不懂的原样返回(AVIF 不改,带着信息的由检查拦下) */
146
+ export function stripMeta(buf, ext) {
147
+ try {
148
+ if (ext === 'webp') {
149
+ if (buf.length < 12 || buf.toString('latin1', 0, 4) !== 'RIFF' || buf.toString('latin1', 8, 12) !== 'WEBP') return buf
150
+ const parts = []
151
+ let i = 12
152
+ let dropped = false
153
+ while (i + 8 <= buf.length) {
154
+ const t = buf.toString('latin1', i, i + 4)
155
+ const len = buf.readUInt32LE(i + 4)
156
+ const end = i + 8 + len + (len & 1)
157
+ if (end > buf.length) return buf
158
+ if (t === 'EXIF' || t === 'XMP ') dropped = true
159
+ else if (t === 'VP8X') { const c = Buffer.from(buf.subarray(i, end)); c[8] &= ~0x0c; parts.push(c) } // 去掉「有 EXIF / XMP」两个标记
160
+ else parts.push(buf.subarray(i, end))
161
+ i = end
162
+ }
163
+ if (!dropped) return buf
164
+ const body = Buffer.concat(parts)
165
+ const head = Buffer.from('RIFF\0\0\0\0WEBP', 'latin1')
166
+ head.writeUInt32LE(body.length + 4, 4)
167
+ return Buffer.concat([head, body])
168
+ }
169
+ if (ext === 'gif') {
170
+ if (buf.length < 13 || buf.toString('latin1', 0, 3) !== 'GIF') return buf
171
+ let i = 13
172
+ if (buf[10] & 0x80) i += 3 * (1 << ((buf[10] & 7) + 1))
173
+ const parts = [buf.subarray(0, i)]
174
+ const sub = (j) => { while (j < buf.length && buf[j] !== 0) j += buf[j] + 1; return j + 1 }
175
+ while (i < buf.length) {
176
+ const t = buf[i]
177
+ if (t === 0x3b) { parts.push(buf.subarray(i, i + 1)); return Buffer.concat(parts) }
178
+ if (t === 0x21) {
179
+ const label = buf[i + 1]
180
+ const end = sub(i + 2)
181
+ if (end > buf.length) return buf
182
+ const xmp = label === 0xff && buf[i + 2] === 11 && buf.toString('latin1', i + 3, i + 11) === 'XMP Data'
183
+ if (label !== 0xfe && !xmp) parts.push(buf.subarray(i, end))
184
+ i = end
185
+ } else if (t === 0x2c) {
186
+ const packed = buf[i + 9]
187
+ let j = i + 10
188
+ if (packed & 0x80) j += 3 * (1 << ((packed & 7) + 1))
189
+ const end = sub(j + 1)
190
+ if (end > buf.length) return buf
191
+ parts.push(buf.subarray(i, end))
192
+ i = end
193
+ } else return buf
194
+ }
195
+ return buf
196
+ }
197
+ if (ext === 'jpg' || ext === 'jpeg') {
198
+ if (buf[0] !== 0xff || buf[1] !== 0xd8) return buf
199
+ const parts = [buf.subarray(0, 2)]
200
+ let i = 2
201
+ while (i + 4 <= buf.length) {
202
+ if (buf[i] !== 0xff) return buf
203
+ const mk = buf[i + 1]
204
+ if (mk === 0xda) { parts.push(buf.subarray(i)); return Buffer.concat(parts) } // 图像数据开始:后面原样
205
+ if (mk === 0xd8 || (mk >= 0xd0 && mk <= 0xd7) || mk === 0x01) { parts.push(buf.subarray(i, i + 2)); i += 2; continue }
206
+ const len = buf.readUInt16BE(i + 2)
207
+ if (len < 2 || i + 2 + len > buf.length) return buf
208
+ // APP1(EXIF / XMP)、APP13(Photoshop)、COM(注释)去掉;APP0(JFIF)、APP2(ICC 色彩)、APP14(Adobe)留着
209
+ if (!(mk === 0xe1 || mk === 0xed || mk === 0xfe)) parts.push(buf.subarray(i, i + 2 + len))
210
+ i += 2 + len
211
+ }
212
+ return buf
213
+ }
214
+ if (ext === 'png') {
215
+ if (buf.length < 8 || buf.readUInt32BE(0) !== 0x89504e47) return buf
216
+ const parts = [buf.subarray(0, 8)]
217
+ let i = 8
218
+ while (i + 12 <= buf.length) {
219
+ const len = buf.readUInt32BE(i)
220
+ const type = buf.toString('latin1', i + 4, i + 8)
221
+ if (i + 12 + len > buf.length) return buf
222
+ if (!['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME'].includes(type)) parts.push(buf.subarray(i, i + 12 + len))
223
+ i += 12 + len
224
+ if (type === 'IEND') break
225
+ }
226
+ return Buffer.concat(parts)
227
+ }
228
+ } catch { /* 看不懂就原样 */ }
229
+ return buf
230
+ }
231
+
232
+ /**
233
+ * 打包前的全部步骤。screens:要打进包的屏(顺序就是包里的顺序)
234
+ * 返回 { ok, errors: [{ code, x, line, msg, sev }], flags, notes: [自动修了什么], skipped, manifest, files: [{ path, data }] }
235
+ */
236
+ export function packDesign(root, { screens, version = '', license = '', author = '', mode = 'zip', lang = 'zh', title = '', description = '', shareHash = '', secrets = [] }) {
237
+ const { files: picked, skipped, tooBig } = collectDesign(root)
238
+ const notes = []
239
+ const errors = []
240
+ if (tooBig) errors.push({ code: 'count', n: '2000+', max: LIMITS.files, sev: 'error' })
241
+ const screenSet = new Set(screens.map((s) => s.toLowerCase()))
242
+ let existing = null
243
+ const files = []
244
+ const htmls = new Map()
245
+ for (const f of picked) {
246
+ if (f.path === 'design.json') {
247
+ try { existing = JSON.parse(fs.readFileSync(f.abs, 'utf8')) } catch { existing = null }
248
+ continue
249
+ }
250
+ // 不在画布上的顶层页面(方向稿删掉了、改名前的旧文件):不打包
251
+ if (/\.html$/i.test(f.path) && !f.path.includes('/') && !screenSet.has(f.path.toLowerCase())) { skipped.push(f.path); continue }
252
+ // 别的 JSON(设置、记录、导出的数据…):模板用不上(不能跑脚本),可能带着个人信息,不打包;只留调节滑块的 tweaks.json
253
+ if (/\.json$/i.test(f.path) && f.path !== 'tweaks.json') { skipped.push(f.path); continue }
254
+ let data
255
+ try {
256
+ const st = fs.statSync(f.abs)
257
+ if (st.size > 4 * 1024 * 1024) { errors.push({ code: 'big', x: f.path, n: `${Math.ceil(st.size / 1024)}KB`, max: '400KB', sev: 'error' }); continue }
258
+ data = fs.readFileSync(f.abs)
259
+ } catch { continue }
260
+ const ext = f.path.slice(f.path.lastIndexOf('.') + 1).toLowerCase()
261
+ if (ext === 'html') {
262
+ const r = normalizeHtml(data.toString('utf8'), { file: f.path })
263
+ if (r.changes.length) notes.push(...r.changes)
264
+ const text = stripInline(r.text, f.path, notes)
265
+ htmls.set(f.path, text)
266
+ files.push({ path: f.path, data: Buffer.from(text, 'utf8') })
267
+ } else if (ext === 'css' || ext === 'svg') {
268
+ const text = data.toString('utf8')
269
+ const clean = stripInline(text, f.path, notes)
270
+ files.push({ path: f.path, data: clean === text ? data : Buffer.from(clean, 'utf8') })
271
+ } else if (ext === 'jpg' || ext === 'jpeg' || ext === 'png' || ext === 'webp' || ext === 'gif') {
272
+ const clean = stripMeta(data, ext)
273
+ if (clean.length !== data.length) notes.push({ code: 'exif', x: f.path })
274
+ files.push({ path: f.path, data: clean })
275
+ } else {
276
+ files.push({ path: f.path, data })
277
+ }
278
+ }
279
+ const order = screens.filter((s) => htmls.has(s))
280
+ const manifest = buildManifest({ existing, screens: order, htmls, version, license, author, title, description, shareHash })
281
+ files.unshift({ path: 'design.json', data: Buffer.from(JSON.stringify(manifest, null, 2) + '\n', 'utf8') })
282
+ const r = checkPack(files, { mode })
283
+ // 只该留在作者电脑上的东西(分享记号):哪个文件里有都拦
284
+ for (const sec of secrets.filter((x) => typeof x === 'string' && x.length >= 16)) {
285
+ for (const f of files) if (Buffer.from(f.data).includes(sec)) errors.push({ code: 'secret', x: f.path, sev: 'error' })
286
+ }
287
+ const all = [...errors, ...r.errors].map((p) => ({ ...p, msg: message(p, lang) }))
288
+ return {
289
+ ok: !all.length,
290
+ errors: all,
291
+ flags: r.flags.map((p) => ({ ...p, msg: message(p, lang) })),
292
+ notes,
293
+ skipped: skipped.slice(0, 50),
294
+ manifest,
295
+ files,
296
+ bytes: r.bytes,
297
+ }
298
+ }
299
+
300
+ /** 包 → zip(design.json 在最前,屏按顺序,其余按路径) */
301
+ export function zipOf(pack, now = new Date()) {
302
+ const rank = new Map(pack.manifest.screens.map((s, i) => [s.file, i]))
303
+ const files = [...pack.files].sort((a, b) => {
304
+ if (a.path === 'design.json') return -1
305
+ if (b.path === 'design.json') return 1
306
+ const ra = rank.has(a.path) ? rank.get(a.path) : 1e6
307
+ const rb = rank.has(b.path) ? rank.get(b.path) : 1e6
308
+ return ra - rb || a.path.localeCompare(b.path)
309
+ })
310
+ return writeZip(files, { now })
311
+ }