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.
- package/README.en.md +6 -4
- package/README.md +6 -4
- package/assets/demo.gif +0 -0
- package/assets/hero-sm.jpg +0 -0
- package/cordis.patch.yml +5 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/_shots/menu.jpg +0 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/app.css +64 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/cart.html +120 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/design.json +56 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/menu.html +200 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/order.html +135 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/tokens.css +43 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/_shots/email.jpg +0 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/design.json +20 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/email.html +268 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/_shots/courses.jpg +0 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/courses.html +364 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/design.json +49 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/lesson.html +196 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/tokens.css +55 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/ui.css +255 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/_shots/poster.jpg +0 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/design.json +21 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/poster.html +165 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/tokens.css +35 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/_shots/home.jpg +0 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/app.css +155 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/booking.html +232 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/design.json +57 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/health.html +221 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/home.html +225 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/tokens.css +54 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/_shots/overview.jpg +0 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/app.css +79 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/design.json +51 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/entries.html +161 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/icons.svg +30 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/overview.html +181 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/tokens.css +50 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/_shots/home.jpg +0 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/book.html +279 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/design.json +21 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/home.html +348 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/style.css +297 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/tokens.css +49 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/01-cover.html +133 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/02-highlights.html +67 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/03-growth.html +89 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/04-milestones.html +92 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/05-next.html +99 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/06-thanks.html +61 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/_shots/01-cover.jpg +0 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/deck.css +67 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/design.json +25 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/tokens.css +30 -0
- package/gallery-seed/index.json +1 -0
- package/gallery-seed/thumbs/orderg-x__alley-noodles.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__autumn-sale.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__little-painter.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__moon-fair.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__paw-diary.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__pocket-ledger.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__time-bookshop.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__year-review.webp +0 -0
- package/lib/browser.js +23 -9
- package/lib/bundle.js +12 -3
- package/lib/client.js +3106 -111
- package/lib/core.js +44 -11
- package/lib/gallery/css.js +230 -0
- package/lib/gallery/html.js +313 -0
- package/lib/gallery/index.js +22 -0
- package/lib/gallery/market-routes.js +306 -0
- package/lib/gallery/market.js +623 -0
- package/lib/gallery/pack.js +311 -0
- package/lib/gallery/policy.js +1560 -0
- package/lib/gallery/qrfind.js +181 -0
- package/lib/gallery/render.js +148 -0
- package/lib/gallery/routes.js +360 -0
- package/lib/gallery/tar.js +118 -0
- package/lib/gallery/use.js +125 -0
- package/lib/gallery/zip.js +212 -0
- package/lib/handoff.js +291 -29
- package/lib/history.js +3 -1
- package/lib/index.js +118 -24
- package/lib/inspire.js +142 -0
- package/lib/offline.js +17 -0
- package/lib/picker.js +425 -32
- package/lib/serve.js +88 -11
- package/lib/works.js +238 -0
- package/locale/en.json +2 -1
- package/locale/offline/en.json +6 -0
- package/locale/offline/zh.json +6 -0
- package/locale/zh.json +2 -1
- package/package.json +10 -3
- 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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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 j : 这类转义还原成字,好查 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'
|