dsh-design-studio 0.10.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 +4 -3
  2. package/README.md +4 -3
  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 +1455 -39
  68. package/lib/core.js +22 -2
  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 +190 -36
  83. package/lib/history.js +3 -1
  84. package/lib/index.js +106 -19
  85. package/lib/inspire.js +142 -0
  86. package/lib/offline.js +17 -0
  87. package/lib/picker.js +33 -0
  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 +9 -2
  95. package/skills/ui-design/SKILL.md +9 -0
package/lib/index.js CHANGED
@@ -40,13 +40,19 @@
40
40
  * 页面本身由 serve.js 开的独立端口提供(见那边的说明)。
41
41
  */
42
42
 
43
+ import crypto from 'node:crypto'
43
44
  import fs from 'node:fs'
44
45
  import path from 'node:path'
45
46
  import { fileURLToPath } from 'node:url'
46
47
  import os from 'node:os'
47
48
  import { execFile } from 'node:child_process'
48
- import { createHub, webOrigins, trustedOrigins, resolveInRoot, ERR_CATCHER } from './serve.js'
49
- import { bundle, inlinePicker, MAX_TOTAL as BUNDLE_MAX } from './bundle.js'
49
+ import { createHub, webOrigins, trustedOrigins, resolveInRoot, ERR_CATCHER, defang } from './serve.js'
50
+ import { bundle, inlinePicker, strictMeta, MAX_TOTAL as BUNDLE_MAX } from './bundle.js'
51
+ import { createLibrary } from './works.js'
52
+ import { createInspiration } from './inspire.js'
53
+ import { createLibraryRoutes } from './gallery/routes.js'
54
+ import { createMarket } from './gallery/market.js'
55
+ import { createMarketRoutes } from './gallery/market-routes.js'
50
56
  import { createTracker, parseTarget, findRule, KINDS } from './core.js'
51
57
  import { createHistory } from './history.js'
52
58
  import { createPhone } from './phone.js'
@@ -82,7 +88,7 @@ const SKILL_RANK = 600 // 和 DSH 自带的技能同一档;用户 / 项目里
82
88
  * 几行之间靠这个互通(同一个包里的模块 Node 只加载一次):工作台起来后放 tracker、无头浏览器(browser);
83
89
  * 「自动打开预览」那行开着时 autoOpen > 0
84
90
  */
85
- export const studio = { tracker: null, autoOpen: 0, check: null, browser: null, log: () => {} }
91
+ export const studio = { tracker: null, autoOpen: 0, offline: 0, check: null, browser: null, market: null, log: () => {} }
86
92
 
87
93
  /** 设计技能:模型做界面设计时加载(DSH 的 skill 工具),也能让用户手动调 */
