dsh-design-studio 0.4.1 → 0.6.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/lib/bundle.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * 远程看(手机走外网、别的电脑打开 DSH 网页):预览服务器只听电脑本机,那边连不上。
3
+ *
4
+ * 这时把一屏打成一整份 HTML:本地的样式表、脚本内联成 <style> / <script>,图片、字体换成 data: 地址,
5
+ * picker 也内联进去。右侧栏把它放进沙盒 iframe 的 srcdoc 里显示——页面是个不透明的源,
6
+ * 碰不到 DSH 的 Cookie 和接口(和本机预览时「不同源」是一个意思)。
7
+ *
8
+ * 只内联设计目录里的文件(resolve 管越界);外网地址、data:、找不到的原样留着。总大小有上限,超了的也原样留着。
9
+ */
10
+
11
+ import path from 'node:path'
12
+ import { MIME, headInsert } from './serve.js'
13
+
14
+ export const MAX_TOTAL = 12 * 1024 * 1024 // 内联进去的总量:每内联一次都算(同一张图引用 24 次算 24 次)
15
+ const MAX_READ = 2 * MAX_TOTAL // 读文件的总量
16
+ const MAX_DEPTH = 4 // 样式表 @import 最多套几层
17
+ const MAX_MODULES = 60 // ES 模块最多带几个
18
+
19
+ /** 能内联的本地地址:相对路径或以 / 开头的路径(不是 http:、//、data:、#…) */
20
+ function localRef(url) {
21
+ const u = String(url || '').trim()
22
+ if (!u || /^(#|[a-z][\w+.-]*:|\/\/)/i.test(u)) return ''
23
+ return u.split(/[?#]/)[0]
24
+ }
25
+
26
+ /** 相对 base(文件在设计目录里的相对路径)解析出另一个文件的相对路径;以 / 开头的从设计目录根算 */
27
+ function join(base, ref) {
28
+ let r = ref
29
+ try { r = decodeURIComponent(ref) } catch { /* 原样 */ }
30
+ const p = r.startsWith('/') ? r.slice(1) : path.posix.join(path.posix.dirname(base), r)
31
+ return path.posix.normalize(p)
32
+ }
33
+
34
+ const escAttr = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
35
+ const escScript = (s) => String(s).replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\!--')
36
+ /** 属性值里的实体解回来(只管引号和 &,style 里用得到的就这些) */
37
+ const unAttr = (s) => String(s).replace(/&(quot|#0*34|#x0*22);/gi, '"').replace(/&(apos|#0*39|#x0*27);/gi, "'").replace(/&amp;/gi, '&')
38
+
39
+ /** 模块里的 import … from '…' / import '…' / export … from '…' / import('…') */
40
+ const IMPORT_RE = /(\bimport\s*(?:[\w$*{}\s,]+?\s*from\s*)?|\bexport\s*(?:\*(?:\s*as\s+[\w$]+)?|\{[^}]*\})\s*from\s*|\bimport\s*\(\s*)(["'])([^"'\n]+)\2/g
41
+ const relSpec = (spec) => (/^\.{0,2}\//.test(spec) ? localRef(spec) : '')
42
+
43
+ /**
44
+ * 放进页面的 SVG 雪碧图:去掉脚本、事件属性和 <style>(放进页面会管到整页),
45
+ * id 都加上这份文件的前缀(两份文件都有 #check 时不串图),文件里对它们的引用跟着改
46
+ */
47
+ function spriteOf(text, pre) {
48
+ return text
49
+ .replace(/<script\b[\s\S]*?<\/script\s*>/gi, '')
50
+ .replace(/<style\b[\s\S]*?<\/style\s*>/gi, '')
51
+ .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
52
+ .replace(/(\sid\s*=\s*)(["'])([^"']+)\2/gi, (m, a, q, id) => `${a}${q}${pre}${id}${q}`)
53
+ .replace(/(\s(?:href|xlink:href)\s*=\s*)(["'])#([^"']+)\2/gi, (m, a, q, id) => `${a}${q}#${pre}${id}${q}`)
54
+ .replace(/url\(\s*(["']?)#([^"')]+)\1\s*\)/gi, (m, q, id) => `url(${q}#${pre}${id}${q})`)
55
+ }
56
+
57
+ /**
58
+ * @param html 这一屏的 HTML
59
+ * @param opts.key 这一屏在设计目录里的相对路径(home.html)
60
+ * @param opts.read (rel) → Buffer | null:读设计目录里的文件(越界、不存在、太大返回 null)
61
+ * @param opts.tail 插在 </body> 前的东西(picker 和它的配置)
62
+ * @param opts.lead 插在 <head> 最前面(<base> 后面)的东西:报错记录
63
+ * @param opts.base 这一屏原来的地址:放一个 <base>,没内联上的相对地址照原样指过去(不指到 DSH 网页上)
64
+ * @returns string
65
+ */
66
+ export function bundle(html, { key, read, tail = '', base = '', lead = '' }) {
67
+ let budget = MAX_TOTAL
68
+ let readLeft = MAX_READ
69
+ const cache = new Map()
70
+ const get = (rel) => {
71
+ if (cache.has(rel)) return cache.get(rel)
72
+ let buf = null
73
+ if (readLeft > 0) { try { buf = read(rel) } catch { buf = null } }
74
+ if (buf && buf.length > Math.min(MAX_TOTAL, readLeft)) buf = null
75
+ if (buf) readLeft -= buf.length
76
+ cache.set(rel, buf)
77
+ return buf
78
+ }
79
+ /** 花预算:够就扣掉 */
80
+ const spend = (n) => { if (n > budget) return false; budget -= n; return true }
81
+ const typeOf = (rel) => {
82
+ const t = (MIME[path.posix.extname(rel).toLowerCase()] || 'application/octet-stream').replace(/;.*/, '')
83
+ return /^text\//.test(t) ? `${t};charset=utf-8` : t
84
+ }
85
+ const encoded = new Map()
86
+ /** 文件 → data: 地址(每用一次扣一次预算);读不到、超了返回 '' */
87
+ const dataOf = (rel) => {
88
+ const buf = get(rel)
89
+ if (!buf) return ''
90
+ let d = encoded.get(rel)
91
+ if (!d) { d = `data:${typeOf(rel)};base64,${buf.toString('base64')}`; encoded.set(rel, d) }
92
+ return spend(d.length) ? d : ''
93
+ }
94
+ const dataUrl = (from, url) => {
95
+ const ref = localRef(url)
96
+ return ref ? dataOf(join(from, ref)) || url : url
97
+ }
98
+ // 样式表里的 url(…) 和 @import(bare:url() 不加引号,行内 style 用——base64 里没有引号、括号、空白)
99
+ const css = (text, from, depth = 0, bare = false) => String(text)
100
+ .replace(/@import\s+(?:url\(\s*)?(["']?)([^"')\s;]+)\1\s*\)?([^;]*);/gi, (m, q, url, media) => {
101
+ const ref = localRef(url)
102
+ if (!ref || depth >= MAX_DEPTH) return m
103
+ const rel = join(from, ref)
104
+ const buf = get(rel)
105
+ if (!buf || !spend(buf.length)) return m
106
+ const inner = css(buf.toString('utf8'), rel, depth + 1)
107
+ return media.trim() ? `@media ${media.trim()}{${inner}}` : inner
108
+ })
109
+ .replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi, (m, q, url) => {
110
+ const d = dataUrl(from, url)
111
+ return d === url ? m : bare ? `url(${d})` : `url("${d}")`
112
+ })
113
+ const srcset = (v) => String(v).split(',').map((part) => {
114
+ const [u, ...rest] = part.trim().split(/\s+/)
115
+ return [dataUrl(key, u), ...rest].join(' ')
116
+ }).join(', ')
117
+
118
+ // ES 模块:入口和依赖都换成 data:,模块里相对的 import 改写成原预览地址的绝对 URL,再用 importmap 把这些地址映射到 data:
119
+ // (data: 模块里没法按相对路径解析;行内模块靠 <base> 解析成同样的绝对地址)。页面自己有 importmap 就不动模块。
120
+ const root = base ? new URL('../'.repeat(String(key).split('/').length - 1) || './', base) : null
121
+ const modules = !!root && !/<script\b[^>]*\btype\s*=\s*["']?importmap/i.test(html)
122
+ const absOf = (rel) => new URL(rel.split('/').map(encodeURIComponent).join('/'), root).href
123
+ const mods = new Map() // 绝对地址 → data:('' = 正在处理 / 没打成)
124
+ const mod = (rel) => {
125
+ const abs = absOf(rel)
126
+ if (mods.has(abs)) return mods.get(abs)
127
+ if (mods.size >= MAX_MODULES) return ''
128
+ const buf = get(rel)
129
+ if (!buf) return ''
130
+ mods.set(abs, '')
131
+ const text = buf.toString('utf8').replace(IMPORT_RE, (m, pre, q, spec) => {
132
+ const ref = relSpec(spec)
133
+ if (!ref) return m
134
+ const dep = join(rel, ref)
135
+ mod(dep)
136
+ return `${pre}${q}${absOf(dep)}${q}`
137
+ }) + `\n//# sourceURL=${abs}\n` // 每个文件一个 data:(内容一样的两个文件也是两个模块),报错时也看得出是哪个文件
138
+ const d = `data:text/javascript;charset=utf-8;base64,${Buffer.from(text).toString('base64')}`
139
+ if (!spend(d.length)) { mods.delete(abs); return '' }
140
+ mods.set(abs, d)
141
+ return d
142
+ }
143
+
144
+ const sprites = new Map() // <use href="icons.svg#x"> 用到的 SVG 文件 → { pre: id 前缀, text }
145
+
146
+ let out = String(html)
147
+ // 样式表:<link rel="stylesheet" href="x.css"> → <style>
148
+ .replace(/<link\b[^>]*>/gi, (tag) => {
149
+ if (!/\brel\s*=\s*["']?[^"'>]*stylesheet/i.test(tag)) {
150
+ // 图标之类的 <link href> 换成 data:
151
+ return tag.replace(/(\bhref\s*=\s*)(["'])([^"']*)\2/i, (m, a, q, u) => `${a}${q}${escAttr(dataUrl(key, u))}${q}`)
152
+ }
153
+ const m = /\bhref\s*=\s*(["'])([^"']*)\1/i.exec(tag) || /\bhref\s*=\s*([^\s>]+)/i.exec(tag)
154
+ const href = m ? m[2] ?? m[1] : ''
155
+ const ref = localRef(href)
156
+ if (!ref) return tag
157
+ const rel = join(key, ref)
158
+ const buf = get(rel)
159
+ if (!buf || !spend(buf.length)) return tag
160
+ const media = /\bmedia\s*=\s*(["'])([^"']*)\1/i.exec(tag)
161
+ return `<style data-href="${escAttr(href)}"${media ? ` media="${escAttr(media[2])}"` : ''}>${css(buf.toString('utf8'), rel).replace(/<\/style/gi, '<\\/style')}</style>`
162
+ })
163
+ // 页面里的 <style>:url() 相对这一屏
164
+ .replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)/gi, (m, a, body, b) => (/\bdata-href=/.test(a) ? m : a + css(body, key) + b))
165
+ // 脚本:本地的经典脚本换成 src="data:…"(defer、async、执行顺序都和原来一样);模块见上
166
+ .replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi, (m, attrs, body) => {
167
+ const s = /\bsrc\s*=\s*(["'])([^"']*)\1/i.exec(attrs) || /\bsrc\s*=\s*([^\s>"']+)/i.exec(attrs)
168
+ const isModule = /\btype\s*=\s*["']?module\b/i.test(attrs)
169
+ if (isModule && !modules) return m
170
+ if (!s) {
171
+ // 行内模块:正文不动(相对 import 按 <base> 解析),把它用到的模块放进 importmap
172
+ if (isModule) body.replace(IMPORT_RE, (x, pre, q, spec) => { const ref = relSpec(spec); if (ref) mod(join(key, ref)); return x })
173
+ return m
174
+ }
175
+ const ref = localRef(s[2] ?? s[1])
176
+ if (!ref) return m
177
+ const rel = join(key, ref)
178
+ const d = isModule ? mod(rel) : dataOf(rel)
179
+ return d ? `<script${attrs.replace(s[0], `src="${d}"`)}>${body}</script>` : m
180
+ })
181
+ // 图片、视频、音频、SVG 里的 image
182
+ .replace(/(<(?:img|source|video|audio|input|image|embed|track)\b[^>]*>)/gi, (tag) => tag
183
+ .replace(/(\s(?:src|poster|href|xlink:href)\s*=\s*)(["'])([^"']*)\2/gi, (m, a, q, u) => `${a}${q}${escAttr(dataUrl(key, u))}${q}`)
184
+ .replace(/(\ssrcset\s*=\s*)(["'])([^"']*)\2/gi, (m, a, q, u) => `${a}${q}${escAttr(srcset(u))}${q}`))
185
+ // <use href="icons.svg#x">:浏览器不认 data: 的 <use>,把那个 SVG 文件放进页面,href 改成 #x
186
+ .replace(/<use\b[^>]*>/gi, (tag) => tag.replace(/(\s(?:href|xlink:href)\s*=\s*)(["'])([^"']*)\2/gi, (m, a, q, u) => {
187
+ const i = u.indexOf('#')
188
+ const ref = localRef(u)
189
+ if (!ref || i < 0) return m
190
+ const rel = join(key, ref)
191
+ if (!sprites.has(rel)) {
192
+ const buf = get(rel)
193
+ const pre = `dsh${sprites.size + 1}-`
194
+ const text = buf ? spriteOf(buf.toString('utf8').replace(/<\?xml[\s\S]*?\?>|<!DOCTYPE[^>]*>/gi, '').trim(), pre) : ''
195
+ if (!/^<svg\b/i.test(text) || !spend(text.length)) return m
196
+ sprites.set(rel, { pre, text })
197
+ }
198
+ let id = u.slice(i + 1)
199
+ try { id = decodeURIComponent(id) } catch { /* 原样 */ }
200
+ return `${a}${q}#${escAttr(sprites.get(rel).pre + id)}${q}`
201
+ }))
202
+ // 行内样式里的 url():解开实体再处理,按原来的引号转义回去
203
+ .replace(/(\sstyle\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi, (m, a, dq, sq) => {
204
+ const raw = dq ?? sq
205
+ if (!/url\(/i.test(raw)) return m
206
+ const v = css(unAttr(raw), key, 0, true).replace(/&/g, '&amp;')
207
+ return dq != null ? `${a}"${v.replace(/"/g, '&quot;')}"` : `${a}'${v.replace(/'/g, '&#39;')}'`
208
+ })
209
+ const head = []
210
+ if (base) head.push(`<base href="${escAttr(base)}">`)
211
+ if (lead) head.push(lead)
212
+ const map = [...mods].filter(([, d]) => d)
213
+ if (map.length) head.push(`<script type="importmap">${escScript(JSON.stringify({ imports: Object.fromEntries(map) }))}</script>`)
214
+ if (head.length) out = headInsert(out, head.join(''))
215
+ const sprite = sprites.size ? `<div aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">${[...sprites.values()].map((x) => x.text).join('')}</div>` : ''
216
+ // picker 和本机预览一样放在 </body> 前(没有就放最后)
217
+ if (sprite || tail) {
218
+ const i = out.toLowerCase().lastIndexOf('</body>')
219
+ out = i >= 0 ? out.slice(0, i) + sprite + tail + out.slice(i) : out + sprite + tail
220
+ }
221
+ return out
222
+ }
223
+
224
+ /** picker 和它的配置,内联成一个 <script>(parents:右侧栏所在的那个源;href:这一屏原来的地址) */
225
+ export function inlinePicker(src, parent, href = '') {
226
+ const cfg = JSON.stringify({ live: false, remote: true, href, parents: parent ? [parent] : [] })
227
+ return `<script>window.__DSH_DESIGN__=${escScript(cfg)};\n${escScript(src)}</script>`
228
+ }