dsh-design-studio 0.11.0 → 0.12.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.md +2 -2
- package/lib/client.js +1893 -539
- package/lib/core.js +210 -6
- package/lib/draft-page.js +763 -0
- package/lib/draft.js +151 -0
- package/lib/index.js +17 -3
- package/lib/picker.js +227 -1
- package/lib/serve.js +177 -1
- package/lib/tweaks.js +58 -24
- package/package.json +1 -1
package/lib/serve.js
CHANGED
|
@@ -22,6 +22,14 @@ import { tokenize } from './gallery/html.js'
|
|
|
22
22
|
|
|
23
23
|
export const INTERNAL = '/__dsh_design__/'
|
|
24
24
|
const PICKER_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker.js')
|
|
25
|
+
const DRAFT_JS = path.join(path.dirname(fileURLToPath(import.meta.url)), 'draft-page.js')
|
|
26
|
+
// 草稿往外吐:模型一点点流的时候来多少吐多少(不拖后腿,右侧栏中途才来看也是先把写到的一口气给);
|
|
27
|
+
// 整页一下子到的(模型服务把参数憋到最后一块给:一块 DRAFT_LUMP 字以上、紧跟着就写完了,或者来看时已经写完、draft.js 记着是整段到的)才排队,
|
|
28
|
+
// 每 50ms 一截、约 60 拍(3 秒)放完,一截至少 100 字
|
|
29
|
+
const DRAFT_TICK = 50
|
|
30
|
+
const DRAFT_STEP = 100
|
|
31
|
+
const DRAFT_SPREAD = 60
|
|
32
|
+
const DRAFT_LUMP = 1500
|
|
25
33
|
const MAX_SERVERS = 8
|
|
26
34
|
const MAX_HTML = 8 * 1024 * 1024 // 要注入脚本的 HTML 最多读这么大
|
|
27
35
|
const UP_HOSTS = ['127.0.0.1', '::1'] // 开发服务器可能只听其中一个
|
|
@@ -160,7 +168,10 @@ export function resolveInRoot(root, pathname) {
|
|
|
160
168
|
let rel
|
|
161
169
|
try { rel = decodeURIComponent(pathname) } catch { return null }
|
|
162
170
|
if (rel.includes('\0')) return null
|
|
171
|
+
if (path.sep === '\\') rel = rel.replace(/\\/g, '/') // Windows:反斜杠也是分隔符,先换成 / 再去掉 ..(不然 ..\ 绕得出去)
|
|
163
172
|
const abs = path.resolve(root, '.' + path.posix.normalize('/' + rel))
|
|
173
|
+
const base = path.resolve(root)
|
|
174
|
+
if (abs !== base && !abs.startsWith(base + path.sep)) return null // 文件还不存在(正在写的草稿)也不许出根目录
|
|
164
175
|
let real
|
|
165
176
|
try { real = fs.realpathSync(abs) } catch { return { abs, real: null } }
|
|
166
177
|
if (!inRoot(root, real)) return null
|
|
@@ -224,7 +235,77 @@ export function trustedOrigins(hosts) {
|
|
|
224
235
|
* @param opts.parents () → 还有哪些 DSH 页面的源能嵌预览(DSH 网页端的地址)
|
|
225
236
|
* @param opts.strict { html(buf) → 这份 HTML 是别人做的、没改过吗, root(dir) → 这个目录是别人做的设计吗 }:是就加硬规矩
|
|
226
237
|
*/
|
|
227
|
-
|
|
238
|
+
/**
|
|
239
|
+
* 草稿页往外吐的时候,在 doctype 后面插上草稿脚本(插在 doctype 前面会进怪异模式)。
|
|
240
|
+
* 开头可能被切成好几块:攒到能判断为止。
|
|
241
|
+
*/
|
|
242
|
+
export function draftInjector(tag) {
|
|
243
|
+
const LIMIT = 4096
|
|
244
|
+
let buf = ''
|
|
245
|
+
let done = false
|
|
246
|
+
let mid = false // 开头的注释太长:已经先吐出去一截,buf 接着在注释里面
|
|
247
|
+
let open = false // 看到 buf 末尾时还在一段没收尾的注释里
|
|
248
|
+
return {
|
|
249
|
+
push(text) {
|
|
250
|
+
if (done) return text
|
|
251
|
+
buf += text
|
|
252
|
+
let out = ''
|
|
253
|
+
let inCmt = mid
|
|
254
|
+
let i = inCmt ? 0 : /^\uFEFF?\s*/.exec(buf)[0].length // buf[0, i) 看过了:空白、完整的注释
|
|
255
|
+
const at = (k) => {
|
|
256
|
+
done = true
|
|
257
|
+
const s = out + buf.slice(0, k) + tag + buf.slice(k)
|
|
258
|
+
buf = ''
|
|
259
|
+
return s
|
|
260
|
+
}
|
|
261
|
+
// 还得等:攒太多了就先把看过的(注释里的也行,留两个字防 --> 被切开)吐出去,接着等 doctype
|
|
262
|
+
const hold = () => {
|
|
263
|
+
open = inCmt
|
|
264
|
+
if (buf.length <= LIMIT) return out
|
|
265
|
+
const k = inCmt ? Math.max(i, buf.length - 2) : i
|
|
266
|
+
out += buf.slice(0, k)
|
|
267
|
+
buf = buf.slice(k)
|
|
268
|
+
mid = inCmt
|
|
269
|
+
return out
|
|
270
|
+
}
|
|
271
|
+
// 开头的注释跳过去(<!-- license --> 后面才是 doctype);doctype 后面插,没有 doctype 就插在注释后面
|
|
272
|
+
for (;;) {
|
|
273
|
+
if (inCmt) {
|
|
274
|
+
const e = buf.indexOf('-->', i)
|
|
275
|
+
if (e < 0) return hold()
|
|
276
|
+
inCmt = false
|
|
277
|
+
i = e + 3
|
|
278
|
+
i += /^\s*/.exec(buf.slice(i))[0].length
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
const rest = buf.slice(i)
|
|
282
|
+
if (!rest) return hold()
|
|
283
|
+
if (rest.startsWith('<!--')) {
|
|
284
|
+
inCmt = true
|
|
285
|
+
i += 4
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
if (rest.length < 9 && ('<!doctype'.startsWith(rest.toLowerCase()) || '<!--'.startsWith(rest))) return hold() // 还没到全
|
|
289
|
+
if (/^<!doctype/i.test(rest)) {
|
|
290
|
+
const e = buf.indexOf('>', i)
|
|
291
|
+
if (e >= 0) return at(e + 1)
|
|
292
|
+
return rest.length > LIMIT ? at(i) : hold() // 这么长还没完:不像 doctype,不等了
|
|
293
|
+
}
|
|
294
|
+
return at(i)
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
flush() {
|
|
298
|
+
if (done) return ''
|
|
299
|
+
done = true
|
|
300
|
+
// 停在一段没写完的注释里:先把注释收上,脚本才会跑
|
|
301
|
+
const out = open ? buf + '-->' + tag : mid ? buf + tag : tag + buf
|
|
302
|
+
buf = ''
|
|
303
|
+
return out
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function createHub({ log = () => {}, onChange = () => {}, history = null, parents = () => [], strict = null, drafts = null } = {}) {
|
|
228
309
|
const strictHtml = (buf) => { try { return !!(strict && strict.html && strict.html(buf)) } catch (e) { log(e); return true } }
|
|
229
310
|
const strictRoot = (dir) => { try { return !!(strict && strict.root && strict.root(dir)) } catch (e) { log(e); return true } }
|
|
230
311
|
const strictVer = (dir) => { try { return !!(strict && strict.versions && strict.versions(dir)) } catch (e) { log(e); return true } }
|
|
@@ -257,6 +338,97 @@ export function createHub({ log = () => {}, onChange = () => {}, history = null,
|
|
|
257
338
|
return [...new Set([DESKTOP_ORIGIN, ...more.filter((o) => typeof o === 'string' && o)])]
|
|
258
339
|
}
|
|
259
340
|
|
|
341
|
+
function draftJs(res) {
|
|
342
|
+
fs.readFile(DRAFT_JS, 'utf8', (err, src) => {
|
|
343
|
+
if (err) return send(res, 500, 'draft script missing')
|
|
344
|
+
send(res, 200, src, { 'Content-Type': MIME['.js'] })
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 正在写的那一页(地址带 ?__ddp_draft=):先吐出已经写到的,之后来一截吐一截,写完收尾(浏览器边收边画) */
|
|
349
|
+
function serveDraft(req, res, entry, pathname) {
|
|
350
|
+
if (entry.kind !== 'file' || !drafts || strictRoot(entry.root)) return send(res, 404, 'not found')
|
|
351
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'method not allowed')
|
|
352
|
+
const hit = resolveInRoot(entry.root, pathname)
|
|
353
|
+
if (!hit) return send(res, 403, 'forbidden')
|
|
354
|
+
let want = ''
|
|
355
|
+
try { want = new URL(req.url, 'http://x').searchParams.get('__ddp_draft') || '' } catch { /* route 里已经验过 */ }
|
|
356
|
+
const got = drafts.get(hit.abs)
|
|
357
|
+
// 地址里的编号对得上才给:同一个文件又开始重写(别的会话、或下一次写)时,旧框子拿到的是真文件,不串到新的那份
|
|
358
|
+
const d = got && String(got.id) === want ? got : null
|
|
359
|
+
if (!d) return serveFile(req, res, entry, pathname) // 已经写完收走了:给真文件
|
|
360
|
+
res.writeHead(200, { 'Content-Type': MIME['.html'], 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
|
|
361
|
+
if (req.method === 'HEAD') return res.end()
|
|
362
|
+
const inj = draftInjector(`<script src="${INTERNAL}draft.js"></script>`)
|
|
363
|
+
let off = () => {}
|
|
364
|
+
let ended = false
|
|
365
|
+
// 一大段一下子到:排着队一截截放,看着还是一段段长出来的;<head> 里的样式看不见,一口气给。
|
|
366
|
+
// step 是 0 = 没在排队,来多少吐多少
|
|
367
|
+
const q = { buf: '', body: false, src: false, timer: null, step: 0, big: false }
|
|
368
|
+
const spread = (len) => Math.max(DRAFT_STEP, Math.ceil(len / DRAFT_SPREAD))
|
|
369
|
+
const stop = () => {
|
|
370
|
+
ended = true
|
|
371
|
+
clearTimeout(q.timer)
|
|
372
|
+
off()
|
|
373
|
+
}
|
|
374
|
+
const pump = () => {
|
|
375
|
+
q.timer = null
|
|
376
|
+
if (ended) return
|
|
377
|
+
if (q.big) {
|
|
378
|
+
q.big = false
|
|
379
|
+
if (q.src) q.step = Math.max(q.step, spread(q.buf.length)) // 一大块到了紧跟着就写完了:整页憋到最后给的,慢放
|
|
380
|
+
}
|
|
381
|
+
let n = q.buf.length
|
|
382
|
+
if (!q.body) {
|
|
383
|
+
const m = /<body\b[^>]*>/i.exec(q.buf) || /<\/head\s*>/i.exec(q.buf)
|
|
384
|
+
if (m) {
|
|
385
|
+
q.body = true
|
|
386
|
+
n = m.index + m[0].length
|
|
387
|
+
if (q.step) q.step = spread(q.buf.length - n) // 剩下的正文按原来的时长放
|
|
388
|
+
} else if (q.src) q.body = true // 写完了也没找到 <body>:整页都按拍子放
|
|
389
|
+
}
|
|
390
|
+
if (q.body && q.step && n === q.buf.length) n = Math.min(n, q.step)
|
|
391
|
+
if (n < q.buf.length && /[\ud800-\udbff]/.test(q.buf[n - 1] || '')) n++ // 别把表情切成两半
|
|
392
|
+
const s = inj.push(q.buf.slice(0, n))
|
|
393
|
+
q.buf = q.buf.slice(n)
|
|
394
|
+
try { if (s) res.write(s) } catch (e) { log(e) }
|
|
395
|
+
if (q.buf) q.timer = setTimeout(pump, DRAFT_TICK)
|
|
396
|
+
else if (!q.src) q.step = 0 // 放完了:之后又是一点点流的,来多少吐多少
|
|
397
|
+
else {
|
|
398
|
+
stop()
|
|
399
|
+
try { res.end(inj.flush()) } catch (e) { log(e) }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// how:'lump' 慢放;'now' 一口气给;不填 = 流过来的一块,一大块的话等这一下过完看是不是紧跟着就写完了
|
|
403
|
+
const out = (text, how) => {
|
|
404
|
+
if (ended) return
|
|
405
|
+
if (text) {
|
|
406
|
+
q.buf += text
|
|
407
|
+
if (how === 'lump') q.step = Math.max(q.step, spread(q.buf.length))
|
|
408
|
+
else if (!how && text.length >= DRAFT_LUMP) q.big = true
|
|
409
|
+
}
|
|
410
|
+
if (q.big) {
|
|
411
|
+
if (!q.timer) q.timer = setTimeout(pump, 0)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
if (!q.timer) pump()
|
|
415
|
+
}
|
|
416
|
+
const finish = () => {
|
|
417
|
+
q.src = true
|
|
418
|
+
out('')
|
|
419
|
+
}
|
|
420
|
+
off = drafts.sub(hit.abs, (ev) => {
|
|
421
|
+
if (ev.text) out(ev.text)
|
|
422
|
+
if (ev.done) finish()
|
|
423
|
+
})
|
|
424
|
+
res.on('close', stop)
|
|
425
|
+
res.on('error', stop)
|
|
426
|
+
// 来看时已经写到的:整段一下子到、已经写完的补放一遍;别的(还在写,或一路流着写完、右侧栏换了个框重新打开的)一口气给
|
|
427
|
+
if (d.done) q.src = true // 先记下已经写完(找不到 <body> 的页面靠它整页按拍子放)
|
|
428
|
+
out(d.text, d.done && d.lump ? 'lump' : 'now')
|
|
429
|
+
if (d.done) finish()
|
|
430
|
+
}
|
|
431
|
+
|
|
260
432
|
function picker(res, entry) {
|
|
261
433
|
fs.readFile(PICKER_FILE, 'utf8', (err, src) => {
|
|
262
434
|
if (err) return send(res, 500, 'picker missing')
|
|
@@ -504,6 +676,9 @@ export function createHub({ log = () => {}, onChange = () => {}, history = null,
|
|
|
504
676
|
let pathname
|
|
505
677
|
try { pathname = new URL(req.url, 'http://x').pathname } catch { return send(res, 400, 'bad url') }
|
|
506
678
|
if (pathname === INTERNAL + 'picker.js') return picker(res, entry)
|
|
679
|
+
if (pathname === INTERNAL + 'draft.js') return draftJs(res)
|
|
680
|
+
// 草稿就挂在这一屏自己的地址上(/menu.html?__ddp_draft=…),页面里的相对地址(tokens.css、图片)照常对得上
|
|
681
|
+
if (entry.kind === 'file' && /[?&]__ddp_draft=/.test(req.url)) return serveDraft(req, res, entry, pathname)
|
|
507
682
|
if (pathname === INTERNAL + 'events') {
|
|
508
683
|
if (entry.kind === 'file') return events(req, res, entry)
|
|
509
684
|
return send(res, 204, '')
|
|
@@ -534,6 +709,7 @@ export function createHub({ log = () => {}, onChange = () => {}, history = null,
|
|
|
534
709
|
if (have) {
|
|
535
710
|
touch(have)
|
|
536
711
|
await have.ready
|
|
712
|
+
if (have.kind === 'file' && !have.watcher) watch(have) // 先开的时候目录还没建(边写边看的第一屏):现在补上监听
|
|
537
713
|
return { key, origin: have.origin }
|
|
538
714
|
}
|
|
539
715
|
evict()
|
package/lib/tweaks.js
CHANGED
|
@@ -273,44 +273,65 @@ function cssFiles(root, realRoot = '') {
|
|
|
273
273
|
return [...css, ...listScreens(root)].filter((rel) => ownFile(root, realRoot, rel))
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/** 外观面板里直接改的设计变量:{ '--名字': 值 } → [[名字, 值]](名字、值都要老实:值里不能有 ; { } < 换行) */
|
|
277
|
+
export function cleanVars(vars) {
|
|
278
|
+
if (!vars || typeof vars !== 'object' || Array.isArray(vars)) return []
|
|
279
|
+
const out = []
|
|
280
|
+
for (const [name, v] of Object.entries(vars)) {
|
|
281
|
+
const value = String(v == null ? '' : v).trim()
|
|
282
|
+
if (!/^--[A-Za-z0-9_-]{1,60}$/.test(name) || !value || value.length > 200 || /[;{}<>\n\r\\]/.test(value)) continue
|
|
283
|
+
out.push([name, value])
|
|
284
|
+
if (out.length >= 60) break
|
|
285
|
+
}
|
|
286
|
+
return out
|
|
287
|
+
}
|
|
288
|
+
|
|
276
289
|
/**
|
|
277
|
-
* 写回:values = { id: 值 }
|
|
290
|
+
* 写回:values = { id: 值 }(调节滑块),vars = { '--名字': 值 }(外观面板里直接改的设计变量)。
|
|
291
|
+
* CSS 变量改样式里的默认值,属性写到每屏的 <html> 上,tweaks.json 的 value 跟着改。
|
|
278
292
|
* @returns { ok, changed: [{ id, label, from, to }], files: [相对路径], missed: [{ id, label }] }(missed:样式里找不到这个变量的默认值)
|
|
279
293
|
*/
|
|
280
|
-
export function applyTweaks(root, values) {
|
|
294
|
+
export function applyTweaks(root, values, vars) {
|
|
281
295
|
const r0 = readTweaks(root)
|
|
282
|
-
|
|
296
|
+
const hasTw = !!(r0 && r0.list.length && values && Object.keys(values).length)
|
|
297
|
+
const varList = cleanVars(vars)
|
|
298
|
+
if (!hasTw && !varList.length) return { ok: false, reason: 'no-tweaks' }
|
|
283
299
|
let realRoot
|
|
284
300
|
try { realRoot = fs.realpathSync(root) } catch { return { ok: false, reason: 'no-tweaks' } }
|
|
285
301
|
// tweaks.json 是软链接(指到别处)就不写:写回只改设计目录自己的文件
|
|
286
|
-
|
|
302
|
+
const twOwn = !!(r0 && r0.list.length) && ownFile(root, realRoot, TWEAKS_FILE)
|
|
303
|
+
if (hasTw && !twOwn) return { ok: false, reason: 'no-tweaks' }
|
|
287
304
|
const file = path.join(root, TWEAKS_FILE)
|
|
288
|
-
|
|
289
|
-
|
|
305
|
+
let raw = null
|
|
306
|
+
try { raw = twOwn ? JSON.parse(fs.readFileSync(file, 'utf8')) : null } catch { if (hasTw) return { ok: false, reason: 'no-tweaks' } }
|
|
307
|
+
const arr = raw ? (Array.isArray(raw) ? raw : raw.tweaks) : []
|
|
290
308
|
const texts = new Map()
|
|
291
309
|
const read = (rel) => (texts.has(rel) ? texts.get(rel) : fs.readFileSync(path.join(root, rel), 'utf8'))
|
|
292
310
|
const changed = []
|
|
293
311
|
const missed = []
|
|
294
|
-
|
|
295
|
-
|
|
312
|
+
// 先把所有文件里顶层的声明都改掉(哪份样式后加载就是哪份生效,全改才稳);一处都没有再退到 @media 里的第一处
|
|
313
|
+
const writeVar = (name, value) => {
|
|
314
|
+
for (const nested of [false, true]) {
|
|
315
|
+
let done = false
|
|
316
|
+
for (const rel of cssFiles(root, realRoot)) {
|
|
317
|
+
let t
|
|
318
|
+
try { t = read(rel) } catch { continue }
|
|
319
|
+
const next = /\.html?$/i.test(rel) ? setVarInHtml(t, name, value, nested) : setVarDecl(t, name, value, nested)
|
|
320
|
+
if (next === null) continue
|
|
321
|
+
texts.set(rel, next)
|
|
322
|
+
done = true
|
|
323
|
+
if (nested) break
|
|
324
|
+
}
|
|
325
|
+
if (done) return true
|
|
326
|
+
}
|
|
327
|
+
return false
|
|
328
|
+
}
|
|
329
|
+
for (const k of hasTw ? r0.list : []) {
|
|
330
|
+
if (!Object.hasOwn(values, k.id)) continue
|
|
296
331
|
const v = normValue(k, values[k.id])
|
|
297
332
|
if (v == null || v === k.value) continue
|
|
298
333
|
if (k.var) {
|
|
299
|
-
|
|
300
|
-
let done = false
|
|
301
|
-
for (const nested of [false, true]) {
|
|
302
|
-
for (const rel of cssFiles(root, realRoot)) {
|
|
303
|
-
let t
|
|
304
|
-
try { t = read(rel) } catch { continue }
|
|
305
|
-
const next = /\.html?$/i.test(rel) ? setVarInHtml(t, k.var, cssValue(k, v), nested) : setVarDecl(t, k.var, cssValue(k, v), nested)
|
|
306
|
-
if (next === null) continue
|
|
307
|
-
texts.set(rel, next)
|
|
308
|
-
done = true
|
|
309
|
-
if (nested) break
|
|
310
|
-
}
|
|
311
|
-
if (done) break
|
|
312
|
-
}
|
|
313
|
-
if (!done) { missed.push({ id: k.id, label: k.label }); continue }
|
|
334
|
+
if (!writeVar(k.var, cssValue(k, v))) { missed.push({ id: k.id, label: k.label }); continue }
|
|
314
335
|
} else {
|
|
315
336
|
for (const rel of listScreens(root)) {
|
|
316
337
|
if (!ownFile(root, realRoot, rel)) continue
|
|
@@ -321,8 +342,21 @@ export function applyTweaks(root, values) {
|
|
|
321
342
|
if (arr[k.i] && typeof arr[k.i] === 'object') arr[k.i].value = k.kind === 'range' ? v : String(v)
|
|
322
343
|
changed.push({ id: k.id, label: k.label, from: shownValue(k, k.value), to: shownValue(k, v) })
|
|
323
344
|
}
|
|
345
|
+
let twChanged = changed.length
|
|
346
|
+
for (const [name, value] of varList) {
|
|
347
|
+
if (!writeVar(name, value)) { missed.push({ id: name, label: name }); continue }
|
|
348
|
+
changed.push({ id: name, label: name, from: '', to: value })
|
|
349
|
+
// 有颜色滑块管着这个变量:tweaks.json 里它的值跟着改,滑块和文件对得上(不然滑块还显示旧颜色)
|
|
350
|
+
if (raw) for (const k of r0.list) {
|
|
351
|
+
if (k.var !== name || k.kind !== 'color' || !arr[k.i] || typeof arr[k.i] !== 'object') continue
|
|
352
|
+
const v = normValue(k, value)
|
|
353
|
+
if (v == null || v === normValue(k, arr[k.i].value)) continue // 和这次已经改过的比(同一次保存里滑块也动了这个变量)
|
|
354
|
+
arr[k.i].value = v
|
|
355
|
+
twChanged++
|
|
356
|
+
}
|
|
357
|
+
}
|
|
324
358
|
if (!changed.length) return { ok: true, changed, files: [], missed }
|
|
325
|
-
texts.set(TWEAKS_FILE, JSON.stringify(raw, null, 2) + '\n')
|
|
359
|
+
if (twChanged) texts.set(TWEAKS_FILE, JSON.stringify(raw, null, 2) + '\n')
|
|
326
360
|
for (const [rel, t] of texts) fs.writeFileSync(path.join(root, rel), t)
|
|
327
361
|
return { ok: true, changed, files: [...texts.keys()], missed }
|
|
328
362
|
}
|