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
package/lib/core.js CHANGED
@@ -78,9 +78,28 @@ export function listScreens(root) {
78
78
  out.push({ name: d.name, born })
79
79
  }
80
80
  out.sort((a, b) => a.born - b.born || a.name.localeCompare(b.name))
81
+ // 有设计清单(design.json,从设计库来的模板 / 分享过的设计):按清单里的顺序排,清单外的(模型后加的)按创建时间排在后面
82
+ const order = manifestOrder(root)
83
+ if (order.length) {
84
+ const rank = new Map(order.map((f, i) => [f.toLowerCase(), i]))
85
+ const listed = out.filter((x) => rank.has(x.name.toLowerCase())).sort((a, b) => rank.get(a.name.toLowerCase()) - rank.get(b.name.toLowerCase()))
86
+ return [...listed, ...out.filter((x) => !rank.has(x.name.toLowerCase()))].slice(0, MAX_SCREENS).map((x) => x.name)
87
+ }
81
88
  return out.slice(0, MAX_SCREENS).map((x) => x.name)
82
89
  }
83
90
 
91
+ /** design.json 里 screens 的文件名(读不了、写错了都当没有) */
92
+ export function manifestOrder(root) {
93
+ let m
94
+ try {
95
+ const raw = fs.readFileSync(path.join(root, 'design.json'), 'utf8')
96
+ if (raw.length > 256 * 1024) return []
97
+ m = JSON.parse(raw)
98
+ } catch { return [] }
99
+ if (!m || !Array.isArray(m.screens)) return []
100
+ return m.screens.map((s) => (s && typeof s.file === 'string' ? s.file : typeof s === 'string' ? s : '')).filter((f) => f && !/[\\/]/.test(f) && HTML_EXT.test(f)).slice(0, MAX_SCREENS)
101
+ }
102
+
84
103
  /** 开发服务器上要并排看的几个路由 */