88
94
  export const designSkill = {
@@ -439,10 +445,10 @@ export const handoffTool = {
439
445
  name: 'handoff_design',
440
446
  description:
441
447
  '把右侧「设计工作台」里的设计(本地 HTML 那几屏)整理成交接包,准备做成真项目的代码:写好 <设计目录>/handoff/HANDOFF.md(每屏的文件、建议路由、跳转、设计变量、几屏共用该抽成组件的部分、交互和状态、调节滑块、素材、做法和验收),和每屏从上到下的截图。' +
442
- '用户说「转成代码」「做成真的项目」「用 React / Vue 做出来」时先调它,读 HANDOFF.md 照着做;每做完一屏用 compare_design 和设计稿比一遍,差异降到 3% 以下再做下一屏。' +
443
- 'framework:same 跟目标目录现有项目一样(认得出框架时默认)、react、vue、next、html;dest:代码放哪个目录(不填:会话目录是项目就放进去,不是就新建 <设计名>-app)。要先用 preview_page 打开过设计。',
448
+ '用户说「转成代码」「做成真的项目」「用 React / Vue / uni-app 做出来」「做成小程序」时先调它,读 HANDOFF.md 照着做;每做完一屏用 compare_design 和设计稿比一遍,差异降到 3% 以下再做下一屏(原生小程序在浏览器里跑不起来,照 HANDOFF.md 说的请用户在微信开发者工具里看)。' +
449
+ 'framework:same 跟目标目录现有项目一样(认得出框架时默认,uni-app(含 HBuilderX 建的、没有 package.json 的)、Taro、微信原生小程序也认得出)、react、vue、next、html、uniapp、weapp;dest:代码放哪个目录(不填:会话目录是项目就放进去,不是就新建 <设计名>-app)。要先用 preview_page 打开过设计。',
444
450
  parameters: specToJsonSchema({
445
- framework: { type: 'string', enum: ['same', ...Object.keys(FRAMEWORKS)], description: 'same 跟现有项目一样;react(React + Vite)、vue(Vue 3 + Vite)、next(Next.js)、html(纯 HTML / CSS)。不填:目标目录(或它所在的项目)认得出框架就 same,没有 package.json 的纯 HTML 站点 html,别的 react' },
451
+ framework: { type: 'string', enum: ['same', ...Object.keys(FRAMEWORKS)], description: 'same 跟现有项目一样;react(React + Vite)、vue(Vue 3 + Vite)、next(Next.js)、html(纯 HTML / CSS)、uniapp(uni-app,出微信小程序和网页版)、weapp(微信原生小程序 WXML / WXSS)。不填:目标目录(或它所在的项目)认得出框架就 same,没有 package.json 的纯 HTML 站点 html,小程序的设计 uniapp,别的 react' },
446
452
  dest: { type: 'string', description: '代码放哪:现有项目的目录(或它里面的子目录),或要新建的项目目录(绝对路径或相对会话目录)。不填:会话目录有 package.json 就放进去,没有就新建 <设计名>-app(设计目录在会话目录下就建在会话目录里,不然建在设计目录旁边)' },
447
453
  screens: { type: 'array', items: { type: 'string' }, description: '只转哪几屏(文件名);不填全部' },
448
454
  }),
@@ -470,15 +476,17 @@ export const handoffTool = {
470
476
  const dest = typeof a.dest === 'string' && a.dest.trim() ? resolvePath(a.dest, cwd) : defaultDest(t.root, cwd, detectProject(cwd))
471
477
  if (within(dest, t.root)) return { text: `代码别放进设计目录(${t.root})里:设计稿留着做对比。换一个目录。` }
472
478
  const project = projectFor(dest)
473
- const framework = typeof a.framework === 'string' && (a.framework === 'same' || FRAMEWORKS[a.framework]) ? a.framework : defaultFramework(project)
474
- if (framework === 'same' && !(project.pkg && project.framework)) return { text: `${dest} 里没有认得出的前端项目(package.json 里没有 React / Vue / Next …):framework 选 react、vue、next 或 html。` }
479
+ const framework = typeof a.framework === 'string' && (a.framework === 'same' || FRAMEWORKS[a.framework]) ? a.framework : defaultFramework(project, kind)
480
+ if (framework === 'same' && !project.framework) return { text: `${dest} 里没有认得出的前端项目(package.json 里没有 React / Vue / Next / uni-app …,也不是 HBuilderX 或微信原生小程序的项目):framework 选 react、vue、next、html、uniapp 或 weapp。` }
475
481
  let r
476
482
  try {
477
483
  r = await buildHandoff(studio.browser, { root: t.root, screens: pick.list, kind, size, framework, dest, project, system: systemOfDir(t.root) })
478
484
  } catch (e) {
479
485
  return { text: `没写成交接包:${String((e && e.message) || e).slice(0, 200)}` }
480
486
  }
481
- const head = `交接包写好了:${r.file}(${r.shots} 张截图在 handoff/shots/)。下面是全文,照「做法和验收」一屏一屏做,每屏做完用 compare_design 比。\n\n`
487
+ // 原生小程序跑不了网页版:比不了,照 HANDOFF.md 请用户在微信开发者工具里看
488
+ const native = framework === 'weapp' || (framework === 'same' && project.framework === 'weapp')
489
+ const head = `交接包写好了:${r.file}(${r.shots} 张截图在 handoff/shots/)。下面是全文,照「做法和验收」一屏一屏做,${native ? '每屏做完请用户在微信开发者工具里看' : '每屏做完用 compare_design 比'}。\n\n`
482
490
  // 每屏顶上一张附上,直接看到要做成什么样
483
491
  const firsts = r.rows.filter((row) => row.shots.length).slice(0, 4).map((row) => ({ key: row.key, jpg: fs.readFileSync(path.join(r.dir, 'shots', row.shots[0])) }))
484
492
  const kept = firsts.length ? await keepImages(ctx, id, firsts) : {}
@@ -569,6 +577,8 @@ async function keepImages(ctx, id, list, toFile = false) {
569
577
  return { files }
570
578
  }
571
579
 
580
+ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '' } catch { return '' } })()
581
+
572
582
  const fileStore = {
573
583
  load: () => JSON.parse(fs.readFileSync(STORE_FILE, 'utf8')),
574
584
  save: (data) => {
@@ -641,9 +651,16 @@ export function apply(ctx) {
641
651
  const previewPorts = new Set()
642
652
  let tracker = null
643
653
  const history = createHistory({ base: HISTORY_DIR, log })
654
+ // 我的作品 + 哪些文件是别人做的(设计库来的):别人做的、没改过的页面预览时加硬规矩
655
+ const library = createLibrary({ log })
656
+ // 设计市场:目录和批次包从 npm 镜像来,验过签名、逐个文件核对过才用;市场上的页面原样预览时也加硬规矩
657
+ const seedDir = fileURLToPath(new URL('../gallery-seed', import.meta.url))
658
+ // 「设计库不联网」那行开着:市场不访问任何网址
659
+ const market = createMarket({ version: VERSION, log, seedDir: fs.existsSync(path.join(seedDir, 'index.json')) ? seedDir : '', online: () => studio.offline === 0 })
644
660
  const hub = createHub({
645
661
  log,
646
662
  history,
663
+ strict: { html: (buf) => library.strictHtml(buf) || market.strictHtml(buf), root: (dir) => library.strictRoot(dir) || market.isStaging(dir), versions: (dir) => library.strictVersions(dir) },
647
664
  // 桌面版的 dsh-app://app 是默认就认的;这里补上 DSH 网页端的地址,和 DSH 配置里声明的对外地址(反向代理 / 域名)
648
665
  parents: () => {
649
666
  let own = []
@@ -664,9 +681,12 @@ export function apply(ctx) {
664
681
  open,
665
682
  log,
666
683
  history,
667
- store: fileStore,
684
+ // 记会话的同时,在做的设计目录补进「我的作品」
685
+ store: { load: fileStore.load, save: (data) => { fileStore.save(data); try { library.sync(data) } catch (e) { log(e) } } },
668
686
  alive: (origin) => hub.alive(origin),
669
687
  autoOpen: () => studio.autoOpen > 0,
688
+ // 装好的别人的设计系统退回旧版:退回来的页面先记成别人做的(预览照样加规矩)
689
+ beforeRestore: (root, html) => { if (html.length) library.addPast(root, html) },
670
690
  skipPorts: () => {
671
691
  const s = new Set(previewPorts)
672
692
  const own = ownHost(ctx).split(':')[1]
@@ -678,7 +698,11 @@ export function apply(ctx) {
678
698
  ctx.effect(() => {
679
699
  studio.tracker = tracker
680
700
  studio.log = log
681
- return () => { if (studio.tracker === tracker) { studio.tracker = null; studio.log = () => {} } }
701
+ studio.market = market
702
+ return () => {
703
+ if (studio.tracker === tracker) { studio.tracker = null; studio.log = () => {} }
704
+ if (studio.market === market) { market.abort(); studio.market = null }
705
+ }
682
706
  }, 'dsh-design-studio: tracker')
683
707
  // check_design / look_design 用的无头浏览器:用到时才开,闲置自己关,这一行停用时关掉
684
708
  ctx.effect(() => {
@@ -746,7 +770,33 @@ export function apply(ctx) {
746
770
  const t = tracker.stateOf(session).target
747
771
  let tweaks = null
748
772
  if (t && t.kind === 'file') { try { tweaks = readTweaks(t.root) } catch (e) { log(e) } }
749
- json(res, 200, { ...tracker.snapshot(session), phone: { on: ph.on, seen: ph.seen || 0 }, tweaks, ...(c ? { checkReq: c.id } : {}) })
773
+ const snap = tracker.snapshot(session)
774
+ json(res, 200, { ...snap, strict: strictScreens(snap), phone: { on: ph.on, seen: ph.seen || 0 }, tweaks, ...(c ? { checkReq: c.id } : {}) })
775
+ }
776
+
777
+ /** 正在看的几屏里,哪些是别人做的、没改过的(地址列表):右侧栏给它们的 iframe 加沙盒、不给剪贴板 */
778
+ const strictCache = new Map() // 文件 → { sig, strict }
779
+ function strictScreens(snap) {
780
+ const t = snap.target
781
+ if (!t || t.kind !== 'file' || !Array.isArray(snap.screens)) return []
782
+ const out = []
783
+ const files = new Set([...snap.screens.map((s) => s.key), snap.focus].filter(Boolean))
784
+ for (const key of files) {
785
+ const file = path.join(t.root, key)
786
+ let st
787
+ try { st = fs.statSync(file) } catch { continue }
788
+ const sig = `${st.mtimeMs}:${st.size}:${library.file && fs.existsSync(library.file) ? fs.statSync(library.file).mtimeMs : 0}`
789
+ let hit = strictCache.get(file)
790
+ if (!hit || hit.sig !== sig) {
791
+ let strict = false
792
+ try { strict = st.size <= 8 * 1024 * 1024 && library.strictHtml(fs.readFileSync(file)) } catch { strict = false }
793
+ hit = { sig, strict }
794
+ strictCache.set(file, hit)
795
+ if (strictCache.size > 500) strictCache.delete(strictCache.keys().next().value)
796
+ }
797
+ if (hit.strict) out.push(`${snap.origin}/${encodeURIComponent(key)}`)
798
+ }
799
+ return out
750
800
  }
751
801
 
752
802
  /** 调节滑块写回文件:values = { id: 值 } */
@@ -806,11 +856,12 @@ export function apply(ctx) {
806
856
  }
807
857
 
808
858
  /** 转成代码前:会话目录是不是个前端项目(右侧栏默认选「跟项目一样」、填好放哪) */
809
- function projectRoute(req, res) {
859
+ async function projectRoute(req, res) {
810
860
  if (!admit(req, res)) return
811
861
  const q = new URL(req.url, 'http://x').searchParams
812
862
  const session = q.get('session') || ''
813
- const cwd = tracker.cwdOf(session)
863
+ // 新会话(还没跑过工具,比如从设计库「用这个开始」、浏览打开的)插件不知道目录:问 DSH,别拿家目录当项目找
864
+ const cwd = await sessionCwd(session)
814
865
  const p = detectProject(cwd)
815
866
  const t = tracker.stateOf(session).target
816
867
  const root = t && t.kind === 'file' ? t.root : ''
@@ -820,7 +871,9 @@ export function apply(ctx) {
820
871
  const pk = picked ? pickedDest(resolvePath(picked, cwd), root) : null
821
872
  const dest = pk ? pk.dest : defaultDest(root, cwd, p)
822
873
  const project = projectFor(dest)
823
- json(res, 200, { cwd, root, project, fw: defaultFramework(project), dest, why: pk ? pk.why : '', frameworks: Object.fromEntries(Object.entries(FRAMEWORKS).map(([k, v]) => [k, v.label])) })
874
+ // 设计类型跟着回去:小程序的设计右侧栏多两个选项(uni-app、原生小程序),默认 uni-app
875
+ const kind = root ? designFor(session).kind : ''
876
+ json(res, 200, { cwd, root, kind, project, fw: defaultFramework(project, kind), dest, why: pk ? pk.why : '', frameworks: Object.fromEntries(Object.entries(FRAMEWORKS).map(([k, v]) => [k, v.label])) })
824
877
  }
825
878
 
826
879
  async function checkRoute(req, res) {
@@ -848,7 +901,8 @@ export function apply(ctx) {
848
901
  const session = String(body.session || '')
849
902
  if (!session) return json(res, 400, { error: '缺 session' })
850
903
  try {
851
- const target = parseTarget(body.target, tracker.cwdOf(session))
904
+ // 输入框里写的相对路径按会话目录算(新会话也问 DSH 要)
905
+ const target = parseTarget(body.target, await sessionCwd(session))
852
906
  await tracker.setTarget(session, target, { source: 'user', popup: false })
853
907
  json(res, 200, tracker.snapshot(session))
854
908
  } catch (e) {
@@ -974,9 +1028,13 @@ export function apply(ctx) {
974
1028
  const page = read(key)
975
1029
  if (!page) return json(res, 404, { error: 'no-screen' })
976
1030
  const href = st.origin ? `${st.origin}/${v ? `__dsh_design__/v/${v}/` : ''}${key.split('/').map(encodeURIComponent).join('/')}` : ''
977
- const html = bundle(page.toString('utf8'), { key, read, base: href, lead: ERR_CATCHER, tail: inlinePicker(fs.readFileSync(PICKER_FILE, 'utf8'), parent, href) })
1031
+ // 别人做的设计(设计库来的、没改过):加硬规矩,右侧栏的沙盒也不放行表单
1032
+ const strict = library.strictHtml(page) || (v > 0 && library.strictVersions(t.root))
1033
+ const nonce = strict ? crypto.randomBytes(16).toString('base64') : ''
1034
+ const lead = strict ? strictMeta(nonce) + ERR_CATCHER.replace('<script>', `<script nonce="${nonce}">`) : ERR_CATCHER
1035
+ const html = bundle(strict ? defang(page.toString('utf8')) : page.toString('utf8'), { key, read, base: href, lead, tail: inlinePicker(fs.readFileSync(PICKER_FILE, 'utf8'), parent, href, nonce) })
978
1036
  // 用到的文件太多就不列(null = 右侧栏不按文件筛,目录里一有改动就重新打包)
979
- json(res, 200, { html, deps: deps.size > 200 ? null : [...deps] })
1037
+ json(res, 200, { html, deps: deps.size > 200 ? null : [...deps], strict })
980
1038
  } catch (e) {
981
1039
  json(res, 500, { error: e.message })
982
1040
  }
@@ -1116,7 +1174,36 @@ export function apply(ctx) {
1116
1174
  json(res, 200, st.on ? { ...st, qr: qrMatrix(st.url).map((row) => row.map((c) => (c ? 1 : 0)).join('')) } : st)
1117
1175
  }
1118
1176
 
1119
- const routes = { state, open: openRoute, view: viewRoute, close: closeRoute, history: historyRoute, restore: restoreRoute, edit: editRoute, style: styleRoute, bundle: bundleRoute, ref: refRoute, export: exportRoute, doc: docRoute, reveal: revealRoute, phone: phoneRoute, check: checkRoute, locate: locateRoute, tweak: tweakRoute, systems: systemsRoute, systhumb: systhumbRoute, project: projectRoute }
1177
+ // 设计库:我的作品、灵感、打 zip、拖 zip 进来用
1178
+ const inspiration = createInspiration({ log })
1179
+ /**
1180
+ * 会话目录:跑过工具的会话插件自己记着;新会话(还没跑过工具)问 DSH——先看活着的会话,再查存下来的会话头。
1181
+ * 都拿不到给家目录(「用这个开始」就放到 ~/DSH 设计)
1182
+ */
1183
+ async function sessionCwd(id) {
1184
+ const known = tracker.cwdOf(id)
1185
+ if (known && path.resolve(known) !== path.resolve(os.homedir())) return known
1186
+ const ok = (c) => typeof c === 'string' && path.isAbsolute(c) ? c : ''
1187
+ try {
1188
+ const ss = ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null
1189
+ const live = ss && typeof ss.list === 'function' ? [...ss.list()].find((s) => s && s.id === id) : null
1190
+ const c = live && ok(live.header && live.header.cwd)
1191
+ if (c) return c
1192
+ } catch (e) { log(e) }
1193
+ try {
1194
+ const q = ctx && typeof ctx.get === 'function' ? ctx.get('sessionQuery') : null
1195
+ if (q && typeof q.listSessions === 'function') {
1196
+ const rec = (await q.listSessions()).find((r) => r && r.header && r.header.id === id)
1197
+ const c = rec && ok(rec.header.cwd)
1198
+ if (c) return c
1199
+ }
1200
+ } catch (e) { log(e) }
1201
+ return known
1202
+ }
1203
+ const lib = createLibraryRoutes({ admit, readBody, json, tracker, library, inspiration, studio, open, storeLoad: fileStore.load, version: VERSION, exported, log, sessionCwd })
1204
+ const mk = createMarketRoutes({ admit, readBody, json, tracker, library, market, open, systemsDir: SYSTEMS_DIR, log, sessionCwd })
1205
+
1206
+ const routes = { works: lib.works, workthumb: lib.workthumb, pack: lib.pack, usezip: lib.usezip, inspire: lib.inspire, inspimg: lib.inspimg, ...mk, state, open: openRoute, view: viewRoute, close: closeRoute, history: historyRoute, restore: restoreRoute, edit: editRoute, style: styleRoute, bundle: bundleRoute, ref: refRoute, export: exportRoute, doc: docRoute, reveal: revealRoute, phone: phoneRoute, check: checkRoute, locate: locateRoute, tweak: tweakRoute, systems: systemsRoute, systhumb: systhumbRoute, project: projectRoute }
1120
1207
  for (const [key, handler] of Object.entries(routes)) {
1121
1208
  ctx.effect(() => ctx.webServer.register({
1122
1209
  kind: 'exact',
package/lib/inspire.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * 设计库 › 灵感:只存在用户电脑上,不上传、不统计
3
+ *
4
+ * <库>/inspiration.json { format: 1, items: [{ id, type, at, … }] },新的在前
5
+ * image { name, file, w, h } 拖进来的参考图(图在 <库>/inspiration/<file>)→「照这个做」走参考图流程
6
+ * url { url, name } 存下的网址 →「提取设计系统」
7
+ * style { name, style, mid? } 我的风格(从当前设计存下来的一组配色、字体、圆角;mid=从市场收藏的那张)→ 和内置风格卡放一起用
8
+ * market { mid, mtype, name } 收藏的市场设计(模板 / 设计系统):只记 id;离线看靠市场缓存,下架了界面标「已下架」
9
+ *
10
+ * 风格的字段和风格卡一样(policy.checkStyle 的白名单);本机存的不卡对比度(分享时才卡)
11
+ */
12
+
13
+ import crypto from 'node:crypto'
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+ import { LIBRARY_DIR } from './works.js'
17
+ import { checkStyle, imageInfo } from './gallery/policy.js'
18
+ import { ID_RE } from './gallery/market.js'
19
+
20
+ const MAX_ITEMS = 300
21
+ const MAX_IMAGE = 6 * 1024 * 1024
22
+ const IMG_EXT = { png: 'png', jpg: 'jpg', webp: 'webp', gif: 'gif', avif: 'avif' }
23
+ const oneLine = (s, n) => [...String(s == null ? '' : s).replace(/[\x00-\x1f\x7f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim()].slice(0, n).join('')
24
+
25
+ export function createInspiration({ dir = LIBRARY_DIR, log = () => {}, now = Date.now } = {}) {
26
+ const file = path.join(dir, 'inspiration.json')
27
+ const imgDir = path.join(dir, 'inspiration')
28
+
29
+ function load() {
30
+ try {
31
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
32
+ if (raw && Array.isArray(raw.items)) return { format: 1, items: raw.items.filter((x) => x && typeof x.id === 'string' && /^[a-z0-9]{8,32}$/.test(x.id)) }
33
+ } catch (e) {
34
+ if (e.code !== 'ENOENT') {
35
+ log(e)
36
+ try { fs.copyFileSync(file, `${file}.bad-${now()}`) } catch { /* 算了 */ }
37
+ }
38
+ }
39
+ return { format: 1, items: [] }
40
+ }
41
+
42
+ function save(data) {
43
+ // 太多了:最旧的图片 / 网址先删(我的风格、收藏留着)
44
+ while (data.items.length > MAX_ITEMS) {
45
+ let i = data.items.length - 1
46
+ while (i >= 0 && (data.items[i].type === 'style' || data.items[i].type === 'market')) i--
47
+ if (i < 0) break
48
+ const [gone] = data.items.splice(i, 1)
49
+ if (gone.type === 'image' && gone.file) try { fs.rmSync(path.join(imgDir, gone.file), { force: true }) } catch { /* 算了 */ }
50
+ }
51
+ fs.mkdirSync(dir, { recursive: true })
52
+ const tmp = `${file}.${process.pid}.tmp`
53
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 1), { mode: 0o600 })
54
+ fs.renameSync(tmp, file)
55
+ }
56
+
57
+ const newId = () => crypto.randomBytes(8).toString('hex')
58
+
59
+ function add(item) {
60
+ const data = load()
61
+ const it = { id: newId(), at: now(), ...item }
62
+ data.items.unshift(it)
63
+ save(data)
64
+ return it
65
+ }
66
+
67
+ /** 参考图:data 是图片的 Buffer */
68
+ function addImage({ name, data }) {
69
+ if (!Buffer.isBuffer(data) || !data.length) throw new Error('不是图片')
70
+ if (data.length > MAX_IMAGE) throw new Error('图片太大(最多 6MB)')
71
+ const info = imageInfo(data)
72
+ const ext = info && IMG_EXT[info.type]
73
+ if (!ext) throw new Error('不是图片')
74
+ const id = newId()
75
+ fs.mkdirSync(imgDir, { recursive: true })
76
+ fs.writeFileSync(path.join(imgDir, `${id}.${ext}`), data, { mode: 0o600 })
77
+ const data2 = load()
78
+ const it = { id, at: now(), type: 'image', name: oneLine(name, 60) || 'image', file: `${id}.${ext}`, w: info.w, h: info.h }
79
+ data2.items.unshift(it)
80
+ save(data2)
81
+ return it
82
+ }
83
+
84
+ /** 网址:只收 http(s),带不带协议都行 */
85
+ function addUrl({ url, name }) {
86
+ let s = String(url || '').trim()
87
+ if (!/^https?:\/\//i.test(s)) s = 'https://' + s
88
+ let u
89
+ try { u = new URL(s) } catch { throw new Error('不是网址') }
90
+ if (!/^https?:$/.test(u.protocol) || !u.hostname.includes('.') || s.length > 500) throw new Error('不是网址')
91
+ const data = load()
92
+ const href = u.href
93
+ const old = data.items.find((x) => x.type === 'url' && x.url === href)
94
+ if (old) return old
95
+ return add({ type: 'url', url: href, name: oneLine(name, 60) || u.hostname })
96
+ }
97
+
98
+ /** 我的风格:字段按风格卡的白名单查;对比度本机不卡 */
99
+ function addStyle({ name, style, mid }) {
100
+ const problems = checkStyle(style).filter((p) => p.code !== 'contrast')
101
+ if (problems.length) throw new Error(`风格不对:${problems.map((p) => p.x).join('、')}`)
102
+ const s = {}
103
+ for (const k of ['c', 'head', 'w', 'radius', 'shadow', 'nzh', 'nen', 'dzh', 'den', 'bgImage']) if (style[k] !== undefined) s[k] = style[k]
104
+ // 从市场收藏的:同一张只存一份
105
+ const from = typeof mid === 'string' && ID_RE.test(mid) ? mid : ''
106
+ if (from) {
107
+ const old = load().items.find((x) => x.type === 'style' && x.mid === from)
108
+ if (old) return old
109
+ }
110
+ return add({ type: 'style', name: oneLine(name, 24) || '我的风格', style: s, ...(from ? { mid: from } : {}) })
111
+ }
112
+
113
+ /** 收藏市场上的一套:同一套只记一次 */
114
+ function addMarket({ mid, mtype, name }) {
115
+ const id = String(mid || '')
116
+ if (!ID_RE.test(id) || (mtype !== 'template' && mtype !== 'system')) throw new Error('不是市场上的设计')
117
+ const data = load()
118
+ const old = data.items.find((x) => x.type === 'market' && x.mid === id)
119
+ if (old) return old
120
+ return add({ type: 'market', mid: id, mtype, name: oneLine(name, 40) || id.split('__')[1] })
121
+ }
122
+
123
+ function remove(id) {
124
+ const data = load()
125
+ const i = data.items.findIndex((x) => x.id === id)
126
+ if (i < 0) return false
127
+ const [gone] = data.items.splice(i, 1)
128
+ if (gone.type === 'image' && gone.file) try { fs.rmSync(path.join(imgDir, path.basename(gone.file)), { force: true }) } catch { /* 算了 */ }
129
+ save(data)
130
+ return true
131
+ }
132
+
133
+ /** 图片文件的绝对路径(只认登记过的) */
134
+ function imageFile(id) {
135
+ const it = load().items.find((x) => x.id === id && x.type === 'image')
136
+ if (!it || !it.file || path.basename(it.file) !== it.file) return null
137
+ const p = path.join(imgDir, it.file)
138
+ return fs.existsSync(p) ? p : null
139
+ }
140
+
141
+ return { list: () => load().items, addImage, addUrl, addStyle, addMarket, remove, imageFile, file }
142
+ }
package/lib/offline.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * dsh-design-studio/offline:设计库不联网(默认关)。
3
+ * 打开后设计库的「市场」一个网址都不访问,只显示已经下载过的;「我的作品」「灵感」照常用。
4
+ * 默认关、反着来:老版本升级上来插件页里还没有这一行时,市场照常联网
5
+ */
6
+ import { studio } from './index.js'
7
+
8
+ export const name = 'dsh-design-studio-offline'
9
+
10
+ export function apply(ctx) {
11
+ ctx.effect(() => {
12
+ studio.offline++
13
+ // 正在下载的目录 / 批次包马上掐掉(不等下完)
14
+ try { if (studio.market) studio.market.abort() } catch { /* 市场还没起来 */ }
15
+ return () => { studio.offline-- }
16
+ }, 'dsh-design-studio: gallery offline')
17
+ }
package/lib/picker.js CHANGED
@@ -36,6 +36,9 @@
36
36
  s.textContent = '*{scrollbar-width:none}*::-webkit-scrollbar{display:none}'
37
37
  ;(document.head || document.documentElement).appendChild(s)
38
38
  }
39
+ // 别人做的设计(设计库来的、没改过):服务端在脚本标签上标了 data-strict(远程看时在 cfg 里)。
40
+ // 点站外链接不跳(页面本来就跑不了脚本、交不出表单,这里再挡住「点一下跳到别的网站」),告诉右侧栏拦下了
41
+ const STRICT = !!cfg.strict || !!(document.currentScript && document.currentScript.getAttribute('data-strict') === '1')
39
42
  const SEED_KEY = '__dsh_design_seed__'
40
43
  // 远程看(右侧栏把这一屏打包放进沙盒 iframe 的 srcdoc,location 是 about:srcdoc):这一屏原本的地址由右侧栏给
41
44
  const HERE = cfg.remote && typeof cfg.href === 'string' ? cfg.href : ''
@@ -1262,6 +1265,36 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1262
1265
  post({ type: 'links', links })
1263
1266
  }
1264
1267
 
1268
+ if (STRICT) {
1269
+ const offsite = (raw) => {
1270
+ const v = String(raw || '').trim()
1271
+ if (!v || v.startsWith('#')) return ''
1272
+ let u
1273
+ try { u = new URL(v, here()) } catch { return v.slice(0, 200) }
1274
+ return u.origin === hereUrl().origin ? '' : u.href.slice(0, 200)
1275
+ }
1276
+ addEventListener('click', (e) => {
1277
+ const a = e.target && e.target.closest ? e.target.closest('a[href], area[href]') : null
1278
+ if (!a) return
1279
+ const dl = a.hasAttribute('download')
1280
+ const out = offsite(a.getAttribute('href'))
1281
+ // 下载、开新窗口沙盒本来就不让;站外的地址这里直接挡(带 download 的按「下载」报)
1282
+ if (!out && !dl) return
1283
+ e.preventDefault()
1284
+ e.stopImmediatePropagation()
1285
+ post({ type: 'blocked', href: dl ? 'download' : out })
1286
+ }, true)
1287
+ if (window.navigation && typeof navigation.addEventListener === 'function') {
1288
+ navigation.addEventListener('navigate', (e) => {
1289
+ if (!e.cancelable || e.navigationType === 'traverse') return
1290
+ const out = offsite(e.destination && e.destination.url)
1291
+ if (!out && !e.downloadRequest) return
1292
+ e.preventDefault()
1293
+ post({ type: 'blocked', href: out || 'download' })
1294
+ })
1295
+ }
1296
+ }
1297
+
1265
1298
  // 演示:点了跳页的地方,不在 iframe 里直接跳,告诉右侧栏换一屏(带过场动画)
1266
1299
  // 远程看也是:沙盒里的页面跳不过去(地址指着电脑本机),由右侧栏打包那一页换上来
1267
1300
  let demo = false
package/lib/serve.js CHANGED
@@ -12,11 +12,13 @@
12
12
  * 每个 HTML 页面都注入 /__dsh_design__/picker.js:负责点选 / 框选,用 postMessage 和右侧栏通信。
13
13
  */
14
14
 
15
+ import crypto from 'node:crypto'
15
16
  import fs from 'node:fs'
16
17
  import http from 'node:http'
17
18
  import net from 'node:net'
18
19
  import path from 'node:path'
19
20
  import { fileURLToPath } from 'node:url'
21
+ import { tokenize } from './gallery/html.js'
20
22
 
21
23
  export const INTERNAL = '/__dsh_design__/'
22
24
  const PICKER_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker.js')
@@ -71,7 +73,7 @@ export function hostOk(host, port) {
71
73
  * picker 在 </body> 前,等它装上,前面脚本的报错早过去了
72
74
  */
73
75
  export const ERR_CATCHER = '<script>(()=>{if(window.__dshErrs)return;const L=window.__dshErrs=[];const add=(x)=>{if(L.length<30)L.push(x)};' +
74
- 'addEventListener("error",(e)=>{const t=e.target;if(t&&t!==window&&t.tagName){add({res:1,tag:t.tagName.toLowerCase(),src:String(t.currentSrc||t.src||t.href||"").slice(0,300)})}' +
76
+ 'addEventListener("error",(e)=>{const t=e.target;if(t&&t!==window&&t.tagName){const h=t.href;add({res:1,tag:t.tagName.toLowerCase(),src:String(t.currentSrc||t.src||(h&&typeof h.baseVal==="string"?h.baseVal:h)||"").slice(0,300)})}' +
75
77
  'else add({msg:String(e.message||"").slice(0,300),src:String(e.filename||"").slice(0,300),line:e.lineno||0})},true);' +
76
78
  'addEventListener("unhandledrejection",(e)=>{const r=e.reason;add({msg:String((r&&r.message)||r).slice(0,300),src:"",line:0})})})()</script>'
77
79
 
@@ -81,12 +83,71 @@ export function headInsert(html, tag) {
81
83
  return m ? html.slice(0, m.index + m[0].length) + tag + html.slice(m.index + m[0].length) : tag + html
82
84
  }
83
85
 
84
- /** 在 </body> 前(没有就在末尾)插入选取脚本,<head> 最前面放报错记录 */
85
- export function injectPicker(html) {
86
- const tag = `<script src="${INTERNAL}picker.js"></script>`
86
+ /**
87
+ * 在 </body> 前(没有就在末尾)插入选取脚本,<head> 最前面放报错记录。
88
+ * nonce:别人做的设计(设计库来的)加了「只准带这个记号的脚本跑」的规矩,我们自己的两段脚本带上它;data-strict 让 picker 拦外链
89
+ */
90
+ export function injectPicker(html, { nonce = '' } = {}) {
91
+ const n = nonce ? ` nonce="${nonce}"` : ''
92
+ const tag = `<script src="${INTERNAL}picker.js"${n}${nonce ? ' data-strict="1"' : ''}></script>`
87
93
  const i = html.toLowerCase().lastIndexOf('</body>')
88
- return headInsert(i >= 0 ? html.slice(0, i) + tag + html.slice(i) : html + tag, ERR_CATCHER)
94
+ return headInsert(i >= 0 ? html.slice(0, i) + tag + html.slice(i) : html + tag, nonce ? ERR_CATCHER.replace('<script>', `<script${n}>`) : ERR_CATCHER)
95
+ }
96
+
97
+ /**
98
+ * 别人做的设计(设计库市场来的、拖进来的 zip,且没被改过)预览时的硬规矩:
99
+ * 只有带 nonce 的脚本(我们注入的 picker)能跑;不联网(样式、图片、字体只能是自己这个预览地址或 data:);
100
+ * 表单交不出去;不能嵌别的页面、不能改 <base>
101
+ */
102
+ export function strictCsp(nonce) {
103
+ return [
104
+ "default-src 'none'",
105
+ `script-src 'nonce-${nonce}'`,
106
+ "style-src 'self' 'unsafe-inline'",
107
+ "img-src 'self' data: blob:",
108
+ "font-src 'self' data:",
109
+ "media-src 'self' data: blob:",
110
+ "connect-src 'self'",
111
+ "frame-src 'none'",
112
+ "child-src 'none'",
113
+ "worker-src 'none'",
114
+ "manifest-src 'none'",
115
+ "object-src 'none'",
116
+ "form-action 'none'",
117
+ "base-uri 'none'",
118
+ ].join('; ')
119
+ }
120
+
121
+ /** 硬规矩预览的其他响应头 */
122
+ export const STRICT_HEADERS = {
123
+ 'X-Content-Type-Options': 'nosniff',
124
+ 'Referrer-Policy': 'no-referrer',
125
+ 'X-DNS-Prefetch-Control': 'off',
126
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), clipboard-read=(), payment=(), usb=(), serial=(), hid=(), bluetooth=(), display-capture=()',
89
127
  }
128
+ /**
129
+ * 硬规矩预览前再拆掉 CSP 管不到的几样:<meta http-equiv>(自动跳走)、<link rel="preconnect / dns-prefetch / prerender …">(偷偷连外网)、
130
+ * a[ping]。只改属性名(data-dsh-off-…),不改别的字。正常的设计库内容本来就没有这些(检查规则拒收),这里是兜底
131
+ */
132
+ export function defang(html) {
133
+ const edits = []
134
+ for (const tk of tokenize(html)) {
135
+ if (tk.type !== 'tag') continue
136
+ const t = tk.tag
137
+ for (const a of t.attrs) {
138
+ const off = (t.name === 'meta' && a.name === 'http-equiv')
139
+ || (t.name === 'link' && a.name === 'rel' && !/^\s*(stylesheet|icon|shortcut icon|apple-touch-icon)\s*$/i.test(a.value))
140
+ || a.name === 'ping'
141
+ if (off) edits.push(a.start)
142
+ }
143
+ }
144
+ let out = html
145
+ for (const at of edits.sort((a, b) => b - a)) out = out.slice(0, at) + 'data-dsh-off-' + out.slice(at)
146
+ return out
147
+ }
148
+
149
+ // 别人做的 SVG 被当页面直接打开时:不准跑任何脚本
150
+ const STRICT_SVG_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
90
151
 
91
152
  /** 真实路径在不在根目录(也取真实路径)里面 */
92
153
  function inRoot(root, real) {
@@ -161,8 +222,21 @@ export function trustedOrigins(hosts) {
161
222
  * @param opts.onChange (key, file, files) 目录里有文件改了(file 目标;files:这一批的相对路径)
162
223
  * @param opts.history 设计版本(lib/history.js):/__dsh_design__/v/<版本>/<文件> 给出那一版的文件
163
224
  * @param opts.parents () → 还有哪些 DSH 页面的源能嵌预览(DSH 网页端的地址)
225
+ * @param opts.strict { html(buf) → 这份 HTML 是别人做的、没改过吗, root(dir) → 这个目录是别人做的设计吗 }:是就加硬规矩
164
226
  */
165
- export function createHub({ log = () => {}, onChange = () => {}, history = null, parents = () => [] } = {}) {
227
+ export function createHub({ log = () => {}, onChange = () => {}, history = null, parents = () => [], strict = null } = {}) {
228
+ const strictHtml = (buf) => { try { return !!(strict && strict.html && strict.html(buf)) } catch (e) { log(e); return true } }
229
+ const strictRoot = (dir) => { try { return !!(strict && strict.root && strict.root(dir)) } catch (e) { log(e); return true } }
230
+ const strictVer = (dir) => { try { return !!(strict && strict.versions && strict.versions(dir)) } catch (e) { log(e); return true } }
231
+
232
+ /** 发一份 HTML:别人做的加硬规矩(CSP + nonce),我们的脚本照常注入 */
233
+ function sendHtml(res, req, buf, type, force = false) {
234
+ if (force || strictHtml(buf)) {
235
+ const nonce = crypto.randomBytes(16).toString('base64')
236
+ return send(res, 200, req.method === 'HEAD' ? '' : injectPicker(defang(buf.toString('utf8')), { nonce }), { 'Content-Type': type, 'Content-Security-Policy': strictCsp(nonce), ...STRICT_HEADERS })
237
+ }
238
+ send(res, 200, req.method === 'HEAD' ? '' : injectPicker(buf.toString('utf8')), { 'Content-Type': type })
239
+ }
166
240
  const servers = new Map() // key → entry
167
241
 
168
242
  function touch(entry) {
@@ -213,14 +287,15 @@ export function createHub({ log = () => {}, onChange = () => {}, history = null,
213
287
  const ext = path.extname(file).toLowerCase()
214
288
  const type = MIME[ext] || 'application/octet-stream'
215
289
  if (ext === '.html' || ext === '.htm') {
216
- fs.readFile(file, 'utf8', (err, html) => {
290
+ fs.readFile(file, (err, buf) => {
217
291
  if (err) return send(res, 500, '读不了这个文件')
218
- send(res, 200, req.method === 'HEAD' ? '' : injectPicker(html), { 'Content-Type': type })
292
+ sendHtml(res, req, buf, type)
219
293
  })
220
294
  return
221
295
  }
222
296
  // 别的源不能把这里的文件当子资源拿走
223
- res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store', 'Cross-Origin-Resource-Policy': 'same-origin' })
297
+ const extra = strictRoot(entry.root) ? { 'X-Content-Type-Options': 'nosniff', ...(ext === '.svg' ? { 'Content-Security-Policy': STRICT_SVG_CSP } : {}) } : {}
298
+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store', 'Cross-Origin-Resource-Policy': 'same-origin', ...extra })
224
299
  if (req.method === 'HEAD') return res.end()
225
300
  const stream = fs.createReadStream(file)
226
301
  stream.on('error', () => res.destroy())
@@ -238,8 +313,10 @@ export function createHub({ log = () => {}, onChange = () => {}, history = null,
238
313
  const type = MIME[ext] || 'application/octet-stream'
239
314
  fs.readFile(file, (err, buf) => {
240
315
  if (err) return send(res, 404, 'no such version file')
241
- if (ext === '.html' || ext === '.htm') return send(res, 200, req.method === 'HEAD' ? '' : injectPicker(buf.toString('utf8')), { 'Content-Type': type })
242
- res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store', 'Cross-Origin-Resource-Policy': 'same-origin' })
316
+ // 装好的别人的设计系统:旧版本里的页面一律加规矩
317
+ if (ext === '.html' || ext === '.htm') return sendHtml(res, req, buf, type, strictVer(entry.root))
318
+ const extra = strictRoot(entry.root) ? { 'X-Content-Type-Options': 'nosniff', ...(ext === '.svg' ? { 'Content-Security-Policy': STRICT_SVG_CSP } : {}) } : {}
319
+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store', 'Cross-Origin-Resource-Policy': 'same-origin', ...extra })
243
320
  res.end(req.method === 'HEAD' ? undefined : buf)
244
321
  })
245
322
  }