@citisen/dsh-font 0.1.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/LICENSE +21 -0
- package/README.md +241 -0
- package/README.zh.md +165 -0
- package/cordis.patch.yml +20 -0
- package/lib/client.js +847 -0
- package/lib/index.js +239 -0
- package/package.json +76 -0
- package/scripts/build-client.mjs +297 -0
- package/scripts/verify-client.mjs +410 -0
- package/scripts/verify-host.mjs +187 -0
- package/scripts/verify-profile.mjs +146 -0
- package/src/client.js +838 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load an emitted DSH client bundle in Node, assert its envelope, and then run
|
|
3
|
+
* `apply(ctx)` against stub services to prove the presentation path works.
|
|
4
|
+
*
|
|
5
|
+
* A broken client bundle otherwise fails only in the browser, where the
|
|
6
|
+
* diagnostic is a console error inside the boot audit. This check makes the
|
|
7
|
+
* cheap-to-catch failure modes — a bundle that registers nothing, one whose
|
|
8
|
+
* factory throws, one that paints nothing, one that registers no Settings row —
|
|
9
|
+
* fail on the command line instead.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/verify-client.mjs [path/to/lib/client.js]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { dirname, join, resolve } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
|
+
const bundlePath = resolve(process.argv[2] ?? join(root, 'lib', 'client.js'))
|
|
22
|
+
const source = readFileSync(bundlePath, 'utf8')
|
|
23
|
+
|
|
24
|
+
/** The package name, which the bundle id must equal. */
|
|
25
|
+
const PACKAGE_NAME = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name
|
|
26
|
+
|
|
27
|
+
/** Minimal React stub: enough for the row component to build a tree. */
|
|
28
|
+
const react = {
|
|
29
|
+
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
|
|
30
|
+
useCallback: (fn) => fn,
|
|
31
|
+
useEffect: () => undefined,
|
|
32
|
+
useRef: (value) => ({ current: value }),
|
|
33
|
+
useState: (value) => [value, () => undefined],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A tiny observable store, matching the `@deepseek-ai/dsh-client-store` face. */
|
|
37
|
+
const stores = []
|
|
38
|
+
const storeModule = {
|
|
39
|
+
defineStore: (spec) => {
|
|
40
|
+
const state = spec.init()
|
|
41
|
+
const listeners = new Set()
|
|
42
|
+
const handle = {
|
|
43
|
+
spec,
|
|
44
|
+
state,
|
|
45
|
+
create: () =>
|
|
46
|
+
Object.fromEntries(
|
|
47
|
+
Object.entries(spec.actions).map(([name, action]) => [
|
|
48
|
+
name,
|
|
49
|
+
(...args) => {
|
|
50
|
+
action(state, ...args)
|
|
51
|
+
for (const listener of listeners) listener()
|
|
52
|
+
},
|
|
53
|
+
]),
|
|
54
|
+
),
|
|
55
|
+
getSnapshot: () => state,
|
|
56
|
+
subscribe: (listener) => {
|
|
57
|
+
listeners.add(listener)
|
|
58
|
+
return () => listeners.delete(listener)
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
stores.push(handle)
|
|
62
|
+
return handle
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stubs = {
|
|
67
|
+
react,
|
|
68
|
+
'react/jsx-runtime': { jsx: react.createElement, jsxs: react.createElement },
|
|
69
|
+
'react-dom': {},
|
|
70
|
+
'react-dom/client': {},
|
|
71
|
+
'@deepseek-ai/cordis': {},
|
|
72
|
+
'@deepseek-ai/dsh-client-store': storeModule,
|
|
73
|
+
'@deepseek-ai/dsh-client-ui-slots': {},
|
|
74
|
+
'@deepseek-ai/dsh-client-ui-primitives': new Proxy(
|
|
75
|
+
{},
|
|
76
|
+
{ get: (_target, key) => (key === 'then' ? undefined : () => null) },
|
|
77
|
+
),
|
|
78
|
+
'@deepseek-ai/dsh-client-ui-dockkit': {},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const registrations = []
|
|
82
|
+
globalThis.window = {
|
|
83
|
+
__ModuleLoader__: {
|
|
84
|
+
load(registration) {
|
|
85
|
+
registrations.push(registration)
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const requested = []
|
|
91
|
+
const requireStub = (specifier) => {
|
|
92
|
+
requested.push(specifier)
|
|
93
|
+
if (!(specifier in stubs)) throw new Error(`unknown platform module "${specifier}"`)
|
|
94
|
+
return stubs[specifier]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// eslint-disable-next-line no-eval -- the bundle is a classic script by contract
|
|
98
|
+
;(0, eval)(source)
|
|
99
|
+
|
|
100
|
+
assert.equal(registrations.length, 1, 'the bundle must register exactly one factory')
|
|
101
|
+
const [registration] = registrations
|
|
102
|
+
assert.equal(registration.id, PACKAGE_NAME)
|
|
103
|
+
assert.equal(typeof registration.factory, 'function')
|
|
104
|
+
|
|
105
|
+
const plugin = registration.factory(requireStub)
|
|
106
|
+
assert.equal(typeof plugin.apply, 'function', 'bundle must export apply()')
|
|
107
|
+
assert.ok(Array.isArray(plugin.inject), 'bundle must export inject as an array')
|
|
108
|
+
assert.deepEqual(plugin.inject, ['slots', 'locale', 'settingsScope'])
|
|
109
|
+
assert.equal(typeof plugin.fontStyleSheet, 'function')
|
|
110
|
+
assert.equal(typeof plugin.applyFonts, 'function')
|
|
111
|
+
|
|
112
|
+
// The build must have substituted the template's identity placeholder, or the
|
|
113
|
+
// bundle would register the placeholder instead of the real package name.
|
|
114
|
+
assert.ok(
|
|
115
|
+
!source.includes('dsh:plugin-id'),
|
|
116
|
+
'the identity placeholder must be substituted at build time',
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// ── the stylesheet builder ──────────────────────────────────────────────────
|
|
120
|
+
const section = {
|
|
121
|
+
uiFontFamily: 'Inter, sans-serif',
|
|
122
|
+
codeFontFamily: '"JetBrains Mono", monospace',
|
|
123
|
+
uiFontScale: 1.25,
|
|
124
|
+
contentFontSize: 16,
|
|
125
|
+
codeFontSize: 13,
|
|
126
|
+
}
|
|
127
|
+
const sheet = plugin.fontStyleSheet(section)
|
|
128
|
+
assert.match(sheet, /--dsh-font-ui-scale:1\.25;/)
|
|
129
|
+
assert.match(sheet, /--dsh-font-code-size:13px;/)
|
|
130
|
+
// The content size is an INLINE custom property on `body` (ui-layout's theme
|
|
131
|
+
// presenter owns that declaration), so the sheet must not declare it — an
|
|
132
|
+
// inline value would win and nothing here could override it.
|
|
133
|
+
assert.ok(
|
|
134
|
+
!sheet.includes('--dsh-content-font-size:'),
|
|
135
|
+
'the sheet must not declare the inline-owned content size',
|
|
136
|
+
)
|
|
137
|
+
assert.match(sheet, /\.dsh-font-size-14\{font-size:calc\(14px \* var\(--dsh-font-ui-scale,1\)\) !important\}/)
|
|
138
|
+
for (const step of [11, 12, 13, 14, 16, 20, 24]) {
|
|
139
|
+
assert.ok(sheet.includes(`.dsh-font-size-${String(step)}{`), `missing scale class for ${String(step)}px`)
|
|
140
|
+
}
|
|
141
|
+
// The scale is stamped per element, never inherited from a universal rule.
|
|
142
|
+
assert.ok(
|
|
143
|
+
!sheet.includes('html body,html body *'),
|
|
144
|
+
'the scale must not be a universal rule (it would compound with the content size)',
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
// The conversation ladder must be absolute px derived from the content slider.
|
|
148
|
+
assert.match(sheet, /--dsh-font-markdown-h1:700 calc\(16px \+ 7px\) \/ calc\(16px \+ 16px\)/)
|
|
149
|
+
assert.match(sheet, /--dsh-font-markdown-base:var\(--dsh-font-conversation-size,14px\) \/ calc\(16px \+ 10px\)/)
|
|
150
|
+
assert.match(sheet, /--dsw-font-markdown-code-block-font-size:var\(--dsh-font-code-size,12px\) !important;/)
|
|
151
|
+
|
|
152
|
+
// A different content size must move the ladder, not just the base variable.
|
|
153
|
+
const bigger = plugin.fontStyleSheet({ ...section, contentFontSize: 20 })
|
|
154
|
+
assert.match(bigger, /--dsh-font-markdown-h1:700 calc\(20px \+ 7px\) \/ calc\(20px \+ 16px\)/)
|
|
155
|
+
assert.notEqual(bigger, sheet)
|
|
156
|
+
|
|
157
|
+
// ── applyFonts against a DOM stub ───────────────────────────────────────────
|
|
158
|
+
const appended = []
|
|
159
|
+
const styleTags = []
|
|
160
|
+
const rootProperties = new Map()
|
|
161
|
+
const bodyProperties = new Map()
|
|
162
|
+
const makeNode = (tagName) => ({
|
|
163
|
+
tagName,
|
|
164
|
+
id: '',
|
|
165
|
+
textContent: '',
|
|
166
|
+
dataset: {},
|
|
167
|
+
className: '',
|
|
168
|
+
appendChild: (child) => appended.push(child),
|
|
169
|
+
querySelectorAll: () => [],
|
|
170
|
+
classList: { add: () => undefined, remove: () => undefined },
|
|
171
|
+
getAttribute: () => null,
|
|
172
|
+
})
|
|
173
|
+
globalThis.document = {
|
|
174
|
+
head: makeNode('head'),
|
|
175
|
+
body: { ...makeNode('body'), style: { setProperty: (name, value) => bodyProperties.set(name, value) } },
|
|
176
|
+
documentElement: { style: { setProperty: (name, value) => rootProperties.set(name, value) } },
|
|
177
|
+
getElementById: (id) => styleTags.find((tag) => tag.id === id) ?? null,
|
|
178
|
+
createElement: (tagName) => {
|
|
179
|
+
const node = makeNode(tagName)
|
|
180
|
+
styleTags.push(node)
|
|
181
|
+
return node
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
globalThis.console = console
|
|
185
|
+
|
|
186
|
+
plugin.applyFonts(section)
|
|
187
|
+
assert.equal(styleTags.length, 1, 'applyFonts must create one stylesheet')
|
|
188
|
+
assert.equal(styleTags[0].tagName, 'style')
|
|
189
|
+
assert.equal(styleTags[0].id, 'dsh-font/variables')
|
|
190
|
+
assert.equal(styleTags[0].dataset.plugin, 'dsh-font')
|
|
191
|
+
|
|
192
|
+
// The host half looks this element up by the same id when it repaints the
|
|
193
|
+
// pre-paint row; a drift between the two halves would silently stack two
|
|
194
|
+
// stylesheets with the later one winning.
|
|
195
|
+
const hostSource = readFileSync(join(root, 'lib', 'index.js'), 'utf8')
|
|
196
|
+
const hostStyleId = /const FONT_STYLE_ID = '([^']+)'/.exec(hostSource)?.[1]
|
|
197
|
+
assert.equal(
|
|
198
|
+
hostStyleId,
|
|
199
|
+
styleTags[0].id,
|
|
200
|
+
'lib/index.js FONT_STYLE_ID must equal the client half stylesheet id',
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
assert.match(styleTags[0].textContent, /\.dsh-font-size-14\{/)
|
|
204
|
+
assert.equal(rootProperties.get('--dsw-font-family'), 'Inter, sans-serif')
|
|
205
|
+
assert.equal(rootProperties.get('--ds-font-family-code'), '"JetBrains Mono", monospace')
|
|
206
|
+
// The content size must be written where ui-layout writes it, or the inline
|
|
207
|
+
// presenter value would win over the stylesheet.
|
|
208
|
+
assert.equal(bodyProperties.get('--dsh-content-font-size'), '16px')
|
|
209
|
+
|
|
210
|
+
// Re-applying must rewrite the same tag, not accumulate stylesheets.
|
|
211
|
+
plugin.applyFonts({ ...section, contentFontSize: 18, uiFontScale: 1 })
|
|
212
|
+
assert.equal(styleTags.length, 1, 'applyFonts must reuse its own stylesheet tag')
|
|
213
|
+
assert.match(styleTags[0].textContent, /--dsh-font-ui-scale:1;/)
|
|
214
|
+
assert.equal(bodyProperties.get('--dsh-content-font-size'), '18px')
|
|
215
|
+
|
|
216
|
+
// ── the interface-scale stamping pass ───────────────────────────────────────
|
|
217
|
+
// A fake tree whose computed sizes are the shipped ones.
|
|
218
|
+
const sizes = new Map()
|
|
219
|
+
const fakeElement = (size, extras = {}) => {
|
|
220
|
+
const classes = new Set()
|
|
221
|
+
const element = {
|
|
222
|
+
className: 'x',
|
|
223
|
+
classList: {
|
|
224
|
+
contains: (name) => classes.has(name),
|
|
225
|
+
add: (name) => classes.add(name),
|
|
226
|
+
remove: (name) => classes.delete(name),
|
|
227
|
+
},
|
|
228
|
+
getAttribute: () => null,
|
|
229
|
+
classes,
|
|
230
|
+
...extras,
|
|
231
|
+
}
|
|
232
|
+
sizes.set(element, `${String(size)}px`)
|
|
233
|
+
return element
|
|
234
|
+
}
|
|
235
|
+
const shipped14 = fakeElement(14)
|
|
236
|
+
const shipped12 = fakeElement(12)
|
|
237
|
+
const shipped9 = fakeElement(9)
|
|
238
|
+
const shipped17 = fakeElement(17)
|
|
239
|
+
const inlineStyled = fakeElement(16)
|
|
240
|
+
inlineStyled.className = ''
|
|
241
|
+
inlineStyled.getAttribute = (name) => (name === 'style' ? 'color:red' : null)
|
|
242
|
+
|
|
243
|
+
const allElements = [shipped14, shipped12, shipped9, shipped17, inlineStyled]
|
|
244
|
+
const stampedSelector = /\.dsh-font-size-(\d+)(?:,|$)/
|
|
245
|
+
globalThis.document = {
|
|
246
|
+
head: makeNode('head'),
|
|
247
|
+
body: {
|
|
248
|
+
...makeNode('body'),
|
|
249
|
+
style: { setProperty: () => undefined },
|
|
250
|
+
querySelectorAll: (selector) =>
|
|
251
|
+
selector.startsWith('.dsh-font-size-')
|
|
252
|
+
? allElements.filter((element) => [...element.classes].some((name) => name.startsWith('dsh-font-size-')))
|
|
253
|
+
: allElements,
|
|
254
|
+
},
|
|
255
|
+
documentElement: { style: { setProperty: () => undefined } },
|
|
256
|
+
getElementById: () => null,
|
|
257
|
+
createElement: (tagName) => makeNode(tagName),
|
|
258
|
+
}
|
|
259
|
+
globalThis.getComputedStyle = (element) => ({ fontSize: sizes.get(element) ?? '' })
|
|
260
|
+
|
|
261
|
+
plugin.applyFonts({ ...section, uiFontScale: 1.25 })
|
|
262
|
+
assert.ok(shipped14.classes.has('dsh-font-size-14'), '14px must be stamped')
|
|
263
|
+
assert.ok(shipped12.classes.has('dsh-font-size-12'), '12px must be stamped')
|
|
264
|
+
assert.ok(inlineStyled.classes.has('dsh-font-size-16'), 'inline-styled 16px must be stamped')
|
|
265
|
+
assert.equal(shipped9.classes.size, 0, 'an unlisted size must not be stamped')
|
|
266
|
+
assert.equal(shipped17.classes.size, 0, 'an unlisted size must not be stamped')
|
|
267
|
+
|
|
268
|
+
// A repeat pass reuses the measurement and does not duplicate stamps.
|
|
269
|
+
plugin.applyFonts({ ...section, uiFontScale: 1.5 })
|
|
270
|
+
assert.equal(shipped14.classes.size, 1, 'a repeat pass must not accumulate stamps')
|
|
271
|
+
|
|
272
|
+
// Returning to 1 must clear every stamp.
|
|
273
|
+
plugin.applyFonts({ ...section, uiFontScale: 1 })
|
|
274
|
+
assert.equal(shipped14.classes.size, 0, 'scale 1 must clear the stamps')
|
|
275
|
+
assert.equal(inlineStyled.classes.size, 0, 'scale 1 must clear the stamps')
|
|
276
|
+
void stampedSelector
|
|
277
|
+
|
|
278
|
+
// Restore a recording DOM for the remaining checks.
|
|
279
|
+
rootProperties.clear()
|
|
280
|
+
bodyProperties.clear()
|
|
281
|
+
globalThis.document = {
|
|
282
|
+
head: makeNode('head'),
|
|
283
|
+
body: { ...makeNode('body'), style: { setProperty: (name, value) => bodyProperties.set(name, value) } },
|
|
284
|
+
documentElement: { style: { setProperty: (name, value) => rootProperties.set(name, value) } },
|
|
285
|
+
getElementById: (id) => styleTags.find((tag) => tag.id === id) ?? null,
|
|
286
|
+
createElement: (tagName) => {
|
|
287
|
+
const node = makeNode(tagName)
|
|
288
|
+
styleTags.push(node)
|
|
289
|
+
return node
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── apply(ctx) end to end ───────────────────────────────────────────────────
|
|
294
|
+
const themeOverrides = []
|
|
295
|
+
const registeredSlots = []
|
|
296
|
+
const dictionaries = []
|
|
297
|
+
const locale = {
|
|
298
|
+
register: (namespace, dict) => {
|
|
299
|
+
dictionaries.push({ namespace, dict })
|
|
300
|
+
return () => undefined
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let scopeListener
|
|
305
|
+
const scope = {
|
|
306
|
+
getSnapshot: () => ({
|
|
307
|
+
status: 'ready',
|
|
308
|
+
value: section,
|
|
309
|
+
revision: 7,
|
|
310
|
+
writable: true,
|
|
311
|
+
mode: 'host',
|
|
312
|
+
}),
|
|
313
|
+
subscribe: (listener) => {
|
|
314
|
+
scopeListener = listener
|
|
315
|
+
return () => undefined
|
|
316
|
+
},
|
|
317
|
+
set: () => Promise.resolve(),
|
|
318
|
+
unset: () => Promise.resolve(),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const ctx = {
|
|
322
|
+
effect: (execute) => {
|
|
323
|
+
execute()
|
|
324
|
+
return { dispose: () => undefined }
|
|
325
|
+
},
|
|
326
|
+
on: () => undefined,
|
|
327
|
+
get: (name) => (name === 'theme' ? { overrideTokens: (source, tokens) => themeOverrides.push({ source, tokens }) } : undefined),
|
|
328
|
+
locale,
|
|
329
|
+
settingsScope: { bind: (spec) => (assert.equal(spec.namespace, 'ui-font'), scope) },
|
|
330
|
+
slots: {
|
|
331
|
+
inject: (name, callback) => {
|
|
332
|
+
assert.equal(name, 'settings.general.item')
|
|
333
|
+
callback()
|
|
334
|
+
},
|
|
335
|
+
register: (options, component) => {
|
|
336
|
+
registeredSlots.push({ options, component })
|
|
337
|
+
return () => undefined
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
plugin.apply(ctx)
|
|
343
|
+
|
|
344
|
+
assert.equal(themeOverrides.length, 1, 'the families must be stacked onto the theme')
|
|
345
|
+
assert.equal(themeOverrides[0].source, PACKAGE_NAME)
|
|
346
|
+
assert.deepEqual(Object.keys(themeOverrides[0].tokens).sort(), ['--ds-font-family-code', '--dsw-font-family'])
|
|
347
|
+
assert.equal(themeOverrides[0].tokens['--dsw-font-family'].light, section.uiFontFamily)
|
|
348
|
+
assert.equal(themeOverrides[0].tokens['--dsw-font-family'].dark, section.uiFontFamily)
|
|
349
|
+
|
|
350
|
+
assert.equal(dictionaries.length, 1)
|
|
351
|
+
assert.deepEqual(Object.keys(dictionaries[0].dict.zh).sort(), Object.keys(dictionaries[0].dict.en).sort())
|
|
352
|
+
|
|
353
|
+
assert.equal(registeredSlots.length, 1)
|
|
354
|
+
const [{ options, component }] = registeredSlots
|
|
355
|
+
assert.equal(options.id, 'font')
|
|
356
|
+
assert.equal(options.name, 'settings.general.item')
|
|
357
|
+
assert.equal(options.locale, 'settings.font')
|
|
358
|
+
assert.ok(stores.length >= 1, 'the row must register a store')
|
|
359
|
+
assert.equal(typeof component, 'function')
|
|
360
|
+
|
|
361
|
+
// The row's inject face must expose the two write paths.
|
|
362
|
+
const actions = options.inject(options.store.create())
|
|
363
|
+
assert.equal(typeof actions.setField, 'function')
|
|
364
|
+
assert.equal(typeof actions.reset, 'function')
|
|
365
|
+
|
|
366
|
+
// The component must render a tree containing the localized labels.
|
|
367
|
+
const rendered = component({
|
|
368
|
+
t: (key) => key,
|
|
369
|
+
useStore: (selector) => selector(options.store.getSnapshot()),
|
|
370
|
+
...actions,
|
|
371
|
+
})
|
|
372
|
+
const labels = []
|
|
373
|
+
const collect = (node) => {
|
|
374
|
+
if (node === null || node === undefined) return
|
|
375
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
376
|
+
labels.push(String(node))
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (typeof node !== 'object') return
|
|
380
|
+
const children = Array.isArray(node.children) ? node.children : [node.children]
|
|
381
|
+
for (const child of children) collect(child)
|
|
382
|
+
// `Field` and `SliderControl` carry their copy in props, because the stubs
|
|
383
|
+
// above do not render function components.
|
|
384
|
+
collect(node.props?.children)
|
|
385
|
+
for (const key of ['label', 'value', 'hint', 'ariaLabel', 'placeholder']) {
|
|
386
|
+
collect(node.props?.[key])
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
collect(rendered)
|
|
390
|
+
for (const key of [
|
|
391
|
+
'font.title',
|
|
392
|
+
'font.uiFamily',
|
|
393
|
+
'font.codeFamily',
|
|
394
|
+
'font.uiScale',
|
|
395
|
+
'font.contentSize',
|
|
396
|
+
'font.codeSize',
|
|
397
|
+
'font.reset',
|
|
398
|
+
]) {
|
|
399
|
+
assert.ok(labels.includes(key), `rendered row is missing ${key}`)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// A pushed settings change must repaint.
|
|
403
|
+
rootProperties.clear()
|
|
404
|
+
scopeListener()
|
|
405
|
+
assert.equal(rootProperties.get('--dsw-font-family'), section.uiFontFamily)
|
|
406
|
+
|
|
407
|
+
delete globalThis.document
|
|
408
|
+
|
|
409
|
+
console.log('verify-client: OK — envelope, stylesheet, theme stacking, and settings row all verified')
|
|
410
|
+
console.log(`verify-client: factory required ${requested.join(', ')}`)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify the host half without a running DSH: import `lib/index.js`, exercise
|
|
3
|
+
* the pure builders against the real `@deepseek-ai/schemastery`, execute the
|
|
4
|
+
* emitted pre-paint bootstrap against a DOM stub, and drive `apply(ctx)` with a
|
|
5
|
+
* stub context to prove the namespace registers and the index-injection row
|
|
6
|
+
* reaches the webserver table.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node scripts/verify-host.mjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { createRequire } from 'node:module'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { pathToFileURL } from 'node:url'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Import `lib/index.js` through the installed profile when one exists.
|
|
23
|
+
*
|
|
24
|
+
* The host half imports `@deepseek-ai/schemastery`, which dsh supplies to a
|
|
25
|
+
* plugin from the installation's module fallback rather than from the plugin's
|
|
26
|
+
* own tree. Importing through the profile link reproduces that resolution, so
|
|
27
|
+
* this check also proves the plugin's runtime dependencies actually resolve at
|
|
28
|
+
* the place dsh will load it from. Falling back to the direct path keeps the
|
|
29
|
+
* check usable in a bare checkout.
|
|
30
|
+
*/
|
|
31
|
+
async function importHost() {
|
|
32
|
+
const dshHome = process.env.DSH_HOME
|
|
33
|
+
const profile = process.env.DSH_PROFILE ?? 'web'
|
|
34
|
+
if (dshHome !== undefined) {
|
|
35
|
+
const anchor = join(dshHome, 'profiles', profile, 'package.json')
|
|
36
|
+
if (existsSync(anchor)) {
|
|
37
|
+
try {
|
|
38
|
+
// Resolve the bare specifier: `exports` maps "." to lib/index.js, and a
|
|
39
|
+
// direct `dsh-font/lib/index.js` path is deliberately not exported.
|
|
40
|
+
const resolved = createRequire(anchor).resolve('dsh-font')
|
|
41
|
+
return { host: await import(pathToFileURL(resolved).href), via: resolved }
|
|
42
|
+
} catch {
|
|
43
|
+
/* not installed in that profile; fall through to the direct import */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const direct = join(root, 'lib', 'index.js')
|
|
48
|
+
return { host: await import(pathToFileURL(direct).href), via: direct }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { host, via } = await importHost()
|
|
52
|
+
console.log(`verify-host: loaded ${via}`)
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
FONT_SETTINGS_NAMESPACE,
|
|
56
|
+
FontSettingsSchema,
|
|
57
|
+
DEFAULT_UI_FONT_FAMILY,
|
|
58
|
+
DEFAULT_CODE_FONT_FAMILY,
|
|
59
|
+
FONT_STYLE_ID,
|
|
60
|
+
fontStyleSheet,
|
|
61
|
+
fontBootstrapScript,
|
|
62
|
+
fontInjection,
|
|
63
|
+
apply,
|
|
64
|
+
} = host
|
|
65
|
+
|
|
66
|
+
assert.equal(FONT_SETTINGS_NAMESPACE, 'ui-font')
|
|
67
|
+
|
|
68
|
+
// The schema resolves a complete default section — what an absent settings
|
|
69
|
+
// document must produce for the pre-paint bootstrap to be a no-op.
|
|
70
|
+
const defaults = FontSettingsSchema({})
|
|
71
|
+
assert.equal(defaults.uiFontFamily, DEFAULT_UI_FONT_FAMILY)
|
|
72
|
+
assert.equal(defaults.codeFontFamily, DEFAULT_CODE_FONT_FAMILY)
|
|
73
|
+
assert.equal(defaults.uiFontScale, 1)
|
|
74
|
+
assert.equal(defaults.contentFontSize, 14)
|
|
75
|
+
assert.equal(defaults.codeFontSize, 12)
|
|
76
|
+
|
|
77
|
+
// Out-of-range values must be rejected at the wire boundary.
|
|
78
|
+
assert.throws(() => FontSettingsSchema({ contentFontSize: 99 }))
|
|
79
|
+
assert.throws(() => FontSettingsSchema({ uiFontScale: 0.1 }))
|
|
80
|
+
|
|
81
|
+
// The pre-paint sheet carries the scale and both size axes, one rule per
|
|
82
|
+
// hard-coded UI text step.
|
|
83
|
+
const sheet = fontStyleSheet(defaults)
|
|
84
|
+
assert.match(sheet, /--dsh-font-ui-scale:1;/)
|
|
85
|
+
assert.match(sheet, /--dsh-font-content-size:14px/)
|
|
86
|
+
assert.match(sheet, /--dsh-font-code-size:12px/)
|
|
87
|
+
assert.match(sheet, /font-size:calc\(14px \* var\(--dsh-font-ui-scale,1\)\) !important/)
|
|
88
|
+
for (const step of [11, 12, 13, 14, 16, 20, 24]) {
|
|
89
|
+
assert.ok(sheet.includes(`calc(${String(step)}px * `), `missing scale rule for ${String(step)}px`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const script = fontBootstrapScript(defaults)
|
|
93
|
+
assert.ok(script.includes(JSON.stringify(FONT_STYLE_ID)), 'bootstrap must key on the stylesheet id')
|
|
94
|
+
assert.ok(!script.includes('</script'), 'bootstrap must not close its own script element')
|
|
95
|
+
|
|
96
|
+
// Execute the bootstrap against a DOM stub and read back what it wrote.
|
|
97
|
+
const appended = []
|
|
98
|
+
const created = []
|
|
99
|
+
const element = () => ({
|
|
100
|
+
id: '',
|
|
101
|
+
textContent: '',
|
|
102
|
+
dataset: {},
|
|
103
|
+
appendChild: (child) => appended.push(child),
|
|
104
|
+
})
|
|
105
|
+
const rootStyle = new Map()
|
|
106
|
+
globalThis.document = {
|
|
107
|
+
head: element(),
|
|
108
|
+
documentElement: { style: { setProperty: (name, value) => rootStyle.set(name, value) } },
|
|
109
|
+
getElementById: () => null,
|
|
110
|
+
createElement: (tag) => {
|
|
111
|
+
const node = { ...element(), tagName: tag }
|
|
112
|
+
created.push(node)
|
|
113
|
+
return node
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
globalThis.console = console
|
|
117
|
+
// eslint-disable-next-line no-eval -- the emitted pre-paint script is a classic script by contract
|
|
118
|
+
;(0, eval)(script)
|
|
119
|
+
|
|
120
|
+
assert.equal(created.length, 1)
|
|
121
|
+
assert.equal(created[0].tagName, 'style')
|
|
122
|
+
assert.equal(created[0].id, FONT_STYLE_ID)
|
|
123
|
+
assert.match(created[0].textContent, /--dsh-font-ui-scale:1;/)
|
|
124
|
+
assert.equal(appended.length, 1)
|
|
125
|
+
assert.equal(rootStyle.get('--dsw-font-family'), DEFAULT_UI_FONT_FAMILY)
|
|
126
|
+
assert.equal(rootStyle.get('--ds-font-family-code'), DEFAULT_CODE_FONT_FAMILY)
|
|
127
|
+
delete globalThis.document
|
|
128
|
+
|
|
129
|
+
// A non-default section must flow through both the sheet and the script.
|
|
130
|
+
const custom = { ...defaults, uiFontScale: 1.25, contentFontSize: 16, codeFontSize: 13 }
|
|
131
|
+
assert.match(fontStyleSheet(custom), /--dsh-font-content-size:16px/)
|
|
132
|
+
assert.match(fontStyleSheet(custom), /font-size:calc\(16px \* var\(--dsh-font-ui-scale,1\)\)/)
|
|
133
|
+
|
|
134
|
+
const injection = fontInjection(defaults)
|
|
135
|
+
assert.equal(injection.kind, 'script')
|
|
136
|
+
assert.equal(injection.placement, 'head')
|
|
137
|
+
|
|
138
|
+
// Drive apply(ctx) with a stub that records the namespace and the injection.
|
|
139
|
+
const registered = []
|
|
140
|
+
const listeners = new Map()
|
|
141
|
+
let injectedSettings
|
|
142
|
+
const ctx = {
|
|
143
|
+
inject(deps, callback) {
|
|
144
|
+
assert.deepEqual(deps, ['settings'])
|
|
145
|
+
injectedSettings = {
|
|
146
|
+
settings: {
|
|
147
|
+
register(namespace, schema) {
|
|
148
|
+
registered.push({ namespace, schema })
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
callback(injectedSettings)
|
|
153
|
+
},
|
|
154
|
+
on(event, listener) {
|
|
155
|
+
listeners.set(event, listener)
|
|
156
|
+
},
|
|
157
|
+
get(name) {
|
|
158
|
+
return name === 'settings' ? injectedSettings.settings : undefined
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
apply(ctx)
|
|
163
|
+
|
|
164
|
+
assert.equal(registered.length, 1)
|
|
165
|
+
assert.equal(registered[0].namespace, 'ui-font')
|
|
166
|
+
assert.ok(registered[0].schema !== undefined)
|
|
167
|
+
|
|
168
|
+
const table = []
|
|
169
|
+
listeners.get('webserver/index-inject')(table)
|
|
170
|
+
assert.equal(table.length, 1)
|
|
171
|
+
assert.equal(table[0].kind, 'script')
|
|
172
|
+
|
|
173
|
+
// With no provider at all, apply() must still answer the injection table with
|
|
174
|
+
// the schema defaults rather than throwing.
|
|
175
|
+
const bareListeners = new Map()
|
|
176
|
+
apply({
|
|
177
|
+
inject: () => undefined,
|
|
178
|
+
on: (event, listener) => bareListeners.set(event, listener),
|
|
179
|
+
get: () => undefined,
|
|
180
|
+
})
|
|
181
|
+
const bareTable = []
|
|
182
|
+
bareListeners.get('webserver/index-inject')(bareTable)
|
|
183
|
+
assert.equal(bareTable.length, 1)
|
|
184
|
+
assert.match(bareTable[0].text, /--dsh-font-ui-scale:1;/)
|
|
185
|
+
|
|
186
|
+
console.log('verify-host: OK — namespace registered, pre-paint script executed, injection row emitted')
|
|
187
|
+
console.log(`verify-host: stylesheet ${String(sheet.length)} chars, script ${String(script.length)} chars`)
|