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
|
@@ -0,0 +1,1560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设计库的检查规则:内容仓库的机器人、插件「用这个开始」前、插件「分享」前,用的都是这一份。
|
|
3
|
+
* 不依赖 Node(也不依赖浏览器):传进来的是文件内容(Uint8Array 或字符串),Node、浏览器里都能跑。
|
|
4
|
+
*
|
|
5
|
+
* checkPack(files, opts) 整套查:路径、类型、大小、清单(design.json)、每个 HTML / CSS / SVG 的危险写法、个人信息
|
|
6
|
+
* checkManifest(m, opts) 只查清单
|
|
7
|
+
* normalizeHtml(src, opts) 能自动修的先修掉:注释、站外链接(改成 #)、表单提交地址
|
|
8
|
+
* message(p, lang) 一条问题 → 一句能照着改的话
|
|
9
|
+
*
|
|
10
|
+
* 只收静态设计稿:没有脚本、不联网(图片 / 样式只能用包里的文件或 data: 图片)、表单只留样子交不出数据。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import crypto from 'node:crypto'
|
|
14
|
+
import { tokenize, scanTags, decodeEntities, lineAt, visibleText, rawEnd } from './html.js'
|
|
15
|
+
import { scanCss, cssTokens, URL_FUNCS } from './css.js'
|
|
16
|
+
|
|
17
|
+
export const FORMAT = 1
|
|
18
|
+
export const LIMITS = {
|
|
19
|
+
files: 60, // 一套最多几个文件
|
|
20
|
+
screens: 24, // 最多几屏(和工作台画布一样)
|
|
21
|
+
depth: 3, // 目录最多几层
|
|
22
|
+
html: 200 * 1024,
|
|
23
|
+
css: 200 * 1024,
|
|
24
|
+
svg: 200 * 1024,
|
|
25
|
+
json: 64 * 1024,
|
|
26
|
+
md: 64 * 1024,
|
|
27
|
+
image: 400 * 1024,
|
|
28
|
+
side: 2400, // 图片长边
|
|
29
|
+
total: 2 * 1024 * 1024,
|
|
30
|
+
name: 64, // 文件名长度
|
|
31
|
+
}
|
|
32
|
+
export const TYPES = ['template', 'system', 'style']
|
|
33
|
+
export const LICENSES = ['CC0-1.0', 'CC-BY-4.0']
|
|
34
|
+
export const KINDS = ['web', 'app', 'miniprogram', 'tablet', 'desktop', 'poster', 'slides', 'email']
|
|
35
|
+
export const STYLE_IDS = ['minimal', 'warm', 'editorial', 'dark', 'playful', 'fresh', 'tech', 'glass', 'soft', 'guochao', 'ink', 'cyber', 'cute', 'retro', 'sale', 'japandi', 'swiss', 'brutal', 'cream', 'sport']
|
|
36
|
+
export const HEADS = ['SANS', 'SERIF', 'ROUND', 'MONO', 'GROTESK'] // 风格卡的字体只能是这几种系统字体栈
|
|
37
|
+
const META_ALIAS = { phone: 'app', mobile: 'app', ios: 'app', android: 'app', mini: 'miniprogram', miniapp: 'miniprogram', weapp: 'miniprogram', wechat: 'miniprogram', 小程序: 'miniprogram', pad: 'tablet', ipad: 'tablet', pc: 'desktop', electron: 'desktop', tauri: 'desktop', mac: 'desktop', macos: 'desktop', windows: 'desktop', win: 'desktop', 桌面: 'desktop', 桌面软件: 'desktop', social: 'poster', image: 'poster', slide: 'slides', deck: 'slides', ppt: 'slides', mail: 'email' }
|
|
38
|
+
|
|
39
|
+
const EXT = { html: 'html', css: 'css', svg: 'svg', png: 'image', jpg: 'image', jpeg: 'image', webp: 'image', gif: 'image', avif: 'image', json: 'json', md: 'md' }
|
|
40
|
+
const DATA_IMAGE = /^data:image\/(png|jpe?g|webp|gif|avif)(;[a-z0-9=._-]+)*(;base64)?,/i
|
|
41
|
+
const DATA_SVG = /^data:image\/svg\+xml(;[a-z0-9=._-]+)*(;base64)?,/i
|
|
42
|
+
// 这些元素一律不收
|
|
43
|
+
const BAD_TAGS = new Set(['script', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'portal', 'base', 'foreignobject', 'handler', 'listener', 'fencedframe', 'xss', 'param'])
|
|
44
|
+
// <link rel> 只收这几种
|
|
45
|
+
const LINK_RELS = new Set(['stylesheet', 'icon', 'shortcut', 'apple-touch-icon', 'mask-icon'])
|
|
46
|
+
// 值是地址的属性(会去拿,或点了会跳过去)
|
|
47
|
+
const URL_ATTRS = new Set(['src', 'href', 'xlink:href', 'srcset', 'imagesrcset', 'poster', 'data', 'background', 'lowsrc', 'dynsrc', 'longdesc', 'cite', 'action', 'formaction', 'manifest', 'codebase', 'archive', 'usemap', 'icon', 'profile', 'data-href'])
|
|
48
|
+
const DROP_ATTRS = new Set(['action', 'formaction', 'ping']) // 自动修:表单交不出去、点链接不往外发通知
|
|
49
|
+
// SVG 的这些属性按 CSS 值读(fill="url(#g)" 这种),里面的转义也会还原
|
|
50
|
+
const PRES_ATTRS = new Set(['fill', 'stroke', 'filter', 'mask', 'clip-path', 'marker-start', 'marker-mid', 'marker-end', 'marker', 'cursor', 'color-profile'])
|
|
51
|
+
const BAD_SCHEME = /^(javascript|vbscript|livescript|mocha|feed|jar):|^data:(text\/html|application\/|text\/xml|image\/svg)/i
|
|
52
|
+
const NAV_TAGS = new Set(['a', 'area'])
|
|
53
|
+
const SVG_ANIM = new Set(['set', 'animate', 'animatetransform', 'animatemotion', 'animatecolor'])
|
|
54
|
+
|
|
55
|
+
// ── 可疑字(只标出来给人看,不直接拒) ────────────────────────────────────────
|
|
56
|
+
const PHISH = /登录|登陆|密码|验证码|银行卡|身份证|转账|汇款|客服|中奖|领取奖|充值|提现|\b(log ?in|sign ?in|password|passcode|verification code|bank card|credit card|wire transfer|account number|cvv)\b/i
|
|
57
|
+
const BRANDS = /微信支付|支付宝|淘宝|天猫|京东|拼多多|抖音|快手|美团|饿了么|小红书|哔哩哔哩|bilibili|微博|知乎|百度|腾讯|阿里巴巴|字节跳动|中国银行|工商银行|建设银行|农业银行|招商银行|交通银行|邮政储蓄|中国移动|中国联通|中国电信|12306|\b(apple|icloud|google|gmail|microsoft|outlook|paypal|amazon|netflix|facebook|instagram|whatsapp|wechat|alipay|taobao|tiktok|steam|binance|coinbase)\b/i
|
|
58
|
+
const INJECT = /忽略(之前|以上|前面|上面|先前)|无视(之前|以上|前面)|不要理会|你(现在)?是一个|你的新(任务|指令)|系统提示|提示词|\b(ignore (all |any )?(the )?(previous|prior|above|earlier)|disregard (all |the )?(previous|prior|above)|system prompt|you are now|new instructions|developer mode|jailbreak)\b|\b(preview_page|check_design|look_design|handoff_design|compare_design|extract_design_system|show_page|run_command|write_file|edit_file|read_file|ask_user_question|bash|shell)\b|rm -rf|\bsudo\b|\bcurl\s+-|\bwget\s|\|\s*(ba)?sh\b|powershell|chmod\s|\bnpm (i|install)\b|\bpip install\b/i
|
|
59
|
+
const URLISH = /\bhttps?:\/\/|\bwww\.[a-z0-9-]+\.|\b[a-z0-9-]+\.(com|cn|net|org|io|xyz|top|cc|me|co|app|dev|site|shop|vip|info)\b/i
|
|
60
|
+
const PERSONAL_MAIL = /@(qq|163|126|yeah|sina|sohu|139|189|foxmail|aliyun|gmail|googlemail|outlook|hotmail|live|msn|yahoo|icloud|me|mac|proton|protonmail)\.(com|cn|net|com\.cn)\b/i
|
|
61
|
+
const FAKE_MAIL = /@([a-z0-9-]+\.)*(example\.(com|org|net)|test|invalid|localhost|example)$/i
|
|
62
|
+
|
|
63
|
+
/** 一条问题的说明({x} 换成具体内容)。sev:error 拒收 / flag 标给人看 */
|
|
64
|
+
const MSG = {
|
|
65
|
+
path: { zh: '文件名「{x}」不行:只能用字母、数字、中文和 . _ -,不能以 . 开头,不能有 ..,目录最多 3 层', en: 'File name "{x}" is not allowed: use letters, digits, . _ - only, no leading dot, no "..", at most 3 folders deep' },
|
|
66
|
+
ext: { zh: '「{x}」这种文件不收:只收 html、css、svg、png、jpg、webp、gif、avif、json、md(第一版不收脚本和字体文件)', en: '"{x}" files are not accepted: only html, css, svg, png, jpg, webp, gif, avif, json and md (no scripts or font files in this version)' },
|
|
67
|
+
magic: { zh: '「{x}」的内容和扩展名对不上(比如改了扩展名的别的文件):用真正的图片文件', en: '"{x}" does not match its extension (e.g. another file renamed): use a real image file' },
|
|
68
|
+
utf8: { zh: '「{x}」不是 UTF-8 文字:另存为 UTF-8', en: '"{x}" is not UTF-8 text: save it as UTF-8' },
|
|
69
|
+
nul: { zh: '「{x}」里有空字符(\\0):删掉', en: '"{x}" contains NUL characters: remove them' },
|
|
70
|
+
big: { zh: '「{x}」太大({n},上限 {max}):压小一点', en: '"{x}" is too big ({n}, limit {max}): make it smaller' },
|
|
71
|
+
side: { zh: '图片「{x}」太大({n},长边最多 {max} 像素):缩小一点', en: 'Image "{x}" is too large ({n}, longest side at most {max} px): scale it down' },
|
|
72
|
+
total: { zh: '整套太大({n},上限 {max}):压缩图片、删掉没用的文件', en: 'The whole design is too big ({n}, limit {max}): compress images, remove unused files' },
|
|
73
|
+
count: { zh: '文件太多({n} 个,上限 {max})', en: 'Too many files ({n}, limit {max})' },
|
|
74
|
+
dup: { zh: '「{x}」重复了(不分大小写)', en: '"{x}" appears twice (case-insensitive)' },
|
|
75
|
+
noManifest: { zh: '缺 design.json(设计清单)', en: 'design.json (the design manifest) is missing' },
|
|
76
|
+
json: { zh: '「{x}」不是合法的 JSON:{n}', en: '"{x}" is not valid JSON: {n}' },
|
|
77
|
+
field: { zh: 'design.json 的「{x}」不对:{n}', en: 'design.json field "{x}" is wrong: {n}' },
|
|
78
|
+
unknownField: { zh: 'design.json 里有不认识的字段「{x}」:删掉', en: 'design.json has an unknown field "{x}": remove it' },
|
|
79
|
+
screenMissing: { zh: 'design.json 列的屏「{x}」不在包里', en: 'Screen "{x}" listed in design.json is not in the package' },
|
|
80
|
+
screenUnlisted: { zh: '「{x}」没列进 design.json 的 screens:加进去,或者改名成 _ 开头(不算一屏)', en: '"{x}" is not listed in design.json screens: add it, or rename it to start with _ (not a screen)' },
|
|
81
|
+
subHtml: { zh: '子目录里的 HTML「{x}」不收:每屏都放在最外层', en: 'HTML in a subfolder ("{x}") is not accepted: keep every screen at the top level' },
|
|
82
|
+
meta: { zh: '「{x}」缺 <meta name="dsh-design" content="{n}">,或者写的类型和 design.json 的 kind 不一样', en: '"{x}" lacks <meta name="dsh-design" content="{n}">, or its kind differs from design.json' },
|
|
83
|
+
titleFmt: { zh: '「{x}」的 <title> 要写成「屏名 · 产品名」(中间是 ·)', en: '"{x}" needs a <title> like "Screen · Product" (with a middle dot)' },
|
|
84
|
+
charset: { zh: '「{x}」声明的编码不是 UTF-8:改成 UTF-8(网页写 <meta charset="utf-8">)', en: '"{x}" declares a charset other than UTF-8: use UTF-8 (in HTML, <meta charset="utf-8">)' },
|
|
85
|
+
tag: { zh: '「{x}」第 {line} 行有 <{n}>:不收(第一版不收脚本、内嵌网页、插件、<base>)', en: '"{x}" line {line} has <{n}>: not accepted (no scripts, embedded pages, plugins or <base>)' },
|
|
86
|
+
handler: { zh: '「{x}」第 {line} 行有事件属性 {n}=:删掉(不收脚本)', en: '"{x}" line {line} has an event attribute {n}=: remove it (no scripts)' },
|
|
87
|
+
scheme: { zh: '「{x}」第 {line} 行的 {n} 用了 javascript: / data:text/html 这类会运行代码的地址:删掉', en: '"{x}" line {line}: {n} uses a code-running URL (javascript:, data:text/html …): remove it' },
|
|
88
|
+
srcdoc: { zh: '「{x}」第 {line} 行有 srcdoc:删掉', en: '"{x}" line {line} has srcdoc: remove it' },
|
|
89
|
+
httpEquiv: { zh: '「{x}」第 {line} 行有 <meta http-equiv>(自动跳转 / 改规则):删掉', en: '"{x}" line {line} has <meta http-equiv> (redirect / policy): remove it' },
|
|
90
|
+
linkRel: { zh: '「{x}」第 {line} 行的 <link rel="{n}"> 不收:只收样式表和图标', en: '"{x}" line {line}: <link rel="{n}"> is not accepted (only stylesheets and icons)' },
|
|
91
|
+
external: { zh: '「{x}」第 {line} 行引用了站外地址「{n}」:图片、样式、字体只能用包里的文件(或 data: 图片),不联网', en: '"{x}" line {line} references an outside URL "{n}": images, styles and fonts must come from the package (or data: images)' },
|
|
92
|
+
externalLink: { zh: '「{x}」第 {line} 行的链接指向站外「{n}」:改成 #(分享时会自动改)', en: '"{x}" line {line} links outside ("{n}"): change it to # (sharing does this for you)' },
|
|
93
|
+
absolute: { zh: '「{x}」第 {line} 行的地址「{n}」以 / 开头:改成相对路径(比如 assets/a.png)', en: '"{x}" line {line}: "{n}" starts with /: use a relative path (e.g. assets/a.png)' },
|
|
94
|
+
backslash: { zh: '「{x}」第 {line} 行的地址「{n}」里有反斜杠 \\:改成 /', en: '"{x}" line {line}: "{n}" contains a backslash: use /' },
|
|
95
|
+
outside: { zh: '「{x}」第 {line} 行的地址「{n}」跑到包外面了', en: '"{x}" line {line}: "{n}" points outside the package' },
|
|
96
|
+
missing: { zh: '「{x}」第 {line} 行引用的「{n}」不在包里', en: '"{x}" line {line} references "{n}", which is not in the package' },
|
|
97
|
+
badEscape: { zh: '「{x}」第 {line} 行的 {n} 里有看不懂的转义(&…;):直接写字', en: '"{x}" line {line}: {n} has an unknown character reference (&…;): write the character directly' },
|
|
98
|
+
animHref: { zh: '「{x}」第 {line} 行的 SVG 动画会改链接地址:删掉', en: '"{x}" line {line}: an SVG animation changes a link: remove it' },
|
|
99
|
+
ping: { zh: '「{x}」第 {line} 行的链接有 ping(点了会往外发通知):删掉(分享时会自动删)', en: '"{x}" line {line}: a link has ping (sends a request when clicked): remove it (sharing does this)' },
|
|
100
|
+
form: { zh: '「{x}」第 {line} 行的表单有提交地址({n}):删掉(分享时会自动删,表单只留样子)', en: '"{x}" line {line}: the form has a submit target ({n}): remove it (sharing does this; forms stay visual only)' },
|
|
101
|
+
comment: { zh: '「{x}」第 {line} 行有 HTML 注释:删掉(分享时会自动删)', en: '"{x}" line {line} has an HTML comment: remove it (sharing does this)' },
|
|
102
|
+
cssImport: { zh: '「{x}」第 {line} 行用了 @import:把样式直接写进来,或在 HTML 里用 <link>', en: '"{x}" line {line} uses @import: inline the styles or <link> them from the HTML' },
|
|
103
|
+
cssFunc: { zh: '「{x}」第 {line} 行用了 {n}():删掉', en: '"{x}" line {line} uses {n}(): remove it' },
|
|
104
|
+
cssProp: { zh: '「{x}」第 {line} 行用了 {n}:删掉', en: '"{x}" line {line} uses {n}: remove it' },
|
|
105
|
+
cssBad: { zh: '「{x}」第 {line} 行的 CSS 写坏了(url 或字符串没写完整):改对', en: '"{x}" line {line}: broken CSS (unfinished url or string): fix it' },
|
|
106
|
+
fontUrl: { zh: '「{x}」第 {line} 行的 @font-face 用了字体文件:第一版只用系统字体(src 只能写 local())', en: '"{x}" line {line}: @font-face loads a font file: only system fonts for now (src: local() only)' },
|
|
107
|
+
svgDoctype: { zh: 'SVG「{x}」里有 <!DOCTYPE> / <!ENTITY> / <?…?>:删掉', en: 'SVG "{x}" has <!DOCTYPE>, <!ENTITY> or <?…?>: remove it' },
|
|
108
|
+
notSvg: { zh: '「{x}」不像 SVG 文件', en: '"{x}" does not look like an SVG file' },
|
|
109
|
+
eofTag: { zh: '「{x}」第 {line} 行的 <{n}> 没写完:补上 >', en: '"{x}" line {line}: <{n}> is not closed: add >' },
|
|
110
|
+
style: { zh: 'style.json 的「{x}」不对:{n}', en: 'style.json field "{x}" is wrong: {n}' },
|
|
111
|
+
contrast: { zh: 'style.json 配色对比度不够:{x} 只有 {n}:1(要 ≥ 4.5:1)', en: 'style.json contrast too low: {x} is {n}:1 (need ≥ 4.5:1)' },
|
|
112
|
+
system: { zh: '设计系统缺「{x}」', en: 'The design system lacks "{x}"' },
|
|
113
|
+
systemSource: { zh: '「{x}」:source/ 是设计台在本机放截图的地方,不分享也不安装;样张要用的图放到 assets/ 里', en: '"{x}": source/ is where the studio keeps local screenshots and is never shared or installed; put images the sample page uses in assets/' },
|
|
114
|
+
asset: { zh: 'design.json 的 assets 里「{x}」:{n}', en: 'design.json assets "{x}": {n}' },
|
|
115
|
+
// 个人信息:分享前插件里硬拦(一交上去就公开);机器人那边标给人看
|
|
116
|
+
phone: { zh: '「{x}」第 {line} 行像真的手机号({n}):改成 138 0000 0000 或 138****0000 这种假号', en: '"{x}" line {line} looks like a real phone number ({n}): use a fake one like 138 0000 0000' },
|
|
117
|
+
idcard: { zh: '「{x}」第 {line} 行像真的身份证号({n}):改成打码的 1101**********1234', en: '"{x}" line {line} looks like a real ID number ({n}): mask it like 1101**********1234' },
|
|
118
|
+
email: { zh: '「{x}」第 {line} 行像个人邮箱({n}):改成 hello@example.com 这种', en: '"{x}" line {line} looks like a personal email ({n}): use something like hello@example.com' },
|
|
119
|
+
localPath: { zh: '「{x}」第 {line} 行有本机路径({n}):会带出你电脑的用户名,删掉或改成相对路径', en: '"{x}" line {line} has a local path ({n}): it reveals your computer user name; remove it or use a relative path' },
|
|
120
|
+
inlineMany: { zh: '「{x}」里内嵌的图片太多(超过 {n} 份),没法逐个查:换成单独的图片文件', en: '"{x}" has too many inline images (over {n}) to check one by one: use separate image files' },
|
|
121
|
+
inlineNested: { zh: '「{x}」第 {line} 行内嵌的矢量图里又套了内嵌的矢量图,没法查里面的字:改成单独的 .svg 文件', en: '"{x}" line {line} has an inline SVG nested inside another inline SVG, which cannot be checked: use a separate .svg file' },
|
|
122
|
+
inlineSvgBad: { zh: '「{x}」第 {line} 行内嵌的矢量图里有不收的写法({n}):改成单独的 .svg 文件,按提示改', en: '"{x}" line {line} has an inline SVG with markup that is not accepted ({n}): move it into a separate .svg file and fix it there' },
|
|
123
|
+
inlineEncoding: { zh: '「{x}」第 {line} 行内嵌的矢量图不是 UTF-8 编码,没法查里面的字:另存为 UTF-8,或改成单独的 .svg 文件', en: '"{x}" line {line} has an inline SVG that is not UTF-8, so it cannot be checked: re-save it as UTF-8, or use a separate .svg file' },
|
|
124
|
+
inlineBad: { zh: '「{x}」第 {line} 行内嵌的图片认不出格式(写的是图片,内容不是):换成单独的图片文件', en: '"{x}" line {line} has an inline image whose content is not a recognizable image: use a separate image file' },
|
|
125
|
+
imgMetaInline: { zh: '「{x}」第 {line} 行内嵌的图片(data:image)带着拍摄信息:换成单独的图片文件,或另存一份不带信息的', en: '"{x}" line {line} has an inline image (data:image) that still carries photo metadata: use a separate image file, or re-save it without metadata' },
|
|
126
|
+
imgMeta: { zh: '「{x}」里带着拍摄信息(EXIF / XMP / 注释):另存一份不带信息的,或换成 PNG / JPG', en: '"{x}" still carries photo metadata (EXIF / XMP / comments): re-save it without metadata, or use PNG / JPG' },
|
|
127
|
+
secret: { zh: '「{x}」里有你的分享记号(只该留在你自己电脑上):删掉这段再分享', en: '"{x}" contains your share token (it should stay on your computer): remove it before sharing' },
|
|
128
|
+
// 只标出来
|
|
129
|
+
bizEmail: { zh: '「{x}」第 {line} 行有邮箱({n}):示例里最好用 example.com', en: '"{x}" line {line} has an email ({n}): prefer example.com in samples' },
|
|
130
|
+
address: { zh: '「{x}」第 {line} 行像详细地址({n}):用虚构地址', en: '"{x}" line {line} looks like a street address ({n}): use a made-up one' },
|
|
131
|
+
password: { zh: '「{x}」有密码框:要人看一眼是不是仿冒真实网站的登录页', en: '"{x}" has a password field: needs a human look (impersonation check)' },
|
|
132
|
+
phish: { zh: '「{x}」有「{n}」这类字:要人看一眼是不是仿冒', en: '"{x}" contains words like "{n}": needs a human look (impersonation check)' },
|
|
133
|
+
brand: { zh: '「{x}」出现了真实品牌「{n}」:市场只收虚构品牌', en: '"{x}" mentions the real brand "{n}": only made-up brands are accepted' },
|
|
134
|
+
inject: { zh: '「{x}」里有像给模型下指令的字(「{n}」):删掉或改写', en: '"{x}" has text that looks like instructions to the model ("{n}"): remove or rephrase it' },
|
|
135
|
+
url: { zh: '「{x}」的文字里有网址({n})', en: '"{x}" text contains a URL ({n})' },
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 一条问题 → 一句话 */
|
|
139
|
+
export function message(p, lang = 'zh') {
|
|
140
|
+
const m = MSG[p.code] || { zh: p.code, en: p.code }
|
|
141
|
+
return (lang === 'en' ? m.en : m.zh).replace(/\{(\w+)\}/g, (_, k) => (p[k] === undefined ? '' : String(p[k])))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const kb = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(n / 1024)}KB`)
|
|
145
|
+
const cut = (s, n = 60) => { const t = String(s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t }
|
|
146
|
+
|
|
147
|
+
// ── 路径和地址 ────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const SEG = /^[\p{L}\p{N}._-]+$/u
|
|
150
|
+
|
|
151
|
+
/** 包里的相对路径是否合规;合规返回规范写法(/ 分隔),不合规返回 '' */
|
|
152
|
+
export function cleanPath(p) {
|
|
153
|
+
const s = String(p || '')
|
|
154
|
+
if (!s || s.includes('\\') || s.startsWith('/') || /[\x00-\x1f\x7f]/.test(s)) return ''
|
|
155
|
+
const segs = s.split('/')
|
|
156
|
+
if (segs.length > LIMITS.depth + 1) return ''
|
|
157
|
+
for (const g of segs) {
|
|
158
|
+
if (!g || g === '.' || g === '..' || g.startsWith('.') || g.length > LIMITS.name || !SEG.test(g)) return ''
|
|
159
|
+
}
|
|
160
|
+
return segs.join('/')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function posixJoin(dir, rel) {
|
|
164
|
+
const out = dir ? dir.split('/') : []
|
|
165
|
+
for (const g of rel.split('/')) {
|
|
166
|
+
if (g === '' || g === '.') continue
|
|
167
|
+
if (g === '..') {
|
|
168
|
+
if (!out.length) return null
|
|
169
|
+
out.pop()
|
|
170
|
+
} else out.push(g)
|
|
171
|
+
}
|
|
172
|
+
return out.join('/')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 一个地址合不合规。ctx:'res' 会去拿的(图片 / 样式)、'nav' 点了跳过去的、'css' CSS 里的、'font' @font-face 里的
|
|
177
|
+
* 返回 null(没问题)或 { code, ref }:external externalLink absolute backslash outside missing scheme
|
|
178
|
+
*/
|
|
179
|
+
export function checkRef(raw, { dir = '', files = null, ctx = 'res' } = {}) {
|
|
180
|
+
// 浏览器读地址时去掉首尾空白,中间的制表符、换行也丢掉
|
|
181
|
+
const v = String(raw).replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '')
|
|
182
|
+
if (!v) return null
|
|
183
|
+
const squash = v.replace(/[\x00-\x20]/g, '')
|
|
184
|
+
if (BAD_SCHEME.test(squash) && !(DATA_SVG.test(squash) && ctx !== 'nav')) return { code: 'scheme', ref: cut(v) }
|
|
185
|
+
if (v.startsWith('#')) return null
|
|
186
|
+
if (/^data:/i.test(v)) {
|
|
187
|
+
if (ctx === 'nav') return { code: 'scheme', ref: cut(v) }
|
|
188
|
+
if (DATA_IMAGE.test(v) || DATA_SVG.test(v)) return ctx === 'font' ? { code: 'fontUrl', ref: cut(v) } : null
|
|
189
|
+
return { code: 'external', ref: cut(v) }
|
|
190
|
+
}
|
|
191
|
+
if (v.includes('\\')) return { code: 'backslash', ref: cut(v) }
|
|
192
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(v) || v.startsWith('//')) return { code: ctx === 'nav' ? 'externalLink' : 'external', ref: cut(v) }
|
|
193
|
+
if (v.startsWith('/')) return { code: 'absolute', ref: cut(v) }
|
|
194
|
+
if (ctx === 'font') return { code: 'fontUrl', ref: cut(v) }
|
|
195
|
+
let p = v.replace(/[?#].*$/, '')
|
|
196
|
+
if (!p) return null
|
|
197
|
+
try { p = decodeURIComponent(p) } catch { return { code: 'outside', ref: cut(v) } }
|
|
198
|
+
if (p.includes('\\') || /[\x00-\x1f]/.test(p)) return { code: 'backslash', ref: cut(v) }
|
|
199
|
+
const full = posixJoin(dir, p)
|
|
200
|
+
if (full === null) return { code: 'outside', ref: cut(v) }
|
|
201
|
+
if (files && !files.has(full.toLowerCase())) return { code: 'missing', ref: cut(v) }
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** srcset:「地址 描述, 地址 描述」 */
|
|
206
|
+
function srcsetUrls(v) {
|
|
207
|
+
const out = []
|
|
208
|
+
const s = String(v)
|
|
209
|
+
let i = 0
|
|
210
|
+
while (i < s.length) {
|
|
211
|
+
while (i < s.length && /[\s,]/.test(s[i])) i++
|
|
212
|
+
let j = i
|
|
213
|
+
while (j < s.length && !/\s/.test(s[j])) j++
|
|
214
|
+
let url = s.slice(i, j)
|
|
215
|
+
if (url.endsWith(',')) url = url.replace(/,+$/, '')
|
|
216
|
+
else {
|
|
217
|
+
// 描述到下一个逗号(括号里的逗号不算)
|
|
218
|
+
let depth = 0
|
|
219
|
+
while (j < s.length && !(s[j] === ',' && depth === 0)) { if (s[j] === '(') depth++; if (s[j] === ')') depth--; j++ }
|
|
220
|
+
}
|
|
221
|
+
if (url) out.push(url)
|
|
222
|
+
i = j + 1
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── 图片 ──────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
const u32 = (b, o) => ((b[o] << 24) >>> 0) + (b[o + 1] << 16) + (b[o + 2] << 8) + b[o + 3]
|
|
230
|
+
const ascii = (b, o, n) => String.fromCharCode(...b.subarray(o, o + n))
|
|
231
|
+
|
|
232
|
+
/** 看文件头:真实格式和宽高(认不出 → null) */
|
|
233
|
+
export function imageInfo(b) {
|
|
234
|
+
if (!b || b.length < 12) return null
|
|
235
|
+
if (b[0] === 0x89 && ascii(b, 1, 3) === 'PNG') return b.length >= 24 ? { type: 'png', w: u32(b, 16), h: u32(b, 20) } : null
|
|
236
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
|
237
|
+
let o = 2
|
|
238
|
+
while (o + 9 < b.length) {
|
|
239
|
+
if (b[o] !== 0xff) { o++; continue }
|
|
240
|
+
const m = b[o + 1]
|
|
241
|
+
if (m === 0xff) { o++; continue }
|
|
242
|
+
if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) { o += 2; continue }
|
|
243
|
+
const len = (b[o + 2] << 8) + b[o + 3]
|
|
244
|
+
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { type: 'jpg', h: (b[o + 5] << 8) + b[o + 6], w: (b[o + 7] << 8) + b[o + 8] }
|
|
245
|
+
o += 2 + len
|
|
246
|
+
}
|
|
247
|
+
return { type: 'jpg', w: 0, h: 0 }
|
|
248
|
+
}
|
|
249
|
+
if (ascii(b, 0, 4) === 'GIF8') return { type: 'gif', w: b[6] + (b[7] << 8), h: b[8] + (b[9] << 8) }
|
|
250
|
+
if (ascii(b, 0, 4) === 'RIFF' && ascii(b, 8, 4) === 'WEBP') {
|
|
251
|
+
const k = ascii(b, 12, 4)
|
|
252
|
+
if (k === 'VP8 ' && b.length >= 30) return { type: 'webp', w: (b[26] + (b[27] << 8)) & 0x3fff, h: (b[28] + (b[29] << 8)) & 0x3fff }
|
|
253
|
+
if (k === 'VP8L' && b.length >= 25) { const x = b[21] + (b[22] << 8) + (b[23] << 16) + (b[24] << 24 >>> 0); return { type: 'webp', w: (x & 0x3fff) + 1, h: ((x >>> 14) & 0x3fff) + 1 } }
|
|
254
|
+
if (k === 'VP8X' && b.length >= 30) return { type: 'webp', w: 1 + b[24] + (b[25] << 8) + (b[26] << 16), h: 1 + b[27] + (b[28] << 8) + (b[29] << 16) }
|
|
255
|
+
return { type: 'webp', w: 0, h: 0 }
|
|
256
|
+
}
|
|
257
|
+
if (ascii(b, 4, 4) === 'ftyp' && /^avi[fs]$/.test(ascii(b, 8, 4))) {
|
|
258
|
+
for (let o = 12; o + 20 < b.length && o < 4096; o++) {
|
|
259
|
+
if (b[o] === 0x69 && ascii(b, o, 4) === 'ispe') return { type: 'avif', w: u32(b, o + 8), h: u32(b, o + 12) }
|
|
260
|
+
}
|
|
261
|
+
return { type: 'avif', w: 0, h: 0 }
|
|
262
|
+
}
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const le32 = (b, o) => (b[o] + (b[o + 1] << 8) + (b[o + 2] << 16) + ((b[o + 3] << 24) >>> 0))
|
|
267
|
+
function hasAscii(b, str) {
|
|
268
|
+
const n = str.length
|
|
269
|
+
outer: for (let i = 0; i + n <= b.length; i++) {
|
|
270
|
+
for (let j = 0; j < n; j++) if (b[i + j] !== str.charCodeAt(j)) continue outer
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
/** GIF 里有注释块、XMP 块吗 */
|
|
276
|
+
function gifMeta(b) {
|
|
277
|
+
let i = 13
|
|
278
|
+
if (b[10] & 0x80) i += 3 * (1 << ((b[10] & 7) + 1))
|
|
279
|
+
const skipSub = () => { while (i < b.length && b[i] !== 0) i += b[i] + 1; i++ }
|
|
280
|
+
while (i < b.length) {
|
|
281
|
+
const t = b[i]
|
|
282
|
+
if (t === 0x3b) return false
|
|
283
|
+
if (t === 0x21) {
|
|
284
|
+
const label = b[i + 1]
|
|
285
|
+
if (label === 0xfe) return true
|
|
286
|
+
if (label === 0xff && b[i + 2] === 11 && ascii(b, i + 3, 8) === 'XMP Data') return true
|
|
287
|
+
i += 2
|
|
288
|
+
skipSub()
|
|
289
|
+
} else if (t === 0x2c) {
|
|
290
|
+
const packed = b[i + 9]
|
|
291
|
+
i += 10
|
|
292
|
+
if (packed & 0x80) i += 3 * (1 << ((packed & 7) + 1))
|
|
293
|
+
i++ // LZW 最小码长
|
|
294
|
+
skipSub()
|
|
295
|
+
} else return true // 看不懂:当它有(让人换个格式)
|
|
296
|
+
}
|
|
297
|
+
return false
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* 图片里还带着拍摄信息 / 文字块吗(EXIF、GPS、XMP、注释)。分享、投稿前要去掉(插件打包时会自动去 JPG / PNG / WebP / GIF 的)
|
|
301
|
+
* 看不懂的结构当「有」
|
|
302
|
+
*/
|
|
303
|
+
export function imageMeta(b, type) {
|
|
304
|
+
try {
|
|
305
|
+
if (type === 'jpg' || type === 'jpeg') {
|
|
306
|
+
let i = 2
|
|
307
|
+
while (i + 4 <= b.length) {
|
|
308
|
+
if (b[i] !== 0xff) return true
|
|
309
|
+
const mk = b[i + 1]
|
|
310
|
+
if (mk === 0xda) return false
|
|
311
|
+
if (mk === 0xd8 || (mk >= 0xd0 && mk <= 0xd7) || mk === 0x01) { i += 2; continue }
|
|
312
|
+
if (mk === 0xe1 || mk === 0xed || mk === 0xfe) return true
|
|
313
|
+
i += 2 + ((b[i + 2] << 8) + b[i + 3])
|
|
314
|
+
}
|
|
315
|
+
return false
|
|
316
|
+
}
|
|
317
|
+
if (type === 'png') {
|
|
318
|
+
let i = 8
|
|
319
|
+
while (i + 12 <= b.length) {
|
|
320
|
+
const t = ascii(b, i + 4, 4)
|
|
321
|
+
if (['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME'].includes(t)) return true
|
|
322
|
+
if (t === 'IEND') return false
|
|
323
|
+
i += 12 + u32(b, i)
|
|
324
|
+
}
|
|
325
|
+
return false
|
|
326
|
+
}
|
|
327
|
+
if (type === 'webp') {
|
|
328
|
+
let i = 12
|
|
329
|
+
while (i + 8 <= b.length) {
|
|
330
|
+
const t = ascii(b, i, 4)
|
|
331
|
+
if (t === 'EXIF' || t === 'XMP ') return true
|
|
332
|
+
const len = le32(b, i + 4)
|
|
333
|
+
i += 8 + len + (len & 1)
|
|
334
|
+
}
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
if (type === 'gif') return gifMeta(b)
|
|
338
|
+
if (type === 'avif') return hasAscii(b, 'Exif') || hasAscii(b, 'xmpmeta') || hasAscii(b, 'ns.adobe.com/xap')
|
|
339
|
+
} catch { return true }
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* CSS 里的字符串和地址(照 CSS 规范分词,转义已还原:\64 ata: 就是 data:,u\72l( 就是 url()。不封顶。
|
|
345
|
+
* → [{ v, url }]:url 是 true 的是「一个确切的地址」(url(…) 记号、url / image-set 这些取地址的函数里的字符串)
|
|
346
|
+
*/
|
|
347
|
+
export function cssValues(text, toks = null) {
|
|
348
|
+
const out = []
|
|
349
|
+
const stack = []
|
|
350
|
+
let prevStr = false // 上一个(不算空白、注释)记号是字符串:content: "139 " "1234" 在页面上是连着显示的
|
|
351
|
+
let ident = '' // 上一个记号是名字:后面紧跟「:」就是属性名
|
|
352
|
+
let prop = '' // 当前声明的属性名(font-family、--font-sans……)
|
|
353
|
+
for (const tk of toks || cssTokens(String(text || ''))) {
|
|
354
|
+
if (tk.t === 'ws' || tk.t === 'comment') continue
|
|
355
|
+
const isStr = (tk.t === 'str' || tk.t === 'bad-str') && typeof tk.v === 'string'
|
|
356
|
+
if (tk.t === ':' && ident && !stack.length) prop = ident
|
|
357
|
+
else if (tk.t === ';' || tk.t === '{' || tk.t === '}') prop = ''
|
|
358
|
+
ident = tk.t === 'ident' ? String(tk.v || '').toLowerCase() : ''
|
|
359
|
+
if (tk.t === 'func') stack.push(String(tk.v || '').toLowerCase())
|
|
360
|
+
else if (tk.t === '(') stack.push('')
|
|
361
|
+
else if (tk.t === ')') stack.pop()
|
|
362
|
+
else if (isStr) out.push({ v: tk.v, url: stack.some((f) => URL_FUNCS.has(f)), next: prevStr, ...(FONT_PROP.test(prop) ? { font: true } : {}) })
|
|
363
|
+
else if ((tk.t === 'url' || tk.t === 'bad-url') && typeof tk.v === 'string') out.push({ v: tk.v, url: true })
|
|
364
|
+
prevStr = isStr
|
|
365
|
+
}
|
|
366
|
+
return out
|
|
367
|
+
}
|
|
368
|
+
// 写字体的属性:font-family、font,和 --font-sans、--heading-font 这种字体变量
|
|
369
|
+
const FONT_PROP = /^(font(-family)?|--[\w-]*(font|family|typeface)[\w-]*)$/
|
|
370
|
+
// 字体名里的厂商名("Microsoft YaHei"、"Apple Color Emoji"、"Segoe UI")不是冒用品牌:查品牌前去掉,别的(个人信息)照查
|
|
371
|
+
const FONT_VENDOR = /^\s*(microsoft|apple|google|segoe)(?=\s+\S)/i
|
|
372
|
+
|
|
373
|
+
/** CSS 里的字符串(默认不要地址:那不是给人看的字);urls:地址也要。紧挨着的几个字符串(content: "139 " "1234")拼成一个,中间留看不见的分界 \u200b */
|
|
374
|
+
export function cssStrings(text, { urls = false } = {}) {
|
|
375
|
+
const out = []
|
|
376
|
+
let kept = false
|
|
377
|
+
for (const x of cssValues(text)) {
|
|
378
|
+
if (!urls && x.url) { kept = false; continue }
|
|
379
|
+
const v = x.font ? x.v.replace(FONT_VENDOR, '') : x.v
|
|
380
|
+
if (x.next && kept) out[out.length - 1] += `\u200b${v}`
|
|
381
|
+
else out.push(v)
|
|
382
|
+
kept = true
|
|
383
|
+
}
|
|
384
|
+
return out
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 一个 data: 网址按浏览器的做法解开(去 Tab/换行 → 百分号解码 → 写了 ;base64 再去空白解 base64)→ { mime, buf } | null */
|
|
388
|
+
export function parseDataUrl(v) {
|
|
389
|
+
// 网址里的 Tab、换行先全部去掉(「da\nta:」「image/p\nng」这种浏览器照样认)
|
|
390
|
+
// 地址里第一个 # 起是片段(#icon、#svgView(…)),不是内容;内容里的 # 要写成 %23
|
|
391
|
+
const m = /^[\f ]*data:([^,#]*),([^#]*)/i.exec(String(v).replace(/[\t\n\r]+/g, ''))
|
|
392
|
+
if (!m) return null
|
|
393
|
+
const buf = /;[ ]*base64[ ]*$/i.test(m[1]) ? Buffer.from(pctBytes(m[2]).toString('latin1').replace(/[\t\n\f\r ]+/g, ''), 'base64') : pctBytes(m[2])
|
|
394
|
+
return { mime: m[1].split(';')[0].trim().toLowerCase(), params: m[1], buf }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** data: 网址 → 是位图就 { type(按字节认,不看写的类型), buf },不然 null */
|
|
398
|
+
export function parseDataImage(v) {
|
|
399
|
+
const d = parseDataUrl(v)
|
|
400
|
+
if (!d) return null
|
|
401
|
+
const info = imageInfo(d.buf)
|
|
402
|
+
return info ? { type: info.type, buf: d.buf } : null
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** 解出来的是不是 SVG(写的类型是 svg,或者内容开头就是 <svg) */
|
|
406
|
+
function isSvgData(d) {
|
|
407
|
+
return d.mime === 'image/svg+xml' || /^\s*(?:<\?xml[^>]*>\s*)?(?:(?:<!--[\s\S]*?-->|<!doctype[^>]*>)\s*)*<svg[\s>/]/i.test(d.buf.subarray(0, 8192).toString('utf8'))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 内嵌 SVG 的字:只认 UTF-8。开头是 UTF-16 的 BOM、data: 网址写了别的 charset、<?xml encoding> 写了别的编码、
|
|
412
|
+
* 不是合法 UTF-8 → null(浏览器会按别的编码读,我们查不准,拦下来)
|
|
413
|
+
*/
|
|
414
|
+
export function svgText(buf, params = '') {
|
|
415
|
+
let b = buf
|
|
416
|
+
if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) b = b.subarray(3)
|
|
417
|
+
else if ((b[0] === 0xfe && b[1] === 0xff) || (b[0] === 0xff && b[1] === 0xfe)) return null
|
|
418
|
+
else {
|
|
419
|
+
const cs = /;\s*charset\s*=\s*"?([^;"]*)/i.exec(params || '')
|
|
420
|
+
if (cs && !/^\s*utf-?8\s*$/i.test(cs[1])) return null
|
|
421
|
+
}
|
|
422
|
+
let text
|
|
423
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(b) } catch { return null }
|
|
424
|
+
if (text.includes('\0')) return null
|
|
425
|
+
const decl = /^\s*<\?xml[^>]*?\bencoding\s*=\s*["']([^"']*)["']/i.exec(text)
|
|
426
|
+
if (decl && !/^\s*utf-?8\s*$/i.test(decl[1])) return null
|
|
427
|
+
return text
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 百分号解码成字节(%XX 按字节,其余按 UTF-8) */
|
|
431
|
+
export function pctBytes(s) {
|
|
432
|
+
const raw = Buffer.from(String(s), 'utf8')
|
|
433
|
+
const out = Buffer.alloc(raw.length)
|
|
434
|
+
let n = 0
|
|
435
|
+
const hex = (c) => (c >= 48 && c <= 57 ? c - 48 : c >= 65 && c <= 70 ? c - 55 : c >= 97 && c <= 102 ? c - 87 : -1)
|
|
436
|
+
for (let i = 0; i < raw.length; i++) {
|
|
437
|
+
if (raw[i] === 0x25 && i + 2 < raw.length && hex(raw[i + 1]) >= 0 && hex(raw[i + 2]) >= 0) { out[n++] = hex(raw[i + 1]) * 16 + hex(raw[i + 2]); i += 2 } else out[n++] = raw[i]
|
|
438
|
+
}
|
|
439
|
+
return out.subarray(0, n)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export const INLINE_MAX = 2000
|
|
443
|
+
|
|
444
|
+
/** 字节看着像 SVG(UTF-8,或 UTF-16 两种字节序) */
|
|
445
|
+
function looksSvg(buf) {
|
|
446
|
+
const head = buf.subarray(0, 8192)
|
|
447
|
+
const re = /<svg[\s>/]/i
|
|
448
|
+
if (re.test(head.toString('utf8')) || re.test(head.toString('utf16le'))) return true
|
|
449
|
+
const be = Buffer.from(head.subarray(0, head.length - (head.length % 2)))
|
|
450
|
+
return re.test(be.swap16().toString('utf16le'))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 一段(已解码的)值里的 data: 网址 → 往 st 里加
|
|
455
|
+
* exact:值本身就是一个地址(src / href / srcset 拆开的每一项、CSS 的 url(…) 和取地址函数里的字符串):照浏览器的读法解,
|
|
456
|
+
* 解出来是 SVG 但不是 UTF-8、写的是图片但认不出 → 记下来要拦
|
|
457
|
+
* loose:在一段字里搜(别的属性、注释、原文兜底):边界不确定,先取到第一个引号 / 括号 / 尖括号,不行再取到逗号后的第一个空白
|
|
458
|
+
* (srcset 的「1x」不算进去);都解不出图片 / SVG 的不算(不是浏览器会显示的东西),但看着是 SVG、又不是 UTF-8 的照样拦
|
|
459
|
+
* 同一份内容只记一次;超过 INLINE_MAX 份记 more(不能当查完了)
|
|
460
|
+
*/
|
|
461
|
+
function dataUrlsIn(v, at, st, mode = 'loose') {
|
|
462
|
+
const s = String(v).replace(/[\t\n\r]+/g, '')
|
|
463
|
+
const starts = mode === 'exact' ? (/^[\f ]*data:/i.test(s) ? [0] : []) : [...s.matchAll(/data:/gi)].map((m) => m.index)
|
|
464
|
+
for (const k of starts) {
|
|
465
|
+
let cands
|
|
466
|
+
if (mode === 'exact') cands = [s.slice(k, k + 16 * 1024 * 1024)]
|
|
467
|
+
else {
|
|
468
|
+
let rest = s.slice(k)
|
|
469
|
+
const e = rest.search(/["'()<>]|"|�*39;|'/i)
|
|
470
|
+
if (e > 0) rest = rest.slice(0, e)
|
|
471
|
+
cands = [rest]
|
|
472
|
+
const comma = rest.indexOf(',')
|
|
473
|
+
const ws = comma >= 0 ? rest.slice(comma).search(/[\f ]/) : -1
|
|
474
|
+
if (ws > 0) cands.push(rest.slice(0, comma + ws))
|
|
475
|
+
}
|
|
476
|
+
let pick = null
|
|
477
|
+
let fallback = null
|
|
478
|
+
for (const c of cands) {
|
|
479
|
+
const d = parseDataUrl(c)
|
|
480
|
+
if (!d || !d.buf.length) continue
|
|
481
|
+
const info = imageInfo(d.buf)
|
|
482
|
+
if (info) { pick = { d, item: { kind: 'image', type: info.type } }; break }
|
|
483
|
+
if (mode === 'exact' ? isSvgData(d) : looksSvg(d.buf)) {
|
|
484
|
+
const text = svgText(d.buf, d.params)
|
|
485
|
+
if (text !== null) { pick = { d, item: { kind: 'svg', text } }; break }
|
|
486
|
+
fallback = fallback || { d, item: { kind: 'svg', text: null } }
|
|
487
|
+
} else if (mode === 'exact' && /^image\//.test(d.mime)) fallback = fallback || { d, item: { kind: 'bad', mime: d.mime } }
|
|
488
|
+
}
|
|
489
|
+
pick = pick || fallback
|
|
490
|
+
if (!pick) continue
|
|
491
|
+
const key = crypto.createHash('sha1').update(pick.d.mime).update('\0').update(pick.d.buf).digest('hex')
|
|
492
|
+
if (st.seen.has(key)) {
|
|
493
|
+
// 同一份内容先被兜底搜到、后来又在确切地址里出现:按确切地址的规矩查(和出现的先后无关)
|
|
494
|
+
const was = st.seen.get(key)
|
|
495
|
+
if (was && was.loose && mode === 'exact') { was.loose = false; was.at = at }
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
if (st.seen.size >= INLINE_MAX) { st.more = true; return }
|
|
499
|
+
let rec = null
|
|
500
|
+
if (pick.item.kind === 'image') st.images.push({ type: pick.item.type, buf: pick.d.buf, at })
|
|
501
|
+
else if (pick.item.kind === 'svg') st.svgs.push((rec = { text: pick.item.text, at, loose: mode !== 'exact' }))
|
|
502
|
+
else st.bad.push({ mime: pick.item.mime, at }) // 写的是图片、内容认不出:藏着别的东西也查不了
|
|
503
|
+
st.seen.set(key, rec)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* 网页 / 矢量图里的 CSS:<style> 的内容、style="…" 属性(实体已解码)→ [{ text, at }](查注释、引号里的字用)。
|
|
509
|
+
* 只按默认读法:另一种读法会把 <svg> 里 <style/> 后面的东西都当样式,误报;手机号 / 身份证 / 邮箱另外整份文件查(personalAll)
|
|
510
|
+
*/
|
|
511
|
+
export function htmlCss(src) {
|
|
512
|
+
const out = []
|
|
513
|
+
for (const t of tokenize(String(src || ''))) {
|
|
514
|
+
if (t.type === 'text' && t.raw === 'style') out.push({ text: t.text, at: t.start })
|
|
515
|
+
else if (t.type === 'tag') for (const a of t.tag.attrs) if (a.name === 'style' && a.value) out.push({ text: a.value, at: a.vStart })
|
|
516
|
+
}
|
|
517
|
+
return out
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* 网页 / 样式 / 矢量图里内嵌的东西(任何 data: 网址)→ { images: [{ type, buf, at }], svgs: [{ text, at }], bad: [{ mime, at }], more }
|
|
522
|
+
* images:解出来的字节是位图(不看写的类型);分享、投稿前要查拍摄信息
|
|
523
|
+
* svgs:内嵌的 SVG(写成 base64 或百分号也一样),里面的字、<metadata>、本机路径也会跟着发出去,要和单独的 .svg 一样查;
|
|
524
|
+
* text 是 null:不是 UTF-8(查不准,要拦)
|
|
525
|
+
* bad:写的是图片、内容认不出(要拦)
|
|
526
|
+
* more:太多了没查完(要拦)
|
|
527
|
+
* 不管在哪(<svg><title> 里、注释里、别的标签的属性值里):每个「<字母」都当标签读(和 checkMarkup 第一步一样),
|
|
528
|
+
* 地址属性(src、href、srcset 拆开的每一项…)按一个确切的地址解;会当 CSS 读的属性(style、fill、mask… 带 url( 的)和
|
|
529
|
+
* 每个 <style> 到下一个 </style> 之间(再按实体还原一遍)照 CSS 规范分词还原转义;别的属性和原文再搜一遍(兜底)。都不封顶
|
|
530
|
+
*/
|
|
531
|
+
export function inlineData(text, kind = '') {
|
|
532
|
+
const src = String(text || '')
|
|
533
|
+
const st = { images: [], svgs: [], bad: [], more: false, seen: new Map(), work: 0 }
|
|
534
|
+
const hasData = (v) => /data:/i.test(String(v).replace(/[\t\n\r]+/g, ''))
|
|
535
|
+
const exactUrl = (v, at) => {
|
|
536
|
+
const t = String(v).replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '')
|
|
537
|
+
if (/^data:/i.test(t.replace(/[\t\n\r]+/g, ''))) dataUrlsIn(t, at, st, 'exact')
|
|
538
|
+
else if (hasData(t)) dataUrlsIn(t, at, st, 'loose')
|
|
539
|
+
}
|
|
540
|
+
// 一共最多按 CSS 读这么多字(一堆 <style/> 每个都读到下一个 </style> 会重复读):超了算没查完(要拦)
|
|
541
|
+
const budget = 8 * src.length + 65536
|
|
542
|
+
const fromCss = (css, at, toks = null) => {
|
|
543
|
+
st.work += css.length
|
|
544
|
+
if (st.work > budget) { st.more = true; return }
|
|
545
|
+
for (const x of cssValues(css, toks)) {
|
|
546
|
+
if (!hasData(x.v)) continue
|
|
547
|
+
if (x.url) exactUrl(x.v, at)
|
|
548
|
+
else dataUrlsIn(x.v, at, st, 'loose')
|
|
549
|
+
if (st.more) return
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const cssLike = (a) => a.name === 'style' || PRES_ATTRS.has(a.name) || /url\s*\(/i.test(a.value) || (a.value.includes('\\') && a.value.includes('('))
|
|
553
|
+
if (kind === 'html' || kind === 'svg') {
|
|
554
|
+
const decOf = (x) => decodeEntities(x.replace(/<!\[CDATA\[|\]\]>/g, '')).text
|
|
555
|
+
// 终点相同的一组 <style> 区段(一堆 <style/> 都读到同一个 </style>):原文、实体还原后各一份,每段从自己的起点往后按 CSS 分词。
|
|
556
|
+
// 读到以前某段在同一位置、同样括号状态下的记号起点就停——分词只往后看,后面读出来和那段一模一样(也不重复记工作量)
|
|
557
|
+
let g = null
|
|
558
|
+
// 括号栈:同样的栈拿同一个编号(编号 0 是空栈),每次进出只花一步,不用每个记号都抄一遍整栈
|
|
559
|
+
const ids = new Map()
|
|
560
|
+
const parent = [0]
|
|
561
|
+
const urlsOf = [0]
|
|
562
|
+
const push = (id, f) => {
|
|
563
|
+
const key = `${id}\u0000${f}`
|
|
564
|
+
let n = ids.get(key)
|
|
565
|
+
if (n === undefined) { n = parent.length; ids.set(key, n); parent.push(id); urlsOf.push(urlsOf[id] + (URL_FUNCS.has(f) ? 1 : 0)) }
|
|
566
|
+
return n
|
|
567
|
+
}
|
|
568
|
+
const cssScan = (text, from, seen, at) => {
|
|
569
|
+
let id = 0
|
|
570
|
+
let k = 0
|
|
571
|
+
let end = text.length
|
|
572
|
+
const take = (out) => {
|
|
573
|
+
for (; k < out.length; k++) {
|
|
574
|
+
const tk = out[k]
|
|
575
|
+
const urls = urlsOf[id]
|
|
576
|
+
if (tk.t === 'func') id = push(id, String(tk.v || '').toLowerCase())
|
|
577
|
+
else if (tk.t === '(') id = push(id, '')
|
|
578
|
+
else if (tk.t === ')') id = parent[id]
|
|
579
|
+
else if ((tk.t === 'str' || tk.t === 'bad-str' || tk.t === 'url' || tk.t === 'bad-url') && typeof tk.v === 'string' && hasData(tk.v)) {
|
|
580
|
+
if (tk.t === 'url' || tk.t === 'bad-url' || urls > 0) exactUrl(tk.v, at)
|
|
581
|
+
else dataUrlsIn(tk.v, at, st, 'loose')
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const out = cssTokens(text, {
|
|
586
|
+
from,
|
|
587
|
+
stop: (i, out) => {
|
|
588
|
+
take(out)
|
|
589
|
+
if (st.more) { end = i; return true }
|
|
590
|
+
if (st.work + (i - from) > budget) { st.more = true; end = i; return true }
|
|
591
|
+
// 括号里没有 url() 这类:状态都算一样(以后读到的字是不是地址只看新进去的括号);有的话整个括号栈要一样
|
|
592
|
+
const sig = urlsOf[id] ? id : 0
|
|
593
|
+
const had = seen.get(i)
|
|
594
|
+
if (had === sig || (had instanceof Set && had.has(sig))) { end = i; return true }
|
|
595
|
+
if (had === undefined) seen.set(i, sig)
|
|
596
|
+
else if (had instanceof Set) had.add(sig)
|
|
597
|
+
else seen.set(i, new Set([had, sig]))
|
|
598
|
+
return false
|
|
599
|
+
},
|
|
600
|
+
})
|
|
601
|
+
take(out)
|
|
602
|
+
st.work += end - from
|
|
603
|
+
}
|
|
604
|
+
for (const t of scanTags(src)) {
|
|
605
|
+
for (const a of t.attrs) {
|
|
606
|
+
if (!a.value) continue
|
|
607
|
+
if (cssLike(a)) fromCss(a.value, a.vStart)
|
|
608
|
+
if (a.name === 'style' || !hasData(a.value)) continue
|
|
609
|
+
if (a.name === 'srcset' || a.name === 'imagesrcset') for (const u of srcsetUrls(a.value)) exactUrl(u, a.vStart)
|
|
610
|
+
else if (URL_ATTRS.has(a.name)) exactUrl(a.value, a.vStart)
|
|
611
|
+
else dataUrlsIn(a.value, a.vStart, st, 'loose')
|
|
612
|
+
if (st.more) break
|
|
613
|
+
}
|
|
614
|
+
// <style/> 在网页里斜杠不算(照样是样式),一样查;矢量图(XML)里就是空的。到下一个真正的 </style 为止(CDATA 里的不算)
|
|
615
|
+
if (t.name === 'style' && !t.eof && !st.more && !(kind === 'svg' && t.selfClosing)) {
|
|
616
|
+
const s0 = t.close
|
|
617
|
+
const e0 = rawEnd(src, 'style', s0, true)
|
|
618
|
+
// 新的一组:终点变了;起点没往后走(标签按 < 的位置排,不按 > 的位置:前面一个假标签的 > 可能在后面);
|
|
619
|
+
// 或者起点前面是「]]>」(去掉 CDATA 标记后 > 也没了,实体还原后的位置不能接着算)
|
|
620
|
+
if (!g || g.e !== e0 || s0 <= g.at || src.startsWith(']]>', s0 - 3)) {
|
|
621
|
+
const raw = src.slice(s0, e0)
|
|
622
|
+
const dec = decOf(raw)
|
|
623
|
+
st.work += raw.length
|
|
624
|
+
g = { e: e0, base: s0, raw, dec: dec === raw ? null : dec, rawSeen: new Map(), decSeen: new Map(), at: s0, decLen: 0 }
|
|
625
|
+
} else {
|
|
626
|
+
// 实体还原后这段从哪起:一小截一小截往后加(起点都在某个标签的 > 后面,实体、CDATA 标记跨不过去)
|
|
627
|
+
if (g.dec !== null) {
|
|
628
|
+
const part = src.slice(g.at, s0)
|
|
629
|
+
st.work += part.length
|
|
630
|
+
g.decLen += decOf(part).length
|
|
631
|
+
}
|
|
632
|
+
g.at = s0
|
|
633
|
+
}
|
|
634
|
+
if (st.work > budget) st.more = true
|
|
635
|
+
else {
|
|
636
|
+
cssScan(g.raw, s0 - g.base, g.rawSeen, s0)
|
|
637
|
+
if (g.dec !== null && !st.more) cssScan(g.dec, g.decLen, g.decSeen, s0)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (st.more) break
|
|
641
|
+
}
|
|
642
|
+
} else if (kind === 'css') fromCss(src, 0)
|
|
643
|
+
if (!st.more) dataUrlsIn(src, 0, st, 'loose')
|
|
644
|
+
return { images: st.images, svgs: st.svgs, bad: st.bad, more: st.more }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** 兼容:只要内嵌的位图 */
|
|
648
|
+
export function inlineImages(text, kind = '') {
|
|
649
|
+
return inlineData(text, kind).images
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── 文字 ──────────────────────────────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
/** 本机路径(/Users/名字/、/home/名字/、C:\Users\名字、file:///):带着就把电脑用户名带出去了 */
|
|
655
|
+
const LOCAL_PATH = /(?<![\w:/.%-])\/(?:Users|home)\/[^\s"'<>/\\]{1,64}\/|(?<![\w])[A-Za-z]:(?:\\{1,2}|\/)(?:Users|Documents and Settings)(?:\\{1,2}|\/)[^\s"'<>/\\]{1,64}|file:\/\/\//i
|
|
656
|
+
export function localPathIn(text) {
|
|
657
|
+
const m = LOCAL_PATH.exec(String(text || ''))
|
|
658
|
+
return m ? { n: m[0], at: m.index } : null
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function asText(data) {
|
|
662
|
+
if (typeof data === 'string') return { text: data.replace(/^/, '') }
|
|
663
|
+
try {
|
|
664
|
+
return { text: new TextDecoder('utf-8', { fatal: true }).decode(data).replace(/^/, '') }
|
|
665
|
+
} catch {
|
|
666
|
+
return { text: null }
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** CSS 转义还原(整段粗还原,查个人信息用):\ + 换行去掉,\31 这种十六进制,\x 就是 x */
|
|
671
|
+
function cssUnescape(v) {
|
|
672
|
+
return String(v).replace(/\\\r?\n/g, '').replace(/\\([0-9a-f]{1,6})[ \t\n\r\f]?/gi, (_, h) => { const c = parseInt(h, 16); return c > 0 && c <= 0x10ffff && !(c >= 0xd800 && c <= 0xdfff) ? String.fromCodePoint(c) : '' }).replace(/\\([\s\S])/g, '$1')
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** 数字很多的属性(路径、坐标、变换):整份文件查个人信息时先空掉,免得坐标凑出像手机号的数 */
|
|
676
|
+
const NUMERIC_ATTRS = new Set(['d', 'points', 'viewbox', 'transform', 'gradienttransform', 'patterntransform', 'values', 'keytimes', 'keysplines', 'keypoints', 'coords', 'path', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'fx', 'fy', 'dx', 'dy', 'width', 'height', 'offset', 'stroke-dasharray', 'kernelmatrix', 'tablevalues', 'rotate', 'stddeviation', 'basefrequency'])
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* 整份文件里的手机号 / 身份证 / 邮箱:不管浏览器显不显示(<svg>、<math> 里怎么切换读法都一样),都会跟着包发出去。
|
|
680
|
+
* 按原文、实体还原、CSS 转义还原各查一遍;数字很多的属性值先空掉 → [{ code, n }]
|
|
681
|
+
*/
|
|
682
|
+
export function personalAll(src) {
|
|
683
|
+
const s = String(src || '')
|
|
684
|
+
let t = s
|
|
685
|
+
if (/<[a-z]/i.test(s)) {
|
|
686
|
+
const parts = []
|
|
687
|
+
let last = 0
|
|
688
|
+
const spans = []
|
|
689
|
+
for (const tag of scanTags(s)) for (const a of tag.attrs) if (NUMERIC_ATTRS.has(a.name) && a.vEnd > a.vStart) spans.push([a.vStart, a.vEnd])
|
|
690
|
+
spans.sort((a, b) => a[0] - b[0])
|
|
691
|
+
for (const [a, b] of spans) { if (a < last) continue; parts.push(s.slice(last, a), ' '); last = b }
|
|
692
|
+
parts.push(s.slice(last))
|
|
693
|
+
t = parts.join('')
|
|
694
|
+
}
|
|
695
|
+
const out = []
|
|
696
|
+
const seen = new Map()
|
|
697
|
+
const dec = decodeEntities(t).text
|
|
698
|
+
for (const v of new Set([t, dec, cssUnescape(dec)])) {
|
|
699
|
+
for (const p of textFlags(v, { urls: false, throughTags: true })) {
|
|
700
|
+
if (p.code !== 'phone' && p.code !== 'idcard' && p.code !== 'email') continue
|
|
701
|
+
const k = `${p.code}|${p.n}`
|
|
702
|
+
const was = seen.get(k)
|
|
703
|
+
if (!was) { const x = { code: p.code, n: p.n, ...(p.maybe ? { maybe: true } : {}) }; seen.set(k, x); out.push(x) } else if (!p.maybe) delete was.maybe
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return out
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const PSEP = '[\\s\\u200b-\\u200d\\u2060\\-\\u2010-\\u2015\\u2043\\u2212\\u2e3a\\u2e3b\\u30fc\\ufe58\\ufe63\\uff0d\\uff65\\u00b7\\u30fb]{0,3}'
|
|
710
|
+
// 不相干的几段字拼起来查时用的隔开符(不算号码里的分隔:不同地方的 139 / 1234 / 7788 不拼成手机号)
|
|
711
|
+
const PART_SEP = '\n\u0001\n'
|
|
712
|
+
// 号码本身(前后边界另外判断,见 numbersIn)
|
|
713
|
+
const PHONE_CORE = new RegExp(`(?:\\+?86${PSEP})?1[3-9]\\d${PSEP}\\d{4}${PSEP}\\d{4}`, 'g')
|
|
714
|
+
const IDCARD_CORE = /\d{17}[\dXx]/g
|
|
715
|
+
// Markdown:号码中间夹的加粗 / 斜体记号(139**1234**7788)页面上看不见,也当分隔
|
|
716
|
+
const PHONE_CORE_MD = new RegExp(PHONE_CORE.source.replaceAll('\\u00b7', '\\u00b7*_'), 'g')
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 在 joined 里找号码(re 是不带边界的号码正则):前后紧挨字母数字、* + 的不算(类名、哈希、更长的数);
|
|
720
|
+
* 前面紧挨的「.」:前面是字母(Tel.、No.)照样算,前面是数字、标点或开头(0.139…、:.139…)是小数,不算;后面「.数字」也是小数。
|
|
721
|
+
* soft:看不见的分界的位置(行内标签、相邻 CSS 字符串那里)——号码中间的分界本来就去掉了(139<mark>123</mark>47788 照样连着),
|
|
722
|
+
* 号码紧挨字母但中间有分界(<span>Tel</span><span>139…</span>)不算紧挨;紧挨数字、小数点照样不算号码(0.<b>139</b>12347788)
|
|
723
|
+
*/
|
|
724
|
+
const MAYBE = 2
|
|
725
|
+
// 个人信息类的提醒(排前面、不受条数限制)
|
|
726
|
+
export const PERSONAL_CODES = new Set(['phone', 'idcard', 'email', 'localPath', 'address'])
|
|
727
|
+
function numbersIn(joined, soft, re, throughTags = false, md = false) {
|
|
728
|
+
const out = []
|
|
729
|
+
const letter = /\p{L}/u
|
|
730
|
+
const TAG_END = /<\/?[A-Za-z][^<>]*>$/
|
|
731
|
+
const TAG_START = /^<\/?[A-Za-z][^<>]*>/
|
|
732
|
+
// throughTags(整份原文扫的时候):紧挨着的是标签,就越过标签看页面上真正挨着的字——
|
|
733
|
+
// 0.<b>13912347788</b> 在页面上是小数,不算(看得见的字另外按页面上的样子查过)
|
|
734
|
+
const prevAt = (i) => {
|
|
735
|
+
if (!throughTags) return i
|
|
736
|
+
for (let g = 0; g < 64 && joined[i - 1] === '>'; g++) {
|
|
737
|
+
const k = joined.lastIndexOf('<', i - 1)
|
|
738
|
+
if (k < 0 || !TAG_END.test(joined.slice(k, i))) break
|
|
739
|
+
i = k
|
|
740
|
+
}
|
|
741
|
+
return i
|
|
742
|
+
}
|
|
743
|
+
const nextAt = (j) => {
|
|
744
|
+
if (!throughTags) return j
|
|
745
|
+
for (let g = 0; g < 64 && joined[j] === '<'; g++) {
|
|
746
|
+
const k = joined.indexOf('>', j)
|
|
747
|
+
if (k < 0 || !TAG_START.test(joined.slice(j, k + 1))) break
|
|
748
|
+
j = k + 1
|
|
749
|
+
}
|
|
750
|
+
return j
|
|
751
|
+
}
|
|
752
|
+
// md(Markdown):紧挨的一串 * / _ 越过去看外面的字——外面是数字(接着别的数)、或 _ 外面是英文字母数字(order_139…_v2 这种标识符)才不算
|
|
753
|
+
const ASCII_AN = /[A-Za-z0-9]/
|
|
754
|
+
const mdBefore = (i) => {
|
|
755
|
+
let k = i
|
|
756
|
+
while (k > 0 && (joined[k - 1] === '*' || joined[k - 1] === '_')) k--
|
|
757
|
+
if (k === i) return null
|
|
758
|
+
const c = joined[k - 1]
|
|
759
|
+
if (c === undefined) return true
|
|
760
|
+
if (/[0-9]/.test(c)) return false
|
|
761
|
+
if (joined.slice(k, i).includes('_') && ASCII_AN.test(c)) return false
|
|
762
|
+
if (c === '.') return k >= 2 && letter.test(joined[k - 2])
|
|
763
|
+
return true
|
|
764
|
+
}
|
|
765
|
+
const mdAfter = (j) => {
|
|
766
|
+
let k = j
|
|
767
|
+
while (k < joined.length && (joined[k] === '*' || joined[k] === '_')) k++
|
|
768
|
+
if (k === j) return null
|
|
769
|
+
const c = joined[k]
|
|
770
|
+
if (c === undefined) return true
|
|
771
|
+
if (/[0-9]/.test(c)) return false
|
|
772
|
+
if (joined.slice(j, k).includes('_') && ASCII_AN.test(c)) return false
|
|
773
|
+
if (c === '.') return !/[0-9]/.test(joined[k + 1] || '')
|
|
774
|
+
return true
|
|
775
|
+
}
|
|
776
|
+
const before = (i0) => {
|
|
777
|
+
if (md) { const r = mdBefore(i0); if (r !== null) return r }
|
|
778
|
+
const i = prevAt(i0)
|
|
779
|
+
if (i === 0) return true
|
|
780
|
+
const b = joined[i - 1]
|
|
781
|
+
if (b === '.') return i >= 2 && letter.test(joined[i - 2])
|
|
782
|
+
if (i !== i0) return !/[0-9]/.test(b) // 越过了标签:只看是不是接着前面的数
|
|
783
|
+
// 紧挨数字:中间有分界只算「拿不准」(两栏并排的 <b>139</b>12347788 / <b>186</b>23456791 是两个号码;
|
|
784
|
+
// 订单号 20261107<mark>139</mark>12347788 是一个长编号)——提醒,不硬拦
|
|
785
|
+
if (/[0-9]/.test(b)) return soft.has(i) ? MAYBE : false
|
|
786
|
+
if (/[*+]/.test(b)) return false
|
|
787
|
+
return soft.has(i) || !/\w/.test(b)
|
|
788
|
+
}
|
|
789
|
+
const after = (j0) => {
|
|
790
|
+
if (md) { const r = mdAfter(j0); if (r !== null) return r }
|
|
791
|
+
const j = nextAt(j0)
|
|
792
|
+
const c = joined[j]
|
|
793
|
+
if (c === undefined) return true
|
|
794
|
+
if (c === '.') return !/[0-9]/.test(joined[j + 1] || '')
|
|
795
|
+
if (j !== j0) return !/[0-9]/.test(c)
|
|
796
|
+
if (/[0-9]/.test(c)) return soft.has(j) ? MAYBE : false
|
|
797
|
+
if (c === '*') return false
|
|
798
|
+
return soft.has(j) || !/\w/.test(c)
|
|
799
|
+
}
|
|
800
|
+
re.lastIndex = 0
|
|
801
|
+
let m
|
|
802
|
+
while ((m = re.exec(joined))) {
|
|
803
|
+
const e = m.index + m[0].length
|
|
804
|
+
const a = before(m.index)
|
|
805
|
+
const b = a && after(e)
|
|
806
|
+
if (b) { if (a === MAYBE || b === MAYBE) m.maybe = true; out.push(m); re.lastIndex = e } else re.lastIndex = m.index + 1
|
|
807
|
+
}
|
|
808
|
+
return out
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** 邮箱:从每个 @ 往两边找(不用整段正则回溯,长编码串里也是线性的);域名一段一段读,遇到「..」这类就停在前面 → [{ n, at }] */
|
|
812
|
+
// 打码用的记号
|
|
813
|
+
const MASK = /[*•●∗⁎◦]/
|
|
814
|
+
const PUNCT = /[\p{P}\p{S}]/u
|
|
815
|
+
// s[m..] 这串记号是不是前面加粗 / 斜体的收尾(**Email**zhang@…、**邮箱**zhang@…):前面不是空白,
|
|
816
|
+
// 同一行往前 300 字里,一模一样、单出来的几串从左往右配对(能开头的开、能收尾的收),最后还有一串开着没收,
|
|
817
|
+
// 而且从那里到这儿没有 @。已经收过的(**Email**zh**san@ 里 Email 后面那串)不会再被当成开头;
|
|
818
|
+
// 前面没有开着的、后面紧接着邮箱前半段的(zh**san@、**an@ 里那串,是打码)不当开头
|
|
819
|
+
// 只往前看 300 字:长行里很多个也不慢
|
|
820
|
+
const MASKED_LOCAL = /[\w.+-]{0,64}@/y
|
|
821
|
+
// s[p..q] 这串星号是不是打码(不当加粗的开头):
|
|
822
|
+
// 后面(跳过紧挨着的别的打码记号)接着邮箱前半段再到 @(zh**san@、**an@、**•••89@);
|
|
823
|
+
// 但邮箱后面又跟着一模一样的一串(**wang@qq.com**)是对称包着的加粗,不算打码。
|
|
824
|
+
// 邮箱域名里的(zhang@q**q.com、li@qq.c**om:前面一直是域名的字到 @,后面接着域名的字)也是打码;
|
|
825
|
+
// 但完整邮箱后面紧跟的加粗标签(li@qq.com**Email**zhang@…:后面那几个字又接着星号)不算
|
|
826
|
+
function maskRun(s, p, q, cand) {
|
|
827
|
+
let r = q
|
|
828
|
+
while (r < s.length && r - q < 64 && MASK.test(s[r])) r++
|
|
829
|
+
MASKED_LOCAL.lastIndex = r
|
|
830
|
+
if (MASKED_LOCAL.test(s)) {
|
|
831
|
+
let d = MASKED_LOCAL.lastIndex
|
|
832
|
+
const at = d - 1
|
|
833
|
+
while (d < s.length && d - at <= 255 && /[\w.-]/.test(s[d])) d++
|
|
834
|
+
if (!s.startsWith(cand, d) || s[d + cand.length] === '*') return true // **•••89@qq.com** 这种对称包着的不算
|
|
835
|
+
}
|
|
836
|
+
let k2 = p
|
|
837
|
+
while (k2 > 0 && p - k2 < 255 && /[\w.-]/.test(s[k2 - 1])) k2--
|
|
838
|
+
if (k2 < p && s[k2 - 1] === '@') {
|
|
839
|
+
const tail = /^[\w-]+/.exec(s.slice(q, q + 64))
|
|
840
|
+
// 前面的域名已经完整(.字母字母 结尾)、后面是三个字以上的词再接空格(li@x.com**Email Address**…):是多词标签的开头,不是打码
|
|
841
|
+
const label = tail && tail[0].length >= 3 && /[ \t]/.test(s[q + tail[0].length] ?? '') && /\.[a-z]{2,}$/i.test(s.slice(k2, p))
|
|
842
|
+
if (tail && s[q + tail[0].length] !== '*' && !label) return true
|
|
843
|
+
}
|
|
844
|
+
return false
|
|
845
|
+
}
|
|
846
|
+
function closesEmphasis(s, m, run) {
|
|
847
|
+
if (m === 0 || /\s/.test(s[m - 1]) || !/^\*+$/.test(run)) return false // 加粗 / 斜体只认星号(• 这些不是)
|
|
848
|
+
let from = Math.max(0, m - 300)
|
|
849
|
+
let w = s.slice(from, m) // 只在这一小段里找(整串往回找,长行里会很慢)
|
|
850
|
+
const nl = w.lastIndexOf('\n')
|
|
851
|
+
if (nl >= 0) { from += nl + 1; w = w.slice(nl + 1) }
|
|
852
|
+
const blank = (c) => c === undefined || /\s/.test(c)
|
|
853
|
+
const punct = (c) => c !== undefined && PUNCT.test(c)
|
|
854
|
+
let open = -1
|
|
855
|
+
let odd = -1
|
|
856
|
+
let openLen = 0
|
|
857
|
+
// 小段里每一整串星号(**•••89@ 里的 ** 照算);比这串短的(单星斜体)管不着,一样长或更长的照算
|
|
858
|
+
// (**Email Address***note* 里那三颗:两颗收加粗、剩一颗开斜体)
|
|
859
|
+
const stars = /\*+/g
|
|
860
|
+
const shorter = [] // 比这串短、还开着没收的(*a**b***li@ 里的 * 和 **):最后还剩着就配不准
|
|
861
|
+
for (let x; (x = stars.exec(w)); ) {
|
|
862
|
+
let p = from + x.index
|
|
863
|
+
const q = p + x[0].length
|
|
864
|
+
while (p > 0 && s[p - 1] === '*') p-- // 小段开头切在一串星号中间:往前补齐
|
|
865
|
+
if (q > m) continue
|
|
866
|
+
const cand = s.slice(p, q)
|
|
867
|
+
const prev = s[p - 1]
|
|
868
|
+
const next = s[q]
|
|
869
|
+
const canOpen = !blank(next) && (!punct(next) || blank(prev) || punct(prev))
|
|
870
|
+
const canClose = !blank(prev) && (!punct(prev) || blank(next) || punct(next))
|
|
871
|
+
if (cand.length < run.length) {
|
|
872
|
+
if (canClose && shorter.length && shorter[shorter.length - 1] === cand.length) shorter.pop() // 和主串一样:先当收尾
|
|
873
|
+
else if (canOpen && !maskRun(s, p, q, cand)) shorter.push(cand.length)
|
|
874
|
+
continue
|
|
875
|
+
}
|
|
876
|
+
if (open >= 0 && canClose) { // 前面有开着的:先当收尾(**Email**li@… 里 Email 后面那串)
|
|
877
|
+
if (cand.length !== openLen) odd = q // 长短不一样的一对(**Email Address***、**A*****)会剩几颗再开新的,配不准:宁可提醒
|
|
878
|
+
open = -1
|
|
879
|
+
continue
|
|
880
|
+
}
|
|
881
|
+
if (maskRun(s, p, q, cand)) continue
|
|
882
|
+
if (canOpen) { open = q; openLen = cand.length }
|
|
883
|
+
}
|
|
884
|
+
if (odd >= 0 || shorter.length) return true // 配不准之后剩下的星号会一直影响后面(中间隔着别的邮箱也一样):这一行后面的都提醒
|
|
885
|
+
return open >= 0 && !s.slice(open, m).includes('@')
|
|
886
|
+
}
|
|
887
|
+
function emailsIn(s) {
|
|
888
|
+
const out = []
|
|
889
|
+
const local = /[\w.+-]/
|
|
890
|
+
const lab = /[\w-]/
|
|
891
|
+
for (let i = s.indexOf('@'); i >= 0; i = s.indexOf('@', i + 1)) {
|
|
892
|
+
let a = i
|
|
893
|
+
while (a > 0 && i - a < 64 && local.test(s[a - 1])) a--
|
|
894
|
+
// 前半段前面是打码记号(zh***san@、12****89@、•••89@):是打了码的邮箱,不是 san@ / 89@。
|
|
895
|
+
// 只有一个记号、前面又不是邮箱的字(•zhang@…、* zhang@… 这种项目符号)不算打码
|
|
896
|
+
// 前后对称包着的(**zhang@qq.com**)是装饰,不是打码
|
|
897
|
+
// 这串记号是前面加粗的收尾(**Email**zhang@…):邮箱是完整露出来的,但拿不准 → 提醒
|
|
898
|
+
let pre = ''
|
|
899
|
+
let closes = false
|
|
900
|
+
if (a > 0 && MASK.test(s[a - 1])) {
|
|
901
|
+
let m = a - 1
|
|
902
|
+
while (m > 0 && m > a - 64 && MASK.test(s[m - 1])) m--
|
|
903
|
+
closes = closesEmphasis(s, m, s.slice(m, a))
|
|
904
|
+
if (!closes) {
|
|
905
|
+
if (m > 0 && local.test(s[m - 1])) continue
|
|
906
|
+
if (a - m >= 2) pre = s.slice(m, a)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
while (a < i && s[a] === '.') a++
|
|
910
|
+
if (a >= i) continue
|
|
911
|
+
let b = i + 1
|
|
912
|
+
const label = () => { const st = b; while (b < s.length && b - i <= 255 && lab.test(s[b])) b++; return b > st }
|
|
913
|
+
if (!label()) continue
|
|
914
|
+
let dots = 0
|
|
915
|
+
while (b < s.length && b - i <= 255 && s[b] === '.' && lab.test(s[b + 1] || '')) { b++; label(); dots++ }
|
|
916
|
+
// 域名里打了码(zhang@qq.c**、zhang@q*q.com):后面连着两个以上记号,或者记号后面还接着域名的字;只有一个记号(尾注 zhang@qq.com*)不算
|
|
917
|
+
let maybe = closes
|
|
918
|
+
if (pre && !s.startsWith(pre, b)) continue
|
|
919
|
+
if (!pre && MASK.test(s[b] || '')) {
|
|
920
|
+
let m = b
|
|
921
|
+
while (m < s.length && MASK.test(s[m])) m++
|
|
922
|
+
if (/[\w.-]/.test(s[m] || '')) {
|
|
923
|
+
// 记号后面还接着字:一般是域名里打了码(zhang@q**q.com、li@qq.c**om);
|
|
924
|
+
// 但前面的域名已经完整(qq.com)、后面那几个字又不接着点、像个标签(**Email**、**Email:**)→ 是紧跟着的加粗标签,邮箱完整 → 提醒
|
|
925
|
+
const tail = /^[\w-]*/.exec(s.slice(m, m + 64))[0]
|
|
926
|
+
const after = s[m + tail.length]
|
|
927
|
+
if (/\.[A-Za-z]{2,}$/.test(s.slice(i + 1, b)) && after !== '.' && (MASK.test(after || '') || tail.length >= 3)) maybe = true
|
|
928
|
+
else continue
|
|
929
|
+
}
|
|
930
|
+
// 后面连着两个以上记号:域名看着完整(最后一段两个字母以上,zhang@qq.com**)可能是尾注,拿不准 → 提醒;不完整(zhang@qq.c**)是打码
|
|
931
|
+
if (m - b >= 2) { if (/\.[A-Za-z]{2,}$/.test(s.slice(i + 1, b))) maybe = true; else continue }
|
|
932
|
+
}
|
|
933
|
+
const loc = s.slice(a, i).replace(/^_+/, '')
|
|
934
|
+
const dom = s.slice(i + 1, b).replace(/[-_]+$/, '')
|
|
935
|
+
if (dots && loc) out.push({ n: `${loc}@${dom}`, at: a, ...(maybe ? { maybe: true } : {}) })
|
|
936
|
+
}
|
|
937
|
+
return out
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** 身份证号校验位对不对、生日像不像 */
|
|
941
|
+
function idOk(s) {
|
|
942
|
+
if (!/^[1-8]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(s)) return false
|
|
943
|
+
const w = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
|
944
|
+
let sum = 0
|
|
945
|
+
for (let i = 0; i < 17; i++) sum += Number(s[i]) * w[i]
|
|
946
|
+
return '10X98765432'[sum % 11] === s[17].toUpperCase()
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** 明显是假的手机号:后八位全一样、连号,或常见的示例号 */
|
|
950
|
+
function fakePhone(d) {
|
|
951
|
+
const tail = d.slice(3)
|
|
952
|
+
if (/^(\d)\1{7}$/.test(tail)) return true
|
|
953
|
+
if ('0123456789012'.includes(tail) || '9876543210987'.includes(tail)) return true
|
|
954
|
+
return d === '13800138000' || d === '13912345678' || /^1\d{2}0000\d{4}$/.test(d)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* 一段文字里的个人信息和可疑字。返回 [{ code, n, at }](at:在 text 里的位置)
|
|
959
|
+
* phone idcard email(个人邮箱) bizEmail address phish brand inject url
|
|
960
|
+
*/
|
|
961
|
+
export function textFlags(text, { urls = true, throughTags = false, md = false } = {}) {
|
|
962
|
+
const out = []
|
|
963
|
+
const s = String(text)
|
|
964
|
+
// 手机号 / 身份证 / 邮箱:全角数字、全角横线、全角 @ 这些先换成半角再找(一个字换一个字,位置不变)。
|
|
965
|
+
// 不用 NFKC:上标、圈码会被拼进前后的数字(订单号 1391234778¹ 不是手机号)
|
|
966
|
+
// 手机号:3-4-4,中间可以夹空格(含不换行 / 全角 / 零宽)、各种横线;前面可带 +86。前后边界见 numbersIn
|
|
967
|
+
const ns = s.replace(/[\uff01-\uff5e\ufe6b]/g, (c) => (c === '\ufe6b' ? '@' : String.fromCharCode(c.charCodeAt(0) - 0xfee0)))
|
|
968
|
+
// 看不见的分界(\u200b:行内标签、相邻的 CSS 字符串那里)先去掉、记下位置:号码、邮箱在页面上是连着的
|
|
969
|
+
const pieces = ns.split('\u200b')
|
|
970
|
+
const soft = new Set()
|
|
971
|
+
for (let k = 0, pos = 0; k < pieces.length - 1; k++) { pos += pieces[k].length; soft.add(pos) }
|
|
972
|
+
const joined = pieces.length > 1 ? pieces.join('') : ns
|
|
973
|
+
for (const m of numbersIn(joined, soft, md ? PHONE_CORE_MD : PHONE_CORE, throughTags, md)) {
|
|
974
|
+
let d = m[0].replace(/\D/g, '')
|
|
975
|
+
if (d.length === 13 && d.startsWith('86')) d = d.slice(2)
|
|
976
|
+
if (d.length === 11 && !fakePhone(d)) out.push({ code: 'phone', n: m[0], at: m.index, ...(m.maybe ? { maybe: true } : {}) })
|
|
977
|
+
}
|
|
978
|
+
for (const m of numbersIn(joined, soft, IDCARD_CORE, throughTags, md)) if (idOk(m[0])) out.push({ code: 'idcard', n: m[0], at: m.index, ...(m.maybe ? { maybe: true } : {}) })
|
|
979
|
+
// Markdown:号码中间随便哪段加粗 / 斜体(139**123**47788、110105**19491231**002X),页面上是连着的。
|
|
980
|
+
// 把夹在两个数字中间的 * / _ 去掉再查一遍;前后的边界照旧(2026**139…** 前面接着数字,还是不算)
|
|
981
|
+
if (md && /\d[*_]+\d/.test(joined)) {
|
|
982
|
+
const keep = []
|
|
983
|
+
const digit = (c) => c >= '0' && c <= '9'
|
|
984
|
+
for (let k = 0; k < joined.length; k++) {
|
|
985
|
+
if ((joined[k] === '*' || joined[k] === '_') && k > 0 && digit(joined[k - 1])) {
|
|
986
|
+
let e = k
|
|
987
|
+
while (e < joined.length && (joined[e] === '*' || joined[e] === '_')) e++
|
|
988
|
+
if (digit(joined[e])) { k = e - 1; continue }
|
|
989
|
+
}
|
|
990
|
+
keep.push(k)
|
|
991
|
+
}
|
|
992
|
+
const dj = keep.map((k) => joined[k]).join('')
|
|
993
|
+
const back = (i) => (i < keep.length ? keep[i] : joined.length)
|
|
994
|
+
const dsoft = new Set()
|
|
995
|
+
for (let i = 0, k = 0; i <= keep.length; i++) { const at = back(i); while (k <= at) { if (soft.has(k)) dsoft.add(i); k++ } }
|
|
996
|
+
const had = new Set(out.filter((p) => p.code === 'phone' || p.code === 'idcard').map((p) => p.n.replace(/[^\dXx]/g, '').slice(-11)))
|
|
997
|
+
for (const m of numbersIn(dj, dsoft, PHONE_CORE, throughTags, md)) {
|
|
998
|
+
let d = m[0].replace(/\D/g, '')
|
|
999
|
+
if (d.length === 13 && d.startsWith('86')) d = d.slice(2)
|
|
1000
|
+
if (d.length === 11 && !fakePhone(d) && !had.has(d)) out.push({ code: 'phone', n: m[0], at: back(m.index), ...(m.maybe ? { maybe: true } : {}) })
|
|
1001
|
+
}
|
|
1002
|
+
for (const m of numbersIn(dj, dsoft, IDCARD_CORE, throughTags, md)) if (idOk(m[0]) && !had.has(m[0].slice(-11))) out.push({ code: 'idcard', n: m[0], at: back(m.index), ...(m.maybe ? { maybe: true } : {}) })
|
|
1003
|
+
}
|
|
1004
|
+
for (const m of emailsIn(joined)) {
|
|
1005
|
+
if (FAKE_MAIL.test(m.n)) continue
|
|
1006
|
+
out.push({ code: PERSONAL_MAIL.test(m.n) ? 'email' : 'bizEmail', n: m.n, at: m.at, ...(m.maybe ? { maybe: true } : {}) })
|
|
1007
|
+
}
|
|
1008
|
+
for (const m of s.matchAll(/[一-鿿]{2,30}(省|市|区|县)[一-鿿]{0,12}(路|街|道|巷|弄|大道)\d{1,5}号(\d{1,4}(室|栋|幢|单元|楼))?/g)) out.push({ code: 'address', n: m[0], at: m.index })
|
|
1009
|
+
const ph = PHISH.exec(s)
|
|
1010
|
+
if (ph) out.push({ code: 'phish', n: ph[0], at: ph.index })
|
|
1011
|
+
const br = BRANDS.exec(s)
|
|
1012
|
+
if (br) out.push({ code: 'brand', n: br[0], at: br.index })
|
|
1013
|
+
const inj = INJECT.exec(s)
|
|
1014
|
+
if (inj) out.push({ code: 'inject', n: cut(inj[0], 30), at: inj.index })
|
|
1015
|
+
if (urls) {
|
|
1016
|
+
const u = URLISH.exec(s)
|
|
1017
|
+
if (u) out.push({ code: 'url', n: cut(u[0], 40), at: u.index })
|
|
1018
|
+
}
|
|
1019
|
+
return out
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ── HTML ──────────────────────────────────────────────────────────────────────
|
|
1023
|
+
|
|
1024
|
+
/** 查 CSS 文字(样式表 / <style> / style=""),问题写进 add;base:这段 CSS 在文件里的起点(算行号) */
|
|
1025
|
+
function checkCssText(css, { file, src, base, dir, files, add }) {
|
|
1026
|
+
const { refs, problems } = scanCss(css)
|
|
1027
|
+
const line = (at) => lineAt(src, base + at)
|
|
1028
|
+
for (const p of problems) {
|
|
1029
|
+
const code = p.code === 'import' ? 'cssImport' : p.code === 'func' ? 'cssFunc' : p.code === 'prop' ? 'cssProp' : 'cssBad'
|
|
1030
|
+
add({ code, x: file, line: line(p.at), n: p.detail || '' })
|
|
1031
|
+
}
|
|
1032
|
+
for (const r of refs) {
|
|
1033
|
+
const bad = checkRef(r.url, { dir, files, ctx: r.kind === 'font' ? 'font' : 'css' })
|
|
1034
|
+
if (bad) add({ code: bad.code === 'scheme' ? 'scheme' : bad.code, x: file, line: line(r.at), n: bad.code === 'scheme' ? 'url()' : bad.ref })
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** 文件所在目录(包里的相对写法) */
|
|
1039
|
+
const dirOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* 查一个 HTML(或 SVG)文件的危险写法。
|
|
1043
|
+
* @param opts.files 包里所有文件(小写路径的 Set);不给就不查「引用的文件在不在」
|
|
1044
|
+
* @param opts.svg 是独立的 SVG 文件
|
|
1045
|
+
* @returns [{ code, x, line, n }]
|
|
1046
|
+
*/
|
|
1047
|
+
export function checkMarkup(src, { file = 'index.html', files = null, svg = false } = {}) {
|
|
1048
|
+
const out = []
|
|
1049
|
+
const seen = new Set()
|
|
1050
|
+
const add = (p) => {
|
|
1051
|
+
const k = `${p.code}|${p.line}|${p.n}`
|
|
1052
|
+
if (seen.has(k) || out.length >= 200) return
|
|
1053
|
+
seen.add(k)
|
|
1054
|
+
out.push(p)
|
|
1055
|
+
}
|
|
1056
|
+
const dir = dirOf(file)
|
|
1057
|
+
const lineOf = (i) => lineAt(src, i)
|
|
1058
|
+
if (svg) {
|
|
1059
|
+
if (!/<svg[\s>]/i.test(src)) add({ code: 'notSvg', x: file, line: 1 })
|
|
1060
|
+
if (/<!doctype|<!entity|<!element|<!attlist/i.test(src) || /<\?(?!xml\s)/i.test(src) || (src.match(/<\?xml\s/gi) || []).length > 1) add({ code: 'svgDoctype', x: file, line: 1 })
|
|
1061
|
+
// 声明了别的编码:浏览器按那个编码读,和我们查的对不上
|
|
1062
|
+
const enc = /^\s*<\?xml[^>]*?\bencoding\s*=\s*["']([^"']*)["']/i.exec(src)
|
|
1063
|
+
if (enc && !/^\s*utf-?8\s*$/i.test(enc[1])) add({ code: 'charset', x: file, line: 1 })
|
|
1064
|
+
}
|
|
1065
|
+
// 1) 每个「<字母」都当标签查(藏在注释、样式、属性值里的也算)
|
|
1066
|
+
for (const t of scanTags(src)) {
|
|
1067
|
+
const line = lineOf(t.start)
|
|
1068
|
+
if (t.eof && t.name) add({ code: 'eofTag', x: file, line, n: t.name })
|
|
1069
|
+
if (BAD_TAGS.has(t.name)) add({ code: 'tag', x: file, line, n: t.name })
|
|
1070
|
+
if (t.name === 'meta' && t.attrs.some((a) => a.name === 'http-equiv')) add({ code: 'httpEquiv', x: file, line })
|
|
1071
|
+
if (t.name === 'meta') {
|
|
1072
|
+
const cs = t.attrs.find((a) => a.name === 'charset')
|
|
1073
|
+
if (cs && !/^\s*utf-?8\s*$/i.test(cs.value)) add({ code: 'charset', x: file, line })
|
|
1074
|
+
}
|
|
1075
|
+
if (t.name === 'link') {
|
|
1076
|
+
const rel = (t.attrs.find((a) => a.name === 'rel') || { value: '' }).value.toLowerCase().split(/\s+/).filter(Boolean)
|
|
1077
|
+
const bad = rel.find((r) => !LINK_RELS.has(r))
|
|
1078
|
+
if (bad || !rel.length) add({ code: 'linkRel', x: file, line, n: bad || '' })
|
|
1079
|
+
}
|
|
1080
|
+
if (SVG_ANIM.has(t.name)) {
|
|
1081
|
+
const target = (t.attrs.find((a) => a.name === 'attributename') || { value: '' }).value.trim().toLowerCase()
|
|
1082
|
+
if (/href$/.test(target) || target === 'src' || target === 'style' || target.startsWith('on')) add({ code: 'animHref', x: file, line })
|
|
1083
|
+
}
|
|
1084
|
+
const isNav = NAV_TAGS.has(t.name)
|
|
1085
|
+
for (const a of t.attrs) {
|
|
1086
|
+
const n = a.name
|
|
1087
|
+
if (/^on/.test(n.replace(/^[^a-z]+/, ''))) { add({ code: 'handler', x: file, line, n }); continue }
|
|
1088
|
+
if (n === 'srcdoc') { add({ code: 'srcdoc', x: file, line }); continue }
|
|
1089
|
+
if (n === 'http-equiv') continue
|
|
1090
|
+
const val = a.value
|
|
1091
|
+
const squash = val.replace(/[\x00-\x20]/g, '')
|
|
1092
|
+
if (BAD_SCHEME.test(squash) && !(DATA_SVG.test(squash) && !isNav && n !== 'href' && n !== 'xlink:href' && n !== 'data-href')) { add({ code: 'scheme', x: file, line, n }); continue }
|
|
1093
|
+
// 任何属性值里出现 javascript: 都不收(SVG 动画的 values / to 也会被当地址用)
|
|
1094
|
+
if (/(javascript|vbscript|livescript):/i.test(squash)) { add({ code: 'scheme', x: file, line, n }); continue }
|
|
1095
|
+
if (t.end) continue
|
|
1096
|
+
if (n === 'style' || PRES_ATTRS.has(n) || /url\s*\(/i.test(val) || (val.includes('\\') && val.includes('('))) {
|
|
1097
|
+
if (a.unknown.length) add({ code: 'badEscape', x: file, line, n })
|
|
1098
|
+
checkCssText(val, { file, src, base: a.vStart, dir, files, add: (p) => add({ ...p, line }) })
|
|
1099
|
+
}
|
|
1100
|
+
if (URL_ATTRS.has(n)) {
|
|
1101
|
+
if (a.unknown.length) { add({ code: 'badEscape', x: file, line, n }); continue }
|
|
1102
|
+
if (n === 'action' || n === 'formaction') { if (val.trim()) add({ code: 'form', x: file, line, n }); continue }
|
|
1103
|
+
const urls = n === 'srcset' || n === 'imagesrcset' ? srcsetUrls(val) : [val]
|
|
1104
|
+
const ctx = (isNav && (n === 'href' || n === 'xlink:href')) || n === 'data-href' ? 'nav' : 'res'
|
|
1105
|
+
for (const u of urls) {
|
|
1106
|
+
// <use href="#x"> 这种片段在 checkRef 里放行
|
|
1107
|
+
const bad = checkRef(u, { dir, files, ctx })
|
|
1108
|
+
if (!bad) continue
|
|
1109
|
+
// 链接到包里没有的页面:原型没连完整,不算危险(体检会报「跳到不存在的页面」)
|
|
1110
|
+
if (bad.code === 'missing' && ctx === 'nav') continue
|
|
1111
|
+
add({ code: bad.code, x: file, line, n: bad.code === 'scheme' ? n : bad.ref })
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (n === 'ping') add({ code: 'ping', x: file, line })
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
// 2) 照规范分词:<style> 里的 CSS(原文和还原转义后的都查:<svg> 里的 <style> 会还原转义)、注释。
|
|
1118
|
+
// <style> 按网页读、认外来内容读各走一遍(浏览器在 <svg> / <math> 里会跳回网页读),重复的 add 会去掉;
|
|
1119
|
+
// 注释认外来内容读就够了(它只把 <style> 当纯文字,看到的注释最多)
|
|
1120
|
+
// 第二遍(按网页读)只在网页里走(矢量图是 XML,不会这么读),也不报「写坏的字符串」(那段多半根本不是样式)
|
|
1121
|
+
const second = svg ? [] : tokenize(src, { foreign: false }).filter((t) => t.type === 'text' && t.raw === 'style').map((t) => ({ ...t, second: true }))
|
|
1122
|
+
for (const tk of [...tokenize(src), ...second]) {
|
|
1123
|
+
if (tk.type === 'text' && tk.raw === 'style') {
|
|
1124
|
+
const base = tk.start
|
|
1125
|
+
const addCss = tk.second ? (p) => { if (p.code !== 'cssBad') add(p) } : add
|
|
1126
|
+
checkCssText(tk.text, { file, src, base, dir, files, add: addCss })
|
|
1127
|
+
const dec = decodeEntities(tk.text.replace(/<!\[CDATA\[|\]\]>/g, '')).text
|
|
1128
|
+
if (dec !== tk.text) checkCssText(dec, { file, src, base, dir, files, add: (p) => addCss({ ...p, line: lineOf(base) }) })
|
|
1129
|
+
} else if (tk.type === 'comment') add({ code: 'comment', x: file, line: lineOf(tk.start) })
|
|
1130
|
+
else if (tk.type === 'bogus' && !svg && /^<!/.test(tk.text) && !/^<!doctype/i.test(tk.text)) add({ code: 'comment', x: file, line: lineOf(tk.start) })
|
|
1131
|
+
}
|
|
1132
|
+
return out
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* 能自动修的先修:删注释、站外链接改成 #、表单的提交地址 / 方法 / ping 删掉。
|
|
1137
|
+
* 返回 { text, changes: [{ code, x, line, n }] }(修了什么,列给作者看)
|
|
1138
|
+
*/
|
|
1139
|
+
export function normalizeHtml(src, { file = 'index.html' } = {}) {
|
|
1140
|
+
const edits = [] // [start, end, replacement]
|
|
1141
|
+
const changes = []
|
|
1142
|
+
for (const tk of tokenize(src)) {
|
|
1143
|
+
if (tk.type === 'comment' || (tk.type === 'bogus' && /^<!(?!doctype)/i.test(tk.text))) {
|
|
1144
|
+
edits.push([tk.start, tk.end, ''])
|
|
1145
|
+
changes.push({ code: 'comment', x: file, line: lineAt(src, tk.start) })
|
|
1146
|
+
continue
|
|
1147
|
+
}
|
|
1148
|
+
if (tk.type !== 'tag') continue
|
|
1149
|
+
const t = tk.tag
|
|
1150
|
+
for (const a of t.attrs) {
|
|
1151
|
+
if (DROP_ATTRS.has(a.name)) {
|
|
1152
|
+
edits.push([a.start, a.end, ''])
|
|
1153
|
+
changes.push({ code: a.name === 'ping' ? 'ping' : 'form', x: file, line: lineAt(src, a.start), n: a.name })
|
|
1154
|
+
continue
|
|
1155
|
+
}
|
|
1156
|
+
if ((NAV_TAGS.has(t.name) && (a.name === 'href' || a.name === 'xlink:href')) || a.name === 'data-href') {
|
|
1157
|
+
const bad = checkRef(a.value, { ctx: 'nav' })
|
|
1158
|
+
if (bad && (bad.code === 'externalLink' || bad.code === 'absolute')) {
|
|
1159
|
+
edits.push([a.vStart, a.vEnd, '#'])
|
|
1160
|
+
changes.push({ code: 'externalLink', x: file, line: lineAt(src, a.start), n: bad.ref })
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
if (!edits.length) return { text: src, changes }
|
|
1166
|
+
edits.sort((a, b) => b[0] - a[0])
|
|
1167
|
+
let text = src
|
|
1168
|
+
let last = Infinity
|
|
1169
|
+
for (const [s, e, r] of edits) {
|
|
1170
|
+
if (e > last) continue // 重叠的跳过
|
|
1171
|
+
text = text.slice(0, s) + r + text.slice(e)
|
|
1172
|
+
last = s
|
|
1173
|
+
}
|
|
1174
|
+
return { text, changes }
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** <meta name="dsh-design"> 的类型(和 look.js parseMeta 一样) */
|
|
1178
|
+
export function metaKind(src) {
|
|
1179
|
+
for (const t of scanTags(src.slice(0, 65536))) {
|
|
1180
|
+
if (t.name !== 'meta') continue
|
|
1181
|
+
const name = t.attrs.find((a) => a.name === 'name')
|
|
1182
|
+
if (!name || name.value.trim().toLowerCase() !== 'dsh-design') continue
|
|
1183
|
+
const content = (t.attrs.find((a) => a.name === 'content') || { value: '' }).value
|
|
1184
|
+
for (const w of content.toLowerCase().split(/[\s,;]+/)) {
|
|
1185
|
+
if (KINDS.includes(w)) return w
|
|
1186
|
+
if (META_ALIAS[w]) return META_ALIAS[w]
|
|
1187
|
+
}
|
|
1188
|
+
return ''
|
|
1189
|
+
}
|
|
1190
|
+
return null
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** <title> 的文字 */
|
|
1194
|
+
export function titleOf(src) {
|
|
1195
|
+
for (const tk of tokenize(src)) if (tk.type === 'text' && tk.raw === 'title') return decodeEntities(tk.text).text.trim()
|
|
1196
|
+
return ''
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// ── 清单 ──────────────────────────────────────────────────────────────────────
|
|
1200
|
+
|
|
1201
|
+
// shareHash:分享记号(只在作者电脑上)的 sha256 前 32 位;目录里带着它,插件就认得出「这套是我分享的,已上架」
|
|
1202
|
+
const MANIFEST_KEYS = new Set(['format', 'type', 'title', 'description', 'kind', 'size', 'os', 'screens', 'style', 'categories', 'tags', 'lang', 'dark', 'license', 'assets', 'author', 'ai', 'derivedFrom', 'shareHash'])
|
|
1203
|
+
const LOCAL_KEYS = new Set(['from', 'shareToken']) // 只在用户电脑上的 design.json 里(从市场来的、分享过的记号)
|
|
1204
|
+
const ID_RE = /^[a-z0-9-]{1,39}__[a-z0-9-]{3,40}$/
|
|
1205
|
+
const isObj = (x) => !!x && typeof x === 'object' && !Array.isArray(x)
|
|
1206
|
+
const len = (s) => [...String(s)].length
|
|
1207
|
+
|
|
1208
|
+
function textField(v, max, maxEn = max * 2) {
|
|
1209
|
+
if (!isObj(v)) return '要写成 {"zh": "…", "en": "…"}'
|
|
1210
|
+
const keys = Object.keys(v)
|
|
1211
|
+
if (keys.some((k) => k !== 'zh' && k !== 'en')) return '只能有 zh / en'
|
|
1212
|
+
if (!keys.length || keys.every((k) => typeof v[k] !== 'string' || !v[k].trim())) return '至少写一种语言'
|
|
1213
|
+
for (const k of keys) {
|
|
1214
|
+
const s = v[k]
|
|
1215
|
+
if (typeof s !== 'string') return `${k} 要是文字`
|
|
1216
|
+
if (/[\x00-\x1f\x7f\u2028\u2029]/.test(s)) return `${k} 只能写一行`
|
|
1217
|
+
if (len(s) > (k === 'en' ? maxEn : max)) return `${k} 太长(最多 ${k === 'en' ? maxEn : max} 字)`
|
|
1218
|
+
if (URLISH.test(s)) return `${k} 里不能有网址`
|
|
1219
|
+
}
|
|
1220
|
+
return ''
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* 查清单。opts.mode:'ci'(机器人:严格,未知字段拒收、要有作者)、'share'(分享前:作者由机器人补)、'install'(落地前:只查要用到的)
|
|
1225
|
+
* opts.vocab:{ categories: [], tags: [] } 受控词表;不给就只查写法
|
|
1226
|
+
*/
|
|
1227
|
+
export function checkManifest(m, { mode = 'install', vocab = null } = {}) {
|
|
1228
|
+
const out = []
|
|
1229
|
+
const bad = (x, n) => out.push({ code: 'field', x, n })
|
|
1230
|
+
if (!isObj(m)) { bad('(整个文件)', '要是一个 JSON 对象'); return out }
|
|
1231
|
+
for (const k of Object.keys(m)) {
|
|
1232
|
+
if (MANIFEST_KEYS.has(k)) continue
|
|
1233
|
+
if (LOCAL_KEYS.has(k) && mode !== 'ci') continue
|
|
1234
|
+
if (mode === 'ci' || mode === 'share') out.push({ code: 'unknownField', x: k })
|
|
1235
|
+
}
|
|
1236
|
+
if (m.format !== FORMAT) bad('format', `要写 ${FORMAT}`)
|
|
1237
|
+
if (!TYPES.includes(m.type)) bad('type', `只能是 ${TYPES.join(' / ')}`)
|
|
1238
|
+
let e = textField(m.title, 24, 40)
|
|
1239
|
+
if (e) bad('title', e)
|
|
1240
|
+
if (m.description !== undefined || mode !== 'install') {
|
|
1241
|
+
e = textField(m.description, 120, 200)
|
|
1242
|
+
if (e) bad('description', e)
|
|
1243
|
+
}
|
|
1244
|
+
if (!LICENSES.includes(m.license)) bad('license', `只能是 ${LICENSES.join(' / ')}`)
|
|
1245
|
+
if (m.type === 'template') {
|
|
1246
|
+
if (!KINDS.includes(m.kind)) bad('kind', `只能是 ${KINDS.join(' / ')}`)
|
|
1247
|
+
if (m.size !== undefined && !/^\d{3,4}x\d{3,4}$/.test(String(m.size))) bad('size', '写成 1080x1920 这样')
|
|
1248
|
+
if (m.os !== undefined && !['mac', 'windows'].includes(m.os)) bad('os', '只能是 mac / windows')
|
|
1249
|
+
if (!Array.isArray(m.screens) || !m.screens.length) bad('screens', '至少一屏')
|
|
1250
|
+
else if (m.screens.length > LIMITS.screens) bad('screens', `最多 ${LIMITS.screens} 屏`)
|
|
1251
|
+
else {
|
|
1252
|
+
const seen = new Set()
|
|
1253
|
+
m.screens.forEach((s, i) => {
|
|
1254
|
+
if (!isObj(s) || typeof s.file !== 'string') return bad(`screens[${i}]`, '要写成 {"file": "home.html", "title": {…}}')
|
|
1255
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,62}\.html$/.test(s.file)) bad(`screens[${i}].file`, '最外层的 .html 文件,用英文字母、数字、. _ -,不能以 _ 开头')
|
|
1256
|
+
if (seen.has(s.file.toLowerCase())) bad(`screens[${i}].file`, '重复了')
|
|
1257
|
+
seen.add(s.file.toLowerCase())
|
|
1258
|
+
if (s.title !== undefined) { const te = textField(s.title, 24, 40); if (te) bad(`screens[${i}].title`, te) }
|
|
1259
|
+
if (Object.keys(s).some((k) => k !== 'file' && k !== 'title')) bad(`screens[${i}]`, '只能有 file / title')
|
|
1260
|
+
})
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if (m.style !== undefined && !STYLE_IDS.includes(m.style) && m.style !== 'custom') bad('style', '20 个风格 id 之一,或 custom')
|
|
1264
|
+
for (const [k, max] of [['categories', 2], ['tags', 8]]) {
|
|
1265
|
+
if (m[k] === undefined) continue
|
|
1266
|
+
if (!Array.isArray(m[k]) || m[k].length > max || m[k].some((x) => typeof x !== 'string' || !/^[a-z0-9-]{2,24}$/.test(x))) bad(k, `最多 ${max} 个,从词表里选`)
|
|
1267
|
+
else if (vocab && Array.isArray(vocab[k]) && m[k].some((x) => !vocab[k].includes(x))) bad(k, `不在词表里:${m[k].filter((x) => !vocab[k].includes(x)).join('、')}`)
|
|
1268
|
+
}
|
|
1269
|
+
if (m.lang !== undefined && !['zh', 'en', 'both'].includes(m.lang)) bad('lang', '只能是 zh / en / both')
|
|
1270
|
+
if (m.dark !== undefined && typeof m.dark !== 'boolean') bad('dark', '要是 true / false')
|
|
1271
|
+
if (m.assets !== undefined) {
|
|
1272
|
+
if (!Array.isArray(m.assets) || m.assets.length > LIMITS.files) bad('assets', '要是列表')
|
|
1273
|
+
else m.assets.forEach((a, i) => {
|
|
1274
|
+
if (!isObj(a) || typeof a.path !== 'string' || !cleanPath(a.path)) return bad(`assets[${i}]`, '要有 path(包里的图片)')
|
|
1275
|
+
if (!['self', 'cc0', 'pd'].includes(a.source)) bad(`assets[${i}].source`, 'self(自己做的)/ cc0 / pd(公有领域)')
|
|
1276
|
+
if (a.source !== 'self' && (typeof a.url !== 'string' || !/^https:\/\/[^\s]{4,300}$/.test(a.url))) bad(`assets[${i}].url`, '写明来源网址(https://…)')
|
|
1277
|
+
if (Object.keys(a).some((k) => !['path', 'source', 'url', 'license'].includes(k))) bad(`assets[${i}]`, '只能有 path / source / url / license')
|
|
1278
|
+
})
|
|
1279
|
+
}
|
|
1280
|
+
if (m.author !== undefined || mode === 'ci') {
|
|
1281
|
+
const a = m.author
|
|
1282
|
+
// 朋友之间传的 zip(不是市场):署名可以只写名字;市场的必须是 GitHub 账号(机器人从 PR 填)
|
|
1283
|
+
const nameOnly = mode !== 'ci' && isObj(a) && a.login === undefined && a.id === undefined
|
|
1284
|
+
if (nameOnly) {
|
|
1285
|
+
if (typeof a.name !== 'string' || !a.name.trim() || len(a.name) > 40 || /[\x00-\x1f]/.test(a.name) || URLISH.test(a.name)) bad('author.name', '最多 40 字,不能有网址')
|
|
1286
|
+
else if (Object.keys(a).some((k) => k !== 'name')) bad('author', '只能有 login / id / name')
|
|
1287
|
+
} else if (!isObj(a) || typeof a.login !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(a.login) || !Number.isInteger(a.id) || a.id <= 0) bad('author', '要写成 {"login": GitHub 用户名, "id": 数字 id}')
|
|
1288
|
+
else if (a.name !== undefined && (typeof a.name !== 'string' || len(a.name) > 40 || /[\x00-\x1f]/.test(a.name) || URLISH.test(a.name))) bad('author.name', '最多 40 字,不能有网址')
|
|
1289
|
+
else if (Object.keys(a).some((k) => !['login', 'id', 'name'].includes(k))) bad('author', '只能有 login / id / name')
|
|
1290
|
+
}
|
|
1291
|
+
if (!isObj(m.ai) || typeof m.ai.generated !== 'boolean' || (m.ai.tool !== undefined && (typeof m.ai.tool !== 'string' || !/^[\w@./ -]{1,60}$/.test(m.ai.tool)))) bad('ai', '要写成 {"generated": true, "tool": "dsh-design-studio@0.11.0"}')
|
|
1292
|
+
if (m.derivedFrom !== undefined && (typeof m.derivedFrom !== 'string' || !/^[a-z0-9-]{1,39}__[a-z0-9-]{3,40}@[0-9a-f]{7,40}$/.test(m.derivedFrom))) bad('derivedFrom', '写成 作者__名字@版本')
|
|
1293
|
+
if (m.shareHash !== undefined && (typeof m.shareHash !== 'string' || !/^[0-9a-f]{32}$/.test(m.shareHash))) bad('shareHash', '32 位小写十六进制')
|
|
1294
|
+
return out
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/** 风格(style.json):只收白名单字段,颜色只能 #rrggbb,字体只能是几种系统字体栈 */
|
|
1298
|
+
export function checkStyle(s) {
|
|
1299
|
+
const out = []
|
|
1300
|
+
const bad = (x, n) => out.push({ code: 'style', x, n })
|
|
1301
|
+
if (!isObj(s)) { bad('(整个文件)', '要是一个 JSON 对象'); return out }
|
|
1302
|
+
const KEYS = ['c', 'head', 'w', 'radius', 'shadow', 'nzh', 'nen', 'dzh', 'den', 'bgImage']
|
|
1303
|
+
for (const k of Object.keys(s)) if (!KEYS.includes(k)) bad(k, '不认识的字段')
|
|
1304
|
+
const C = ['bg', 'surface', 'text', 'muted', 'line', 'primary', 'accent', 'on', 'link']
|
|
1305
|
+
if (!isObj(s.c)) bad('c', '要写配色')
|
|
1306
|
+
else {
|
|
1307
|
+
for (const k of C) if (typeof s.c[k] !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(s.c[k])) bad(`c.${k}`, '写成 #rrggbb')
|
|
1308
|
+
for (const k of Object.keys(s.c)) if (!C.includes(k)) bad(`c.${k}`, '不认识的颜色')
|
|
1309
|
+
if (!out.length) {
|
|
1310
|
+
for (const [a, b] of [['text', 'bg'], ['muted', 'bg'], ['text', 'surface'], ['on', 'primary']]) {
|
|
1311
|
+
const r = hexRatio(s.c[a], s.c[b])
|
|
1312
|
+
if (r < 4.5) out.push({ code: 'contrast', x: `${a} / ${b}`, n: r.toFixed(2) })
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
if (!HEADS.includes(s.head)) bad('head', HEADS.join(' / '))
|
|
1317
|
+
if (!Number.isInteger(s.w) || s.w < 100 || s.w > 900 || s.w % 100) bad('w', '100–900 的整百')
|
|
1318
|
+
if (typeof s.radius !== 'number' || s.radius < 0 || s.radius > 48) bad('radius', '0–48')
|
|
1319
|
+
if (typeof s.shadow !== 'string' || len(s.shadow) > 120 || !/^(none|[-0-9a-z.,()#%\s]+)$/i.test(s.shadow) || /url|image|var|expression/i.test(s.shadow)) bad('shadow', 'none 或 box-shadow 写法(只能有数字、px、rgba()、#颜色)')
|
|
1320
|
+
for (const k of ['dzh', 'den']) if (s[k] !== undefined && (typeof s[k] !== 'string' || len(s[k]) > (k === 'den' ? 80 : 40) || /[\x00-\x1f]/.test(s[k]))) bad(k, '一行,最多 40 字')
|
|
1321
|
+
for (const k of ['nzh', 'nen']) {
|
|
1322
|
+
if (s[k] === undefined) continue
|
|
1323
|
+
const items = typeof s[k] === 'string' ? s[k].split(/[;;]/).map((x) => x.trim()).filter(Boolean) : null
|
|
1324
|
+
if (!items || items.length > 4 || items.some((x) => len(x) > (k === 'nen' ? 100 : 40)) || /[\x00-\x1f]/.test(s[k])) bad(k, '要点最多 4 条(用;分开),每条最多 40 字')
|
|
1325
|
+
}
|
|
1326
|
+
if (s.bgImage !== undefined && (typeof s.bgImage !== 'string' || len(s.bgImage) > 300 || !/^(repeating-)?(linear|radial|conic)-gradient\(/i.test(s.bgImage) || !/^[-a-z0-9#(),.%\s]+$/i.test(s.bgImage) || /url|image\(|var|expression|attr|env/i.test(s.bgImage.replace(/-gradient\(/gi, '(')))) bad('bgImage', '只能写渐变(linear-gradient(…)),不能有 url()')
|
|
1327
|
+
for (const k of ['nzh', 'nen', 'dzh', 'den']) {
|
|
1328
|
+
if (typeof s[k] !== 'string') continue
|
|
1329
|
+
const f = textFlags(s[k]).find((x) => x.code === 'inject' || x.code === 'url')
|
|
1330
|
+
if (f) bad(k, `不能有指令或网址(${f.n})`)
|
|
1331
|
+
}
|
|
1332
|
+
return out
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** 两个 #rrggbb 的对比度(WCAG,和 client.js hexRatio 一样) */
|
|
1336
|
+
export function hexRatio(a, b) {
|
|
1337
|
+
const l = (hex) => { const v = hex.slice(1).match(/../g).map((x) => parseInt(x, 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2] }
|
|
1338
|
+
const [x, y] = [l(a), l(b)].sort((p, q) => q - p)
|
|
1339
|
+
return (x + 0.05) / (y + 0.05)
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ── 整套 ──────────────────────────────────────────────────────────────────────
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* 查一整套。
|
|
1346
|
+
* @param files [{ path, data }]:data 是 Uint8Array 或字符串
|
|
1347
|
+
* @param opts.mode 'ci' | 'share' | 'install' | 'zip'
|
|
1348
|
+
* ci:机器人(个人信息只标出来给人看);share:分享前(个人信息硬拦);zip:打 zip 给别人前(个人信息也硬拦);
|
|
1349
|
+
* install:落地前(市场来的、拖进来的 zip,个人信息只标出来)
|
|
1350
|
+
* @param opts.vocab 受控词表
|
|
1351
|
+
* @returns { ok, errors, flags, manifest, screens, bytes, kind }
|
|
1352
|
+
*/
|
|
1353
|
+
export function checkPack(files, { mode = 'install', vocab = null } = {}) {
|
|
1354
|
+
const errors = []
|
|
1355
|
+
const flags = []
|
|
1356
|
+
const err = (p) => { if (errors.length < 300 && !errors.some((e) => e.code === p.code && e.x === p.x && e.line === p.line && e.n === p.n)) errors.push({ ...p, sev: 'error' }) }
|
|
1357
|
+
// 提醒最多 100 条;个人信息的提醒不受这个限制(最后排在最前面,界面截短也先看到)
|
|
1358
|
+
const flag = (p) => { if ((flags.length < 100 || PERSONAL_CODES.has(p.code)) && !flags.some((f) => f.code === p.code && f.x === p.x && f.n === p.n)) flags.push({ ...p, sev: 'flag' }) }
|
|
1359
|
+
const list = []
|
|
1360
|
+
const lower = new Set()
|
|
1361
|
+
let bytes = 0
|
|
1362
|
+
for (const f of Array.isArray(files) ? files : []) {
|
|
1363
|
+
const p = cleanPath(f && f.path)
|
|
1364
|
+
if (!p) { err({ code: 'path', x: cut(String((f && f.path) || ''), 80) }); continue }
|
|
1365
|
+
const key = p.toLowerCase()
|
|
1366
|
+
if (lower.has(key)) { err({ code: 'dup', x: p }); continue }
|
|
1367
|
+
lower.add(key)
|
|
1368
|
+
const ext = p.includes('.') ? p.slice(p.lastIndexOf('.') + 1).toLowerCase() : ''
|
|
1369
|
+
const kind = EXT[ext]
|
|
1370
|
+
if (!kind) { err({ code: 'ext', x: p }); continue }
|
|
1371
|
+
const size = typeof f.data === 'string' ? new TextEncoder().encode(f.data).length : (f.data && f.data.length) || 0
|
|
1372
|
+
bytes += size
|
|
1373
|
+
list.push({ path: p, ext, kind, data: f.data, size })
|
|
1374
|
+
}
|
|
1375
|
+
if (list.length > LIMITS.files) err({ code: 'count', n: list.length, max: LIMITS.files })
|
|
1376
|
+
if (bytes > LIMITS.total) err({ code: 'total', n: kb(bytes), max: kb(LIMITS.total) })
|
|
1377
|
+
|
|
1378
|
+
const texts = new Map()
|
|
1379
|
+
for (const f of list) {
|
|
1380
|
+
if (f.kind === 'image') {
|
|
1381
|
+
const info = typeof f.data === 'string' ? null : imageInfo(f.data)
|
|
1382
|
+
if (!info || (info.type !== f.ext && !(info.type === 'jpg' && f.ext === 'jpeg'))) { err({ code: 'magic', x: f.path }); continue }
|
|
1383
|
+
if (f.size > LIMITS.image) err({ code: 'big', x: f.path, n: kb(f.size), max: kb(LIMITS.image) })
|
|
1384
|
+
if (Math.max(info.w, info.h) > LIMITS.side) err({ code: 'side', x: f.path, n: `${info.w}×${info.h}`, max: LIMITS.side })
|
|
1385
|
+
// 要公开的(分享、打给别人、投稿):拍摄信息、XMP 不能带
|
|
1386
|
+
if ((mode === 'share' || mode === 'zip' || mode === 'ci') && imageMeta(f.data, info.type)) err({ code: 'imgMeta', x: f.path })
|
|
1387
|
+
continue
|
|
1388
|
+
}
|
|
1389
|
+
const max = LIMITS[f.kind] || LIMITS.html
|
|
1390
|
+
if (f.size > max) { err({ code: 'big', x: f.path, n: kb(f.size), max: kb(max) }); continue }
|
|
1391
|
+
const { text } = asText(f.data)
|
|
1392
|
+
if (text === null) { err({ code: 'utf8', x: f.path }); continue }
|
|
1393
|
+
if (text.includes('\0')) { err({ code: 'nul', x: f.path }); continue }
|
|
1394
|
+
texts.set(f.path, text)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// 清单
|
|
1398
|
+
let manifest = null
|
|
1399
|
+
const mtext = texts.get('design.json')
|
|
1400
|
+
if (mtext === undefined) {
|
|
1401
|
+
if (!list.some((f) => f.path === 'design.json')) err({ code: 'noManifest' })
|
|
1402
|
+
} else {
|
|
1403
|
+
try {
|
|
1404
|
+
manifest = JSON.parse(mtext)
|
|
1405
|
+
for (const p of checkManifest(manifest, { mode: mode === 'zip' ? 'install' : mode, vocab })) err(p)
|
|
1406
|
+
} catch (e) {
|
|
1407
|
+
err({ code: 'json', x: 'design.json', n: cut(e.message, 80) })
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
const type = manifest && TYPES.includes(manifest.type) ? manifest.type : 'template'
|
|
1411
|
+
const screens = manifest && Array.isArray(manifest.screens) ? manifest.screens.filter((s) => s && typeof s.file === 'string').map((s) => s.file) : []
|
|
1412
|
+
const screenSet = new Set(screens.map((s) => s.toLowerCase()))
|
|
1413
|
+
const kind = manifest && KINDS.includes(manifest.kind) ? manifest.kind : ''
|
|
1414
|
+
|
|
1415
|
+
if (type === 'template') {
|
|
1416
|
+
for (const s of screens) if (!lower.has(s.toLowerCase())) err({ code: 'screenMissing', x: s })
|
|
1417
|
+
}
|
|
1418
|
+
if (type === 'system') {
|
|
1419
|
+
for (const need of ['system.json', 'tokens.css', 'components.css', 'components.html', 'DESIGN.md']) if (!list.some((f) => f.path === need)) err({ code: 'system', x: need })
|
|
1420
|
+
// source/ 是设计台在本机放网站截图、提取素材的地方,装的时候不装:要用的图放 assets/
|
|
1421
|
+
const src = list.find((f) => /^source\//i.test(f.path))
|
|
1422
|
+
if (src) err({ code: 'systemSource', x: src.path })
|
|
1423
|
+
}
|
|
1424
|
+
if (type === 'style') {
|
|
1425
|
+
const st = texts.get('style.json')
|
|
1426
|
+
if (st === undefined) err({ code: 'style', x: 'style.json', n: '缺这个文件' })
|
|
1427
|
+
else {
|
|
1428
|
+
try { for (const p of checkStyle(JSON.parse(st))) err(p) } catch (e) { err({ code: 'json', x: 'style.json', n: cut(e.message, 80) }) }
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const personalHard = mode === 'share' || mode === 'zip' // 分享前、打 zip 给别人前:个人信息直接拦(发出去就收不回)
|
|
1433
|
+
for (const f of list) {
|
|
1434
|
+
const text = texts.get(f.path)
|
|
1435
|
+
if (text === undefined) continue
|
|
1436
|
+
if (f.kind === 'html') {
|
|
1437
|
+
const top = !f.path.includes('/')
|
|
1438
|
+
if (!top) err({ code: 'subHtml', x: f.path })
|
|
1439
|
+
else if (type === 'template' && !screenSet.has(f.path.toLowerCase())) err({ code: 'screenUnlisted', x: f.path })
|
|
1440
|
+
for (const p of checkMarkup(text, { file: f.path, files: lower })) {
|
|
1441
|
+
// 注释、表单地址、站外链接在分享 / 投稿时会自动修;落地前(市场来的已经修过)照样算错
|
|
1442
|
+
err(p)
|
|
1443
|
+
}
|
|
1444
|
+
if (type === 'template' && screenSet.has(f.path.toLowerCase())) {
|
|
1445
|
+
const mk = metaKind(text)
|
|
1446
|
+
if (!mk || (kind && mk !== kind)) err({ code: 'meta', x: f.path, n: kind || 'web' })
|
|
1447
|
+
const title = titleOf(text)
|
|
1448
|
+
if (!/\S\s*·\s*\S/.test(title)) err({ code: 'titleFmt', x: f.path })
|
|
1449
|
+
}
|
|
1450
|
+
if (/<input\b[^>]*\btype\s*=\s*["']?password/i.test(text)) flag({ code: 'password', x: f.path })
|
|
1451
|
+
scanText(visibleText(text), text, f.path)
|
|
1452
|
+
scanCss(text, f.path)
|
|
1453
|
+
} else if (f.kind === 'svg') {
|
|
1454
|
+
for (const p of checkMarkup(text, { file: f.path, files: lower, svg: true })) err(p)
|
|
1455
|
+
scanText(visibleText(text), text, f.path)
|
|
1456
|
+
scanCss(text, f.path)
|
|
1457
|
+
} else if (f.kind === 'css') {
|
|
1458
|
+
checkCssText(text, { file: f.path, src: text, base: 0, dir: dirOf(f.path), files: lower, add: err })
|
|
1459
|
+
const cs = /^@charset\s+"([^"]*)"/i.exec(text)
|
|
1460
|
+
if (cs && !/^utf-?8$/i.test(cs[1].trim())) err({ code: 'charset', x: f.path, line: 1 })
|
|
1461
|
+
// 注释和引号里的字(content: "…")都会跟着包发出去
|
|
1462
|
+
const notes = [...text.matchAll(/\/\*[\s\S]*?\*\//g)].map((m) => m[0]).concat(cssStrings(text)).join(PART_SEP)
|
|
1463
|
+
if (notes) scanText(notes, text, f.path)
|
|
1464
|
+
} else if (f.kind === 'json') {
|
|
1465
|
+
if (f.path !== 'design.json') {
|
|
1466
|
+
try { JSON.parse(text) } catch (e) { err({ code: 'json', x: f.path, n: cut(e.message, 80) }) }
|
|
1467
|
+
scanText(text, text, f.path)
|
|
1468
|
+
}
|
|
1469
|
+
} else if (f.kind === 'md') {
|
|
1470
|
+
// Markdown:号码前后紧挨的 * / _(加粗、斜体,不管配没配上,号码在页面上都看得见)不挡;邮箱的打码 / 装饰见 emailsIn
|
|
1471
|
+
scanText(text, text, f.path, { md: true })
|
|
1472
|
+
}
|
|
1473
|
+
// 网页、样式、矢量图里内嵌的图片:一样不能带拍摄信息(插件打包时会自动去掉 JPG / PNG / WebP / GIF 的)
|
|
1474
|
+
// 内嵌的 SVG:和单独的 .svg 一样查里面的字、样式、本机路径,再往里一层查它内嵌的图片;里面再套 SVG、不是 UTF-8、认不出的图都拦
|
|
1475
|
+
if (f.kind === 'html' || f.kind === 'css' || f.kind === 'svg') {
|
|
1476
|
+
const strict = mode === 'share' || mode === 'zip' || mode === 'ci'
|
|
1477
|
+
const hard = strict ? err : flag
|
|
1478
|
+
const d = inlineData(text, f.kind)
|
|
1479
|
+
if (d.more) hard({ code: 'inlineMany', x: f.path, n: INLINE_MAX })
|
|
1480
|
+
const svgs = d.svgs.slice(0, 300)
|
|
1481
|
+
if (d.svgs.length > svgs.length) hard({ code: 'inlineMany', x: f.path, n: 300 })
|
|
1482
|
+
const imgs = [...d.images]
|
|
1483
|
+
const bad = [...d.bad]
|
|
1484
|
+
for (const sv of svgs) {
|
|
1485
|
+
const line = lineAt(text, sv.at)
|
|
1486
|
+
if (sv.text === null) { hard({ code: 'inlineEncoding', x: f.path, line }); continue }
|
|
1487
|
+
// 浏览器会显示的(地址属性、CSS 的 url):和单独的 .svg 一样的写法规矩(DOCTYPE / 实体、注释、脚本、外链…),里面不许再套 SVG
|
|
1488
|
+
// ——实体能把地址拼出来、注释里的字也会跟着发出去。兜底搜到的(边界不确定,可能是一截)不会被显示:只查里面的字
|
|
1489
|
+
const inner = inlineData(sv.text, 'svg')
|
|
1490
|
+
if (!sv.loose) {
|
|
1491
|
+
const mk = checkMarkup(sv.text, { file: f.path, svg: true })
|
|
1492
|
+
if (mk.length) hard({ code: 'inlineSvgBad', x: f.path, line, n: [...new Set(mk.map((p) => p.code))].slice(0, 4).join(', ') })
|
|
1493
|
+
if (inner.more) hard({ code: 'inlineMany', x: f.path, n: INLINE_MAX })
|
|
1494
|
+
if (inner.svgs.length) hard({ code: 'inlineNested', x: f.path, line })
|
|
1495
|
+
}
|
|
1496
|
+
imgs.push(...inner.images.map((x) => ({ ...x, at: sv.at })))
|
|
1497
|
+
if (!sv.loose) bad.push(...inner.bad.map((x) => ({ ...x, at: sv.at })))
|
|
1498
|
+
// 兜底搜到的不会被显示,只是跟着发出去的字节:整段(实体还原后)都查
|
|
1499
|
+
const parts = sv.loose ? [decodeEntities(sv.text).text] : [visibleText(sv.text)]
|
|
1500
|
+
if (!sv.loose) for (const c of htmlCss(sv.text)) parts.push(...[...c.text.matchAll(/\/\*[\s\S]*?\*\//g)].map((m) => m[0]), ...cssStrings(c.text))
|
|
1501
|
+
for (const p of textFlags(parts.join(PART_SEP))) {
|
|
1502
|
+
const item = { code: p.code, x: f.path, line, n: cut(p.n, 40) }
|
|
1503
|
+
if (p.code === 'phone' || p.code === 'idcard' || p.code === 'email') (personalHard && !p.maybe ? err : flag)(item)
|
|
1504
|
+
else if (p.code !== 'url') flag(item)
|
|
1505
|
+
}
|
|
1506
|
+
if (!sv.loose) for (const p of personalAll(sv.text)) (personalHard && !p.maybe ? err : flag)({ code: p.code, x: f.path, line, n: cut(p.n, 40) })
|
|
1507
|
+
const lp = localPathIn(sv.text)
|
|
1508
|
+
if (lp) (personalHard || mode === 'ci' ? err : flag)({ code: 'localPath', x: f.path, line, n: cut(lp.n, 40) })
|
|
1509
|
+
}
|
|
1510
|
+
if (bad.length) hard({ code: 'inlineBad', x: f.path, line: lineAt(text, bad[0].at) })
|
|
1511
|
+
for (const im of imgs) {
|
|
1512
|
+
if (!imageMeta(im.buf, im.type)) continue
|
|
1513
|
+
hard({ code: 'imgMetaInline', x: f.path, line: lineAt(text, im.at) })
|
|
1514
|
+
break
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
// 网页 / 矢量图 / 样式:整份文件的手机号、身份证、邮箱(看不看得见都会发出去;md / json 本来就整份查)
|
|
1518
|
+
if (f.kind === 'html' || f.kind === 'svg' || f.kind === 'css') {
|
|
1519
|
+
for (const p of personalAll(text)) (personalHard && !p.maybe ? err : flag)({ code: p.code, x: f.path, line: lineNear(text, p.n), n: cut(p.n, 40) })
|
|
1520
|
+
}
|
|
1521
|
+
// 所有文字文件(连 design.json):本机路径
|
|
1522
|
+
const lp = localPathIn(text)
|
|
1523
|
+
if (lp) (personalHard || mode === 'ci' ? err : flag)({ code: 'localPath', x: f.path, line: lineAt(text, lp.at), n: cut(lp.n, 40) })
|
|
1524
|
+
}
|
|
1525
|
+
if (manifest && Array.isArray(manifest.assets)) {
|
|
1526
|
+
for (const a of manifest.assets) if (a && typeof a.path === 'string' && cleanPath(a.path) && !lower.has(cleanPath(a.path).toLowerCase())) err({ code: 'asset', x: a.path, n: '包里没有这个文件' })
|
|
1527
|
+
}
|
|
1528
|
+
// 清单里人写的字:名字、介绍、每屏的标题、署名(编号、哈希、受控词这些不查)
|
|
1529
|
+
if (manifest) {
|
|
1530
|
+
const own = [manifest.title, manifest.description, ...(Array.isArray(manifest.screens) ? manifest.screens.map((x) => (isObj(x) ? x.title : null)) : [])]
|
|
1531
|
+
const who = isObj(manifest.author) && typeof manifest.author.name === 'string' ? [manifest.author.name] : []
|
|
1532
|
+
scanText([...own.flatMap((x) => (isObj(x) ? Object.values(x).filter((v) => typeof v === 'string') : [])), ...who].join(PART_SEP), null, 'design.json')
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// 网页 / 矢量图里 <style> 和 style 属性:注释和引号里的字(content: "…")也会跟着发出去
|
|
1536
|
+
function scanCss(src, file) {
|
|
1537
|
+
const parts = []
|
|
1538
|
+
for (const c of htmlCss(src)) parts.push(...[...c.text.matchAll(/\/\*[\s\S]*?\*\//g)].map((m) => m[0]), ...cssStrings(c.text))
|
|
1539
|
+
if (parts.length) scanText(parts.join(PART_SEP), src, file)
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function scanText(visible, src, file, { md = false } = {}) {
|
|
1543
|
+
for (const p of textFlags(visible, { md })) {
|
|
1544
|
+
const line = src ? lineNear(src, p.n) : 1
|
|
1545
|
+
const item = { code: p.code, x: file, line, n: cut(p.n, 40) }
|
|
1546
|
+
// 拿不准的(号码紧挨着别的数字、只隔着行内标签):提醒,不硬拦
|
|
1547
|
+
if (p.code === 'phone' || p.code === 'idcard' || p.code === 'email') (personalHard && !p.maybe ? err : flag)(item)
|
|
1548
|
+
else if (p.code === 'url' && file === 'design.json') err(item)
|
|
1549
|
+
else if (p.code !== 'url') flag(item)
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
flags.sort((a, b) => Number(PERSONAL_CODES.has(b.code)) - Number(PERSONAL_CODES.has(a.code)))
|
|
1554
|
+
return { ok: !errors.length, errors, flags, manifest, screens, bytes, kind, type }
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function lineNear(src, needle) {
|
|
1558
|
+
const i = src.indexOf(needle)
|
|
1559
|
+
return i >= 0 ? lineAt(src, i) : 1
|
|
1560
|
+
}
|