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/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
- export function createHub({ log = () => {}, onChange = () => {}, history = null, parents = () => [], strict = null } = {}) {
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: 值 }。CSS 变量改样式里的默认值,属性写到每屏的 <html> 上,tweaks.json 的 value 跟着改。
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
- if (!r0 || !r0.list.length) return { ok: false, reason: 'no-tweaks' }
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
- if (!ownFile(root, realRoot, TWEAKS_FILE)) return { ok: false, reason: 'no-tweaks' }
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
- const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
289
- const arr = Array.isArray(raw) ? raw : raw.tweaks
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
- for (const k of r0.list) {
295
- if (!values || !Object.hasOwn(values, k.id)) continue
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
- // 先把所有文件里顶层的声明都改掉(哪份样式后加载就是哪份生效,全改才稳);一处都没有再退到 @media 里的第一处
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-design-studio",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1 test/*.mjs"