85
104
  function normRoutes(list) {
86
105
  if (!Array.isArray(list)) return null
@@ -109,7 +128,10 @@ export function fromBashPath(p, platform = process.platform) {
109
128
  * @throws Error 带中文说明
110
129
  */
111
130
  export function parseTarget(input, cwd = os.homedir()) {
112
- const s = String(input || '').trim()
131
+ let s = String(input || '').trim()
132
+ // Windows「复制为路径」会带一对引号:"C:\a\b.html"
133
+ const q = /^(["'“])(.*)(["'”])$/.exec(s)
134
+ if (q && (q[1] === q[3] || (q[1] === '“' && q[3] === '”'))) s = q[2].trim()
113
135
  if (!s) throw new Error('地址是空的')
114
136
  if (/^\d{2,5}$/.test(s)) return proxyTarget(Number(s), '/')
115
137
  const m = LOCAL_URL.exec(s)
@@ -118,21 +140,31 @@ export function parseTarget(input, cwd = os.homedir()) {
118
140
  let p = null
119
141
  // 标准的 file:// 地址交给 Node 转(Windows 上是 file:///C:/…,直接去掉前缀会变成 /C:/…)
120
142
  if (/^file:\/\//i.test(s)) try { p = fileURLToPath(s) } catch { /* file://~/… 之类的写法,按下面的老办法 */ }
143
+ const full = (x) => {
144
+ x = fromBashPath(x)
145
+ if (x === '~' || x.startsWith('~/') || x.startsWith('~\\')) x = path.join(os.homedir(), x.slice(1))
146
+ return path.resolve(cwd, x)
147
+ }
121
148
  if (p === null) {
122
- p = s.replace(/^file:\/\//i, '')
123
- if (p.includes('%')) {
124
- try { p = decodeURIComponent(p) } catch { /* 原样用 */ }
149
+ const raw = s.replace(/^file:\/\//i, '')
150
+ p = raw
151
+ // %20 之类按网址解开;但文件名里本来就带 %(「浏览…」选的、拖进来的都是原样路径)且原样存在时,别解
152
+ if (raw.includes('%') && !fs.existsSync(full(raw))) {
153
+ try { p = decodeURIComponent(raw) } catch { /* 原样用 */ }
125
154
  }
126
155
  }
127
- p = fromBashPath(p)
128
- if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) p = path.join(os.homedir(), p.slice(1))
129
- p = path.resolve(cwd, p)
156
+ p = full(p)
130
157
  let st
131
158
  try { st = fs.statSync(p) } catch { throw new Error(`找不到:${p}`) }
132
159
  if (st.isDirectory()) {
160
+ // 有 index.html 开它;没有就开最先做的那屏(设计目录一屏一个文件,画布上会把同目录的几屏都排出来)
133
161
  const index = path.join(p, 'index.html')
134
- if (!fs.existsSync(index)) throw new Error(`这个目录里没有 index.html:${p}`)
135
- p = index
162
+ const first = fs.existsSync(index) ? 'index.html' : listScreens(p)[0]
163
+ if (!first) {
164
+ try { fs.readdirSync(p) } catch (e) { throw new Error(`读不了这个文件夹(${e.code === 'EACCES' || e.code === 'EPERM' ? '没有权限' : e.code || e.message}):${p}`) }
165
+ throw new Error(`这个文件夹里没有 HTML 文件:${p}`)
166
+ }
167
+ p = path.join(p, first)
136
168
  } else if (!HTML_EXT.test(p)) {
137
169
  throw new Error(`只能预览 .html 文件:${p}`)
138
170
  }
@@ -512,7 +544,7 @@ export function askOf(data) {
512
544
  /**
513
545
  * @param opts.alive (origin) → 这个预览服务器还开着吗(开的太多时旧的会被关掉;发现没了就重开)
514
546
  */
515
- export function createTracker({ open, skipPorts = () => new Set(), now = Date.now, log = () => {}, store = null, history = null, alive = () => true, autoOpen = () => true }) {
547
+ export function createTracker({ open, skipPorts = () => new Set(), now = Date.now, log = () => {}, store = null, history = null, alive = () => true, autoOpen = () => true, beforeRestore = () => {} }) {
516
548
  const states = new Map() // sessionId → state
517
549
  const cwds = new Map() // sessionId → 最近一次工具的工作目录
518
550
  let saved = {}
@@ -896,7 +928,8 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
896
928
  function restoreVersion(id, n) {
897
929
  const st = states.get(id)
898
930
  if (!history || !st || !st.target || st.target.kind !== 'file') throw new Error('这个预览没有版本记录')
899
- const r = history.restore(st.target.root, n)
931
+ const root = st.target.root
932
+ const r = history.restore(root, n, { before: (html) => beforeRestore(root, html) })
900
933
  st.vrev++
901
934
  return r
902
935
  }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * 设计库用的 CSS 读法:照 CSS 规范分词(转义会还原:\75rl( 就是 url(),找出所有会去拿文件的地方。
3
+ * Node 和浏览器里都能跑,不依赖别的包。
4
+ */
5
+
6
+ const isHex = (c) => /[0-9a-fA-F]/.test(c)
7
+ const isNameStart = (c) => /[a-zA-Z_]/.test(c) || (c && c.charCodeAt(0) >= 0x80)
8
+ const isName = (c) => isNameStart(c) || /[0-9-]/.test(c)
9
+ const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f'
10
+ const validEscape = (a, b) => a === '\\' && b !== '\n' && b !== '\r' && b !== '\f' && b !== undefined
11
+
12
+ /**
13
+ * 分词。返回 [{ t, v, s, e }]
14
+ * t:ws comment str bad-str url bad-url func ident at hash num delim ( ) [ ] { } , : ; cdo cdc
15
+ * v:还原过转义的值(func / ident / at / hash 是名字,str / url 是内容)
16
+ */
17
+ export function cssTokens(src, { from = 0, stop = null } = {}) {
18
+ const s = String(src)
19
+ const n = s.length
20
+ const out = []
21
+ let i = from
22
+
23
+ function escape() {
24
+ // 已经越过反斜杠
25
+ const c = s[i]
26
+ if (c === undefined) return '�'
27
+ if (isHex(c)) {
28
+ let hex = ''
29
+ while (hex.length < 6 && i < n && isHex(s[i])) hex += s[i++]
30
+ if (i < n && isWs(s[i])) { if (s[i] === '\r' && s[i + 1] === '\n') i++; i++ }
31
+ const cp = parseInt(hex, 16)
32
+ return cp === 0 || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff) ? '�' : String.fromCodePoint(cp)
33
+ }
34
+ i++
35
+ return c
36
+ }
37
+ function name() {
38
+ let v = ''
39
+ for (;;) {
40
+ const c = s[i]
41
+ if (c !== undefined && isName(c)) { v += c; i++ } else if (validEscape(c, s[i + 1])) { i++; v += escape() } else return v
42
+ }
43
+ }
44
+ const startsIdent = (a, b, c) => a === '-' ? isNameStart(b) || b === '-' || validEscape(b, c) : isNameStart(a) || validEscape(a, b)
45
+ const startsNumber = (a, b, c) => /[0-9]/.test(a) || (a === '.' && /[0-9]/.test(b)) || ((a === '+' || a === '-') && (/[0-9]/.test(b) || (b === '.' && /[0-9]/.test(c))))
46
+
47
+ function str(q, from) {
48
+ let v = ''
49
+ for (;;) {
50
+ const c = s[i]
51
+ if (c === undefined) return { t: 'str', v, s: from, e: i }
52
+ if (c === q) { i++; return { t: 'str', v, s: from, e: i } }
53
+ if (c === '\n' || c === '\r' || c === '\f') return { t: 'bad-str', v, s: from, e: i }
54
+ if (c === '\\') {
55
+ const d = s[i + 1]
56
+ if (d === undefined) { i++; continue }
57
+ if (d === '\n' || d === '\f') { i += 2; continue }
58
+ if (d === '\r') { i += s[i + 2] === '\n' ? 3 : 2; continue }
59
+ i++
60
+ v += escape()
61
+ continue
62
+ }
63
+ v += c
64
+ i++
65
+ }
66
+ }
67
+
68
+ function url(from) {
69
+ while (i < n && isWs(s[i])) i++
70
+ let v = ''
71
+ for (;;) {
72
+ const c = s[i]
73
+ if (c === undefined) return { t: 'url', v, s: from, e: i }
74
+ if (c === ')') { i++; return { t: 'url', v, s: from, e: i } }
75
+ if (isWs(c)) {
76
+ while (i < n && isWs(s[i])) i++
77
+ if (s[i] === ')' || s[i] === undefined) { if (s[i] === ')') i++; return { t: 'url', v, s: from, e: i } }
78
+ return badUrl(v, from)
79
+ }
80
+ if (c === '"' || c === "'" || c === '(' || /[\x00-\x08\x0b\x0e-\x1f\x7f]/.test(c)) return badUrl(v, from)
81
+ if (c === '\\') {
82
+ if (validEscape(c, s[i + 1])) { i++; v += escape(); continue }
83
+ return badUrl(v, from)
84
+ }
85
+ v += c
86
+ i++
87
+ }
88
+ }
89
+ function badUrl(v, from) {
90
+ for (;;) {
91
+ const c = s[i]
92
+ if (c === undefined) return { t: 'bad-url', v, s: from, e: i }
93
+ if (c === ')') { i++; return { t: 'bad-url', v, s: from, e: i } }
94
+ if (validEscape(c, s[i + 1])) { i++; escape(); continue }
95
+ i++
96
+ }
97
+ }
98
+
99
+ while (i < n) {
100
+ if (stop && stop(i, out)) break
101
+ const from = i
102
+ const c = s[i]
103
+ if (c === '/' && s[i + 1] === '*') {
104
+ const e = s.indexOf('*/', i + 2)
105
+ i = e < 0 ? n : e + 2
106
+ out.push({ t: 'comment', v: '', s: from, e: i })
107
+ continue
108
+ }
109
+ if (isWs(c)) { while (i < n && isWs(s[i])) i++; out.push({ t: 'ws', v: ' ', s: from, e: i }); continue }
110
+ if (c === '"' || c === "'") { i++; out.push(str(c, from)); continue }
111
+ if (c === '#') {
112
+ if (isName(s[i + 1]) || validEscape(s[i + 1], s[i + 2])) { i++; out.push({ t: 'hash', v: name(), s: from, e: i }); continue }
113
+ i++; out.push({ t: 'delim', v: '#', s: from, e: i }); continue
114
+ }
115
+ if ('()[]{},:;'.includes(c)) { i++; out.push({ t: c, v: c, s: from, e: i }); continue }
116
+ if (startsNumber(c, s[i + 1], s[i + 2])) {
117
+ i++
118
+ while (i < n && /[0-9.eE]/.test(s[i])) i++
119
+ if (s[i] === '%') i++
120
+ else if (startsIdent(s[i], s[i + 1], s[i + 2])) name()
121
+ out.push({ t: 'num', v: s.slice(from, i), s: from, e: i })
122
+ continue
123
+ }
124
+ if (c === '-' && s[i + 1] === '-' && s[i + 2] === '>') { i += 3; out.push({ t: 'cdc', v: '-->', s: from, e: i }); continue }
125
+ if (c === '<' && s.startsWith('<!--', i)) { i += 4; out.push({ t: 'cdo', v: '<!--', s: from, e: i }); continue }
126
+ if (c === '@' && startsIdent(s[i + 1], s[i + 2], s[i + 3])) { i++; out.push({ t: 'at', v: name(), s: from, e: i }); continue }
127
+ if (startsIdent(c, s[i + 1], s[i + 2])) {
128
+ const v = name()
129
+ if (s[i] === '(') {
130
+ i++
131
+ if (v.toLowerCase() === 'url') {
132
+ let k = i
133
+ while (k < n && isWs(s[k])) k++
134
+ if (s[k] === '"' || s[k] === "'") { out.push({ t: 'func', v, s: from, e: i }); continue }
135
+ out.push(url(from))
136
+ continue
137
+ }
138
+ out.push({ t: 'func', v, s: from, e: i })
139
+ continue
140
+ }
141
+ out.push({ t: 'ident', v, s: from, e: i })
142
+ continue
143
+ }
144
+ i++
145
+ out.push({ t: 'delim', v: c, s: from, e: i })
146
+ }
147
+ return out
148
+ }
149
+
150
+ // 参数里的字符串会被当成文件地址去拿的函数
151
+ export const URL_FUNCS = new Set(['url', 'src', 'image-set', '-webkit-image-set', 'image', 'cross-fade', '-webkit-cross-fade'])
152
+ const BAD_FUNCS = new Set(['expression', '-moz-element', 'element'])
153
+ const BAD_PROPS = new Set(['behavior', '-ms-behavior', '-moz-binding', 'binding'])
154
+
155
+ /**
156
+ * 查一段 CSS(整份样式表,或 style="" 里的声明)。
157
+ * 返回 { refs: [{ url, at, kind }], problems: [{ code, at, detail }] }
158
+ * refs:所有会去拿的地址(kind:'url' 普通、'font' 在 @font-face 里);at:在这段 CSS 里的位置
159
+ * problems:import(@import)、func(expression() 这类)、prop(behavior / -moz-binding)、bad(写坏的 url / 字符串,可能藏东西)、font-url
160
+ */
161
+ export function scanCss(src) {
162
+ const toks = cssTokens(src)
163
+ const refs = []
164
+ const problems = []
165
+ let depth = 0 // 括号层数(URL_FUNCS 里的字符串)
166
+ const funcStack = []
167
+ let fontFace = -1 // 在 @font-face 块里:块的层数
168
+ let block = 0
169
+ let skipUntilSemi = false // @namespace 的地址不会去拿
170
+ let pendingAt = ''
171
+ for (let k = 0; k < toks.length; k++) {
172
+ const tk = toks[k]
173
+ const low = typeof tk.v === 'string' ? tk.v.toLowerCase() : ''
174
+ if (skipUntilSemi) {
175
+ if (tk.t === ';' || tk.t === '{') skipUntilSemi = false
176
+ continue
177
+ }
178
+ switch (tk.t) {
179
+ case 'at':
180
+ if (low === 'import') problems.push({ code: 'import', at: tk.s })
181
+ else if (low === 'namespace') skipUntilSemi = true
182
+ pendingAt = low
183
+ break
184
+ case '{':
185
+ block++
186
+ if (pendingAt === 'font-face' && fontFace < 0) fontFace = block
187
+ pendingAt = ''
188
+ break
189
+ case '}':
190
+ if (fontFace === block) fontFace = -1
191
+ block = Math.max(0, block - 1)
192
+ break
193
+ case ';':
194
+ pendingAt = ''
195
+ break
196
+ case 'url':
197
+ refs.push({ url: tk.v, at: tk.s, kind: fontFace > 0 ? 'font' : 'url' })
198
+ break
199
+ case 'bad-url':
200
+ case 'bad-str':
201
+ problems.push({ code: 'bad', at: tk.s })
202
+ break
203
+ case 'func':
204
+ if (BAD_FUNCS.has(low)) problems.push({ code: 'func', at: tk.s, detail: tk.v })
205
+ funcStack.push(low)
206
+ depth++
207
+ break
208
+ case '(':
209
+ funcStack.push('')
210
+ depth++
211
+ break
212
+ case ')':
213
+ funcStack.pop()
214
+ depth = Math.max(0, depth - 1)
215
+ break
216
+ case 'str':
217
+ if (funcStack.some((f) => URL_FUNCS.has(f))) refs.push({ url: tk.v, at: tk.s, kind: fontFace > 0 ? 'font' : 'url' })
218
+ break
219
+ case 'ident': {
220
+ // 属性名:后面(跳过空白)是冒号
221
+ let j = k + 1
222
+ while (j < toks.length && (toks[j].t === 'ws' || toks[j].t === 'comment')) j++
223
+ if (toks[j] && toks[j].t === ':' && BAD_PROPS.has(low)) problems.push({ code: 'prop', at: tk.s, detail: tk.v })
224
+ break
225
+ }
226
+ default:
227
+ }
228
+ }
229
+ return { refs, problems }
230
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * 设计库用的 HTML 读法(不依赖浏览器,Node 和浏览器里都能跑)。
3
+ *
4
+ * 只为「查有没有危险的东西」服务,所以宁可多认:
5
+ * tokenize(src) 照 HTML 规范的分词走一遍(<style> / <textarea> 这类里面只当文字),拿样式、文字、注释和它们的位置
6
+ * scanTags(src) 每个 「<字母」 都当一个标签读一遍,不管它在注释、样式还是属性值里——
7
+ * 浏览器在 <svg> 里、或者遇到拼错的标签时读法会变,逐个都查就不怕读法对不上
8
+ * decodeEntities &#106; &colon; 这类转义还原成字,好查 javascript: 这种藏起来的写法
9
+ */
10
+
11
+ const WS = new Set(['\t', '\n', '\f', '\r', ' '])
12
+ const ALPHA = /[a-zA-Z]/
13
+
14
+ // 这些元素里面是纯文字(不再认标签),直到对应的结束标签
15
+ const RAWTEXT = new Set(['style', 'script', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript', 'textarea', 'title'])
16
+
17
+ // 常用的命名转义(够查危险写法:字母本身没有命名转义,能拼出 javascript: 的只有数字转义和下面这些标点)
18
+ const NAMED = {
19
+ amp: '&', AMP: '&', lt: '<', LT: '<', gt: '>', GT: '>', quot: '"', QUOT: '"', apos: "'", nbsp: ' ',
20
+ colon: ':', Tab: '\t', NewLine: '\n', sol: '/', bsol: '\\', lpar: '(', rpar: ')', period: '.', comma: ',',
21
+ semi: ';', excl: '!', num: '#', commat: '@', equals: '=', quest: '?', lsqb: '[', rsqb: ']', lbrack: '[', rbrack: ']',
22
+ lcub: '{', rcub: '}', lbrace: '{', rbrace: '}', verbar: '|', vert: '|', VerticalLine: '|', ast: '*', midast: '*',
23
+ plus: '+', dollar: '$', percnt: '%', Hat: '^', grave: '`', lowbar: '_', UnderBar: '_', hyphen: '‐', dash: '‐',
24
+ copy: '©', COPY: '©', reg: '®', REG: '®', trade: '™', middot: '·', centerdot: '·',
25
+ hellip: '…', mdash: '—', ndash: '–', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”',
26
+ laquo: '«', raquo: '»', times: '×', divide: '÷', deg: '°', yen: '¥', euro: '€',
27
+ pound: '£', cent: '¢', bull: '•', rarr: '→', larr: '←', uarr: '↑', darr: '↓',
28
+ harr: '↔', check: '✓', star: '☆', starf: '★', hearts: '♥', ensp: ' ', emsp: ' ',
29
+ thinsp: ' ', zwj: '‍', zwnj: '‌', shy: '­', sect: '§', para: '¶', plusmn: '±',
30
+ frac12: '½', frac14: '¼', frac34: '¾', sup2: '²', sup3: '³', micro: 'µ', infin: '∞',
31
+ le: '≤', ge: '≥', ne: '≠', minus: '−', prime: '′', Prime: '″', loz: '◊', iexcl: '¡',
32
+ iquest: '¿', acute: '´', uml: '¨', cedil: '¸', macr: '¯', ordf: 'ª', ordm: 'º',
33
+ }
34
+ // 不带分号也算数的老写法(只列上面有的)
35
+ const LEGACY = new Set(['amp', 'AMP', 'lt', 'LT', 'gt', 'GT', 'quot', 'QUOT', 'nbsp', 'copy', 'COPY', 'reg', 'REG', 'middot', 'laquo', 'raquo', 'times', 'divide', 'deg', 'yen', 'pound', 'cent', 'sect', 'para', 'plusmn', 'frac12', 'frac14', 'frac34', 'sup2', 'sup3', 'micro', 'iexcl', 'iquest', 'acute', 'uml', 'cedil', 'macr', 'ordf', 'ordm', 'shy'])
36
+
37
+ /**
38
+ * 还原转义。inAttr:属性值里老写法后面跟着字母、数字或 = 时不还原(和浏览器一样)。
39
+ * 返回 { text, unknown }:unknown = 认不出的命名转义(查网址时当可疑)
40
+ */
41
+ export function decodeEntities(s, inAttr = false) {
42
+ const str = String(s)
43
+ if (!str.includes('&')) return { text: str, unknown: [] }
44
+ const unknown = []
45
+ const text = str.replace(/&(#[xX][0-9a-fA-F]+;?|#[0-9]+;?|[a-zA-Z][a-zA-Z0-9]*;?)/g, (m, body, off) => {
46
+ if (body[0] === '#') {
47
+ const hex = body[1] === 'x' || body[1] === 'X'
48
+ const n = parseInt(body.slice(hex ? 2 : 1).replace(';', ''), hex ? 16 : 10)
49
+ if (!Number.isFinite(n) || n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff)) return '�'
50
+ return String.fromCodePoint(n)
51
+ }
52
+ const semi = body.endsWith(';')
53
+ const name = semi ? body.slice(0, -1) : body
54
+ if (semi && Object.prototype.hasOwnProperty.call(NAMED, name)) return NAMED[name]
55
+ // 不带分号:最长的老写法前缀
56
+ for (let k = name.length; k > 1; k--) {
57
+ const pre = name.slice(0, k)
58
+ if (!LEGACY.has(pre)) continue
59
+ const next = k < name.length ? name[k] : str[off + m.length] || ''
60
+ if (inAttr && (k < name.length || /[=a-zA-Z0-9]/.test(next))) break
61
+ return NAMED[pre] + name.slice(k) + (semi ? ';' : '')
62
+ }
63
+ unknown.push(m)
64
+ return m
65
+ })
66
+ return { text, unknown }
67
+ }
68
+
69
+ /**
70
+ * 从 src[i] === '<' 开始读一个标签(开始或结束标签)。读不成标签返回 null。
71
+ * 返回 { end?, name, attrs: [{ name, value, raw, start, end, vStart, vEnd, quote, dup }], selfClosing, start, close, eof }
72
+ * value 已还原转义;raw 是原文;start / end 是整个属性在原文里的位置;vStart / vEnd 是值(不含引号)
73
+ */
74
+ export function parseTagAt(src, i) {
75
+ let p = i + 1
76
+ let isEnd = false
77
+ if (src[p] === '/') { isEnd = true; p++ }
78
+ if (!ALPHA.test(src[p] || '')) return null
79
+ const n = src.length
80
+ let name = ''
81
+ while (p < n && !WS.has(src[p]) && src[p] !== '/' && src[p] !== '>') { name += src[p] === '\0' ? '�' : src[p]; p++ }
82
+ const tag = { end: isEnd, name: name.toLowerCase(), attrs: [], selfClosing: false, start: i, close: n, eof: false }
83
+ const seen = new Set()
84
+ const push = (a) => {
85
+ a.name = a.name.toLowerCase()
86
+ a.dup = seen.has(a.name)
87
+ seen.add(a.name)
88
+ const d = decodeEntities(a.raw, true)
89
+ a.value = d.text
90
+ a.unknown = d.unknown
91
+ tag.attrs.push(a)
92
+ }
93
+ for (;;) {
94
+ while (p < n && (WS.has(src[p]) || src[p] === '/')) {
95
+ if (src[p] === '/' && src[p + 1] === '>') { tag.selfClosing = true; p++; break }
96
+ p++
97
+ }
98
+ if (p >= n) { tag.eof = true; tag.close = n; return tag }
99
+ if (src[p] === '>') { tag.close = p + 1; return tag }
100
+ // 属性名(第一个字可以是 =)
101
+ const aStart = p
102
+ let aname = src[p]
103
+ p++
104
+ while (p < n && !WS.has(src[p]) && src[p] !== '/' && src[p] !== '>' && src[p] !== '=') { aname += src[p]; p++ }
105
+ let q = p
106
+ while (q < n && WS.has(src[q])) q++
107
+ if (src[q] !== '=') {
108
+ push({ name: aname, raw: '', start: aStart, end: p, vStart: p, vEnd: p, quote: '' })
109
+ continue
110
+ }
111
+ q++
112
+ while (q < n && WS.has(src[q])) q++
113
+ if (q >= n) { push({ name: aname, raw: '', start: aStart, end: n, vStart: n, vEnd: n, quote: '' }); tag.eof = true; tag.close = n; return tag }
114
+ if (src[q] === '>') { push({ name: aname, raw: '', start: aStart, end: q, vStart: q, vEnd: q, quote: '' }); p = q; continue }
115
+ if (src[q] === '"' || src[q] === "'") {
116
+ const quote = src[q]
117
+ const close = src.indexOf(quote, q + 1)
118
+ if (close < 0) { push({ name: aname, raw: src.slice(q + 1), start: aStart, end: n, vStart: q + 1, vEnd: n, quote }); tag.eof = true; tag.close = n; return tag }
119
+ push({ name: aname, raw: src.slice(q + 1, close), start: aStart, end: close + 1, vStart: q + 1, vEnd: close, quote })
120
+ p = close + 1
121
+ continue
122
+ }
123
+ let e = q
124
+ while (e < n && !WS.has(src[e]) && src[e] !== '>') e++
125
+ push({ name: aname, raw: src.slice(q, e), start: aStart, end: e, vStart: q, vEnd: e, quote: '' })
126
+ p = e
127
+ }
128
+ }
129
+
130
+ /** 第几行(1 起) */
131
+ // 最近一份文字的换行位置(同一份文字反复问行号时不用每次从头数)
132
+ let lineCache = { src: null, nl: null }
133
+ export function lineAt(src, i) {
134
+ if (lineCache.src !== src) {
135
+ const nl = []
136
+ for (let k = src.indexOf('\n'); k >= 0; k = src.indexOf('\n', k + 1)) nl.push(k)
137
+ lineCache = { src, nl }
138
+ }
139
+ // 位置 i 之前有几个换行(二分)
140
+ const nl = lineCache.nl
141
+ let lo = 0
142
+ let hi = nl.length
143
+ const end = Math.min(i, src.length)
144
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (nl[mid] < end) lo = mid + 1; else hi = mid }
145
+ return lo + 1
146
+ }
147
+
148
+ /**
149
+ * 照规范分词(简化版:只认数据、标签、注释、doctype、纯文字元素)。
150
+ * 返回 [{ type: 'text' | 'tag' | 'endtag' | 'comment' | 'doctype' | 'bogus' | 'cdata', start, end, ... }]
151
+ * tag / endtag 带 parseTagAt 的结果;text 带 raw(在哪个纯文字元素里,没有是 '')
152
+ */
153
+ /**
154
+ * 纯文字元素从 from 起到哪结束:下一个 </名字(后面是空白、/ 或 >)。
155
+ * cdata:中间的 <![CDATA[ … ]]> 里的 </名字 不算(矢量图里 <style> 的 CDATA 是文字);没收尾的 CDATA 一直到最后
156
+ */
157
+ export function rawEnd(src, name, from, cdata = false) {
158
+ const re = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])|<!\\[CDATA\\[`, 'ig')
159
+ re.lastIndex = from
160
+ for (;;) {
161
+ const m = re.exec(src)
162
+ if (!m) return src.length
163
+ if (m[0][1] === '/') return m.index
164
+ if (!cdata) continue
165
+ const c = src.indexOf(']]>', m.index + 9)
166
+ if (c < 0) return src.length
167
+ re.lastIndex = c + 3
168
+ }
169
+ }
170
+
171
+ /**
172
+ * opts.foreign:认不认 <svg> / <math>(外来内容)。默认认:里面 <![CDATA[ 是文字,只有 <style> 还按纯文字读(CDATA 里的 </style> 不算),
173
+ * title / textarea 这些不按纯文字读(和独立的 SVG 文件一样)。false:完全按网页读(<svg> 里的 title / textarea 也按纯文字)。
174
+ * 网页里浏览器两种都有(<svg><p> 会跳回网页、<svg><title> 里按网页读):查隐私 / 写法的地方两种都走一遍取并集(见 visibleText)
175
+ */
176
+ export function tokenize(src, { foreign: useForeign = true } = {}) {
177
+ const out = []
178
+ const n = src.length
179
+ let i = 0
180
+ let raw = ''
181
+ let foreign = 0 // 在 <svg> / <math> 里几层(里面的 <![CDATA[ 是文字)
182
+ const text = (s, e, inRaw = '') => { if (e > s) out.push({ type: 'text', start: s, end: e, raw: inRaw, text: src.slice(s, e) }) }
183
+ while (i < n) {
184
+ if (raw) {
185
+ const e = raw === 'plaintext' ? n : rawEnd(src, raw, i, foreign > 0)
186
+ text(i, e, raw)
187
+ i = e
188
+ raw = ''
189
+ continue
190
+ }
191
+ const lt = src.indexOf('<', i)
192
+ if (lt < 0) { text(i, n); break }
193
+ text(i, lt)
194
+ const c = src[lt + 1] || ''
195
+ if (src.startsWith('<!--', lt)) {
196
+ let e
197
+ if (src.startsWith('<!-->', lt)) e = lt + 5
198
+ else if (src.startsWith('<!--->', lt)) e = lt + 6
199
+ else {
200
+ const a = src.indexOf('-->', lt + 4)
201
+ const b = src.indexOf('--!>', lt + 4)
202
+ const cands = [a >= 0 ? a + 3 : Infinity, b >= 0 ? b + 4 : Infinity]
203
+ e = Math.min(...cands)
204
+ if (!Number.isFinite(e)) e = n
205
+ }
206
+ out.push({ type: 'comment', start: lt, end: e, text: src.slice(lt, e) })
207
+ i = e
208
+ continue
209
+ }
210
+ if (c === '!') {
211
+ if (foreign && src.startsWith('<![CDATA[', lt)) {
212
+ const a = src.indexOf(']]>', lt + 9)
213
+ const e = a < 0 ? n : a + 3
214
+ out.push({ type: 'cdata', start: lt, end: e, text: src.slice(lt + 9, a < 0 ? n : a) })
215
+ i = e
216
+ continue
217
+ }
218
+ const a = src.indexOf('>', lt + 2)
219
+ const e = a < 0 ? n : a + 1
220
+ out.push({ type: /^<!doctype/i.test(src.slice(lt, lt + 9)) ? 'doctype' : 'bogus', start: lt, end: e, text: src.slice(lt, e) })
221
+ i = e
222
+ continue
223
+ }
224
+ if (c === '?') {
225
+ const a = src.indexOf('>', lt + 2)
226
+ const e = a < 0 ? n : a + 1
227
+ out.push({ type: 'bogus', start: lt, end: e, text: src.slice(lt, e) })
228
+ i = e
229
+ continue
230
+ }
231
+ if (c === '/') {
232
+ if (src[lt + 2] === '>') { i = lt + 3; continue }
233
+ if (!ALPHA.test(src[lt + 2] || '')) {
234
+ const a = src.indexOf('>', lt + 2)
235
+ const e = a < 0 ? n : a + 1
236
+ out.push({ type: 'bogus', start: lt, end: e, text: src.slice(lt, e) })
237
+ i = e
238
+ continue
239
+ }
240
+ const t = parseTagAt(src, lt)
241
+ out.push({ type: 'endtag', start: lt, end: t.close, tag: t })
242
+ if ((t.name === 'svg' || t.name === 'math') && foreign && useForeign) foreign--
243
+ i = t.close
244
+ continue
245
+ }
246
+ if (ALPHA.test(c)) {
247
+ const t = parseTagAt(src, lt)
248
+ out.push({ type: 'tag', start: lt, end: t.close, tag: t })
249
+ i = t.close
250
+ if (t.eof) break
251
+ if ((t.name === 'svg' || t.name === 'math') && !t.selfClosing && useForeign) foreign++
252
+ // <svg> / <math> 里(外来内容):浏览器不按纯文字读 title / textarea / plaintext 这些(<svg><title> 里还会当网页读,
253
+ // 里面的 <style> 照样生效),只有 <style> 我们还按纯文字读(CSS 照样查,实体另外还原一遍)
254
+ if (t.name === 'plaintext' && !foreign) raw = 'plaintext'
255
+ else if (RAWTEXT.has(t.name) && !(foreign && (t.selfClosing || t.name !== 'style'))) raw = t.name
256
+ continue
257
+ }
258
+ text(lt, lt + 1)
259
+ i = lt + 1
260
+ }
261
+ return out
262
+ }
263
+
264
+ /** 每个「<字母」都当标签读一遍(包括藏在注释、样式、属性值里的);返回 parseTagAt 的结果列表 */
265
+ export function scanTags(src) {
266
+ const out = []
267
+ const re = /<[a-zA-Z]/g
268
+ let m
269
+ while ((m = re.exec(src))) {
270
+ const t = parseTagAt(src, m.index)
271
+ if (t) out.push(t)
272
+ }
273
+ return out
274
+ }
275
+
276
+ // 行内标签:前后的字在页面上连成一串(不隔开);sup / sub 不算(脚注、上标和正文是两回事)
277
+ const INLINE = new Set(['a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'data', 'del', 'dfn', 'em', 'font', 'i', 'ins', 'kbd', 'label', 'mark', 'nobr', 'q', 's', 'samp', 'small', 'span', 'strong', 'time', 'tspan', 'tt', 'u', 'var', 'wbr'])
278
+
279
+ /**
280
+ * 看得见的文字(和 alt / title / placeholder 这些也会显示出来的属性):查个人信息、可疑字用。
281
+ * 按网页读和认外来内容读各走一遍取并集(浏览器在 <svg> / <math> 里会跳回网页读,两种都可能),宁多勿漏;
282
+ * textarea / title / noscript / xmp / plaintext 里的字也算(会显示出来)。
283
+ * 页面上连着的一串字(中间只隔着行内标签、注释,那里留一个 \u200b)拼成一段;表格格子、段落、换行这些隔开的各是一段,段和段之间用 \u0001 隔开
284
+ *(不会被当成号码里的分隔,三个格子里的 139 / 1234 / 7788 不拼成手机号)
285
+ */
286
+ export function visibleText(src) {
287
+ const parts = []
288
+ const seen = new Set()
289
+ const add = (x) => { if (x && x.trim() && !seen.has(x)) { seen.add(x); parts.push(x) } }
290
+ const SHOWN = new Set(['alt', 'title', 'placeholder', 'aria-label', 'value', 'content', 'label', 'summary', 'data-title'])
291
+ const SHOWN_RAW = new Set(['', 'textarea', 'title', 'noscript', 'xmp', 'plaintext'])
292
+ for (const foreign of [true, false]) {
293
+ let run = ''
294
+ // 一段里连续的空白按浏览器的做法并成一个;行内标签、注释那里留一个看不见的分界(\u200b):
295
+ // 号码可以跨过它(<b>139</b> 1234 7788),也可以从它后面开始(<span>Tel</span><span>139…</span> 排成两块时)
296
+ const flush = () => { add(run.replace(/[ \t\n\r\f\u200b]+/g, (m) => (m.includes('\u200b') ? (/[^\u200b]/.test(m) ? ' \u200b' : '\u200b') : ' '))); run = '' }
297
+ const mark = () => { if (run) run += '\u200b' }
298
+ for (const t of tokenize(src, { foreign })) {
299
+ if (t.type === 'text' && SHOWN_RAW.has(t.raw || '')) {
300
+ if (t.raw) { flush(); add(decodeEntities(t.text).text) } else run += decodeEntities(t.text).text
301
+ continue
302
+ }
303
+ if (t.type === 'cdata') { run += t.text; continue }
304
+ if (t.type === 'comment') { mark(); continue }
305
+ const tag = t.type === 'tag' || t.type === 'endtag' ? t.tag : null
306
+ if (tag && INLINE.has(tag.name)) mark()
307
+ else flush()
308
+ if (t.type === 'tag') for (const a of tag.attrs) if (SHOWN.has(a.name)) add(a.value)
309
+ }
310
+ flush()
311
+ }
312
+ return parts.join('\n\u0001\n')
313
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `dsh-design-studio/gallery`:给设计市场的内容仓库(机器人 / CI)用的出口。
3
+ * 规则和插件里「用这个开始」「打成 zip」是同一份,机器人按版本钉住插件,两边不会各查各的。
4
+ *
5
+ * checkPack / checkManifest / checkStyle / normalizeHtml / message 检查规则(不依赖 Node)
6
+ * readZip / writeZip / readTar zip、npm 包 tgz(不加依赖)
7
+ * cssTokens CSS 分词(转义会还原)
8
+ * renderPack 断网开出来跑体检、截图(要 Chrome)
9
+ */
10
+
11
+ export {
12
+ FORMAT, LIMITS, TYPES, LICENSES, KINDS, STYLE_IDS, HEADS,
13
+ checkPack, checkManifest, checkStyle, normalizeHtml, message, hexRatio, metaKind, titleOf, cleanPath, textFlags, imageMeta, localPathIn,
14
+ } from './policy.js'
15
+ export { stripMeta, stripInline } from './pack.js'
16
+ export { readZip, writeZip, crc32, ZipError, ZIP_LIMITS } from './zip.js'
17
+ export { readTar, TarError, TAR_LIMITS } from './tar.js'
18
+ export { cssTokens } from './css.js'
19
+ export { renderPack, HARD } from './render.js'
20
+ export { decodePng, findQrLike } from './qrfind.js'
21
+ export { KIND_SIZE } from '../look.js'
22
+ export { findBrowser } from '../browser.js'