@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of `dsh-font`.
|
|
3
|
+
*
|
|
4
|
+
* A dsh Web profile bundle has two halves. This file is the Node half: it owns
|
|
5
|
+
* the durable `ui-font` settings namespace (so every value the Settings row
|
|
6
|
+
* writes survives a restart inside `$DSH_HOME/settings.yaml`) and it
|
|
7
|
+
* contributes a pre-paint `<style>` row to the served index document, so the
|
|
8
|
+
* chosen families and sizes apply to the very first frame instead of flashing
|
|
9
|
+
* the shipped defaults while the browser plugin tree activates.
|
|
10
|
+
*
|
|
11
|
+
* The browser half lives in `./client` (`src/client.js` -> `lib/client.js`) and
|
|
12
|
+
* owns the actual presentation: it stacks the families onto the design system's
|
|
13
|
+
* font tokens through the theme service and registers the Settings row.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-font
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import z from '@deepseek-ai/schemastery'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Settings namespace owned by this plugin. Lowercase-hyphenated, matching the
|
|
22
|
+
* sibling `ui-theme` namespace the Web surface already ships.
|
|
23
|
+
*/
|
|
24
|
+
export const FONT_SETTINGS_NAMESPACE = 'ui-font'
|
|
25
|
+
|
|
26
|
+
/** Field: CSS font-family list for interface (non-code) text. */
|
|
27
|
+
export const UI_FONT_FAMILY_FIELD = 'uiFontFamily'
|
|
28
|
+
/** Field: CSS font-family list for code, terminal, and monospace text. */
|
|
29
|
+
export const CODE_FONT_FAMILY_FIELD = 'codeFontFamily'
|
|
30
|
+
/** Field: scale applied to every interface font size, as a unitless number. */
|
|
31
|
+
export const UI_FONT_SCALE_FIELD = 'uiFontScale'
|
|
32
|
+
/** Field: conversation content font size in px (owns `--dsh-content-font-size`). */
|
|
33
|
+
export const CONTENT_FONT_SIZE_FIELD = 'contentFontSize'
|
|
34
|
+
/** Field: conversation code font size in px. */
|
|
35
|
+
export const CODE_FONT_SIZE_FIELD = 'codeFontSize'
|
|
36
|
+
|
|
37
|
+
/** Shipped interface family, mirroring `ui-theme`'s `--dsw-font-family`. */
|
|
38
|
+
export const DEFAULT_UI_FONT_FAMILY =
|
|
39
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif'
|
|
40
|
+
/** Shipped code family, mirroring `ui-theme`'s `--ds-font-family-code`. */
|
|
41
|
+
export const DEFAULT_CODE_FONT_FAMILY =
|
|
42
|
+
'"SF Mono", "JetBrains Mono", "Fira Code", Consolas, "Liberation Mono", Menlo, Courier, "PingFang SC", "Microsoft YaHei"'
|
|
43
|
+
|
|
44
|
+
/** Accepted interface scale range (1 = the shipped sizes). */
|
|
45
|
+
export const UI_FONT_SCALE_MIN = 0.75
|
|
46
|
+
/** Accepted interface scale range upper bound. */
|
|
47
|
+
export const UI_FONT_SCALE_MAX = 1.5
|
|
48
|
+
/** Accepted conversation content font size range in px. */
|
|
49
|
+
export const CONTENT_FONT_SIZE_MIN = 12
|
|
50
|
+
/** Accepted conversation content font size range in px. */
|
|
51
|
+
export const CONTENT_FONT_SIZE_MAX = 20
|
|
52
|
+
/** Accepted code font size range in px. */
|
|
53
|
+
export const CODE_FONT_SIZE_MIN = 10
|
|
54
|
+
/** Accepted code font size range in px. */
|
|
55
|
+
export const CODE_FONT_SIZE_MAX = 20
|
|
56
|
+
|
|
57
|
+
/** The one stylesheet id the pre-paint bootstrap and the browser half share. */
|
|
58
|
+
export const FONT_STYLE_ID = 'dsh-font/variables'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Durable font preferences. Every field defaults to the shipped value, so an
|
|
62
|
+
* empty (or absent) `ui-font` section resolves to "no customization".
|
|
63
|
+
*
|
|
64
|
+
* The schema is the wire contract: the settings domain validates every write
|
|
65
|
+
* from the Settings row against it, and the configuration surface renders it.
|
|
66
|
+
*/
|
|
67
|
+
export const FontSettingsSchema = z.object({
|
|
68
|
+
[UI_FONT_FAMILY_FIELD]: z.string().default(DEFAULT_UI_FONT_FAMILY),
|
|
69
|
+
[CODE_FONT_FAMILY_FIELD]: z.string().default(DEFAULT_CODE_FONT_FAMILY),
|
|
70
|
+
[UI_FONT_SCALE_FIELD]: z
|
|
71
|
+
.number()
|
|
72
|
+
.min(UI_FONT_SCALE_MIN)
|
|
73
|
+
.max(UI_FONT_SCALE_MAX)
|
|
74
|
+
.default(1),
|
|
75
|
+
[CONTENT_FONT_SIZE_FIELD]: z
|
|
76
|
+
.number()
|
|
77
|
+
.step(1)
|
|
78
|
+
.min(CONTENT_FONT_SIZE_MIN)
|
|
79
|
+
.max(CONTENT_FONT_SIZE_MAX)
|
|
80
|
+
.default(14),
|
|
81
|
+
[CODE_FONT_SIZE_FIELD]: z
|
|
82
|
+
.number()
|
|
83
|
+
.step(1)
|
|
84
|
+
.min(CODE_FONT_SIZE_MIN)
|
|
85
|
+
.max(CODE_FONT_SIZE_MAX)
|
|
86
|
+
.default(12),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
/** The interface font sizes the shipped components hard-code, in px. */
|
|
90
|
+
const UI_TEXT_STEPS = [11, 12, 13, 14, 16, 20, 24]
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The interface-scale rules: every hard-coded UI text size is re-derived from
|
|
94
|
+
* the user's scale. Sizes outside {@link UI_TEXT_STEPS} (display headings, the
|
|
95
|
+
* SVG labels baked into file-type icons) are deliberately left alone.
|
|
96
|
+
*
|
|
97
|
+
* @param scale - unitless multiplier, 1 = shipped sizes.
|
|
98
|
+
* @returns one CSS declaration block body.
|
|
99
|
+
*/
|
|
100
|
+
function uiScaleRules(scale) {
|
|
101
|
+
return UI_TEXT_STEPS.map(
|
|
102
|
+
(step) =>
|
|
103
|
+
`font-size:calc(${step}px * var(--dsh-font-ui-scale,1)) !important`,
|
|
104
|
+
).join(';')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build the pre-paint stylesheet for one resolved settings section.
|
|
109
|
+
*
|
|
110
|
+
* This is the subset of the browser half's sheet that must exist before any
|
|
111
|
+
* plugin runs: the interface scale and the conversation text size. Families are
|
|
112
|
+
* handled separately (see {@link fontBootstrapScript}) because the design
|
|
113
|
+
* system resolves them through `var()` chains the shell's own `body` rule
|
|
114
|
+
* already reads.
|
|
115
|
+
*
|
|
116
|
+
* Written as a `<style>` element rather than inline custom properties because
|
|
117
|
+
* `ui-layout`'s theme presenter owns `document.body.style` and rewrites the
|
|
118
|
+
* token set on every theme change; a stylesheet cannot be clobbered by it.
|
|
119
|
+
* The selectors are `html body ...` so they outrank the shipped component
|
|
120
|
+
* rules regardless of stylesheet order.
|
|
121
|
+
*
|
|
122
|
+
* @param section - resolved `ui-font` value (validated or schema-defaulted).
|
|
123
|
+
* @returns the complete stylesheet text.
|
|
124
|
+
*/
|
|
125
|
+
export function fontStyleSheet(section) {
|
|
126
|
+
const uiScale = section[UI_FONT_SCALE_FIELD]
|
|
127
|
+
const contentSize = section[CONTENT_FONT_SIZE_FIELD]
|
|
128
|
+
const codeSize = section[CODE_FONT_SIZE_FIELD]
|
|
129
|
+
|
|
130
|
+
return [
|
|
131
|
+
'/* dsh-font: pre-paint variables and interface scale */',
|
|
132
|
+
'html body{',
|
|
133
|
+
`--dsh-font-ui-scale:${String(uiScale)};`,
|
|
134
|
+
`--dsh-font-content-size:${String(contentSize)}px;`,
|
|
135
|
+
`--dsh-font-code-size:${String(codeSize)}px;`,
|
|
136
|
+
'}',
|
|
137
|
+
'html body,html body *{',
|
|
138
|
+
uiScaleRules(uiScale),
|
|
139
|
+
'}',
|
|
140
|
+
].join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Escape a stylesheet for embedding inside an inline `<script>` element.
|
|
145
|
+
* @param text - raw stylesheet text.
|
|
146
|
+
* @returns the text safe to place in a script body.
|
|
147
|
+
*/
|
|
148
|
+
function escapeForScript(text) {
|
|
149
|
+
return text.replaceAll('</', '<\\/')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The pre-paint bootstrap: inject the stylesheet, then mirror the two family
|
|
154
|
+
* values onto the design system's own tokens so pre-existing CSS that reads
|
|
155
|
+
* `var(--dsw-font-family)` (the shell's `body` rule, for instance) agrees with
|
|
156
|
+
* the hard-coded variable set before the browser half ever runs.
|
|
157
|
+
*
|
|
158
|
+
* @param section - resolved `ui-font` value.
|
|
159
|
+
* @returns inline script text.
|
|
160
|
+
*/
|
|
161
|
+
export function fontBootstrapScript(section) {
|
|
162
|
+
const uiFamily = JSON.stringify(section[UI_FONT_FAMILY_FIELD])
|
|
163
|
+
const codeFamily = JSON.stringify(section[CODE_FONT_FAMILY_FIELD])
|
|
164
|
+
// Keep the stylesheet after the user-controlled family strings so the
|
|
165
|
+
// `</`-escaping cannot be defeated by a family value that itself contains a
|
|
166
|
+
// script-closing sequence.
|
|
167
|
+
const css = escapeForScript(fontStyleSheet(section))
|
|
168
|
+
|
|
169
|
+
return `(() => {
|
|
170
|
+
try {
|
|
171
|
+
const css = ${JSON.stringify(css)}
|
|
172
|
+
const existing = document.getElementById(${JSON.stringify(FONT_STYLE_ID)})
|
|
173
|
+
const tag = existing !== null ? existing : document.createElement('style')
|
|
174
|
+
if (existing === null) {
|
|
175
|
+
tag.id = ${JSON.stringify(FONT_STYLE_ID)}
|
|
176
|
+
tag.dataset.plugin = 'dsh-font'
|
|
177
|
+
const parent = document.head || document.documentElement
|
|
178
|
+
parent.appendChild(tag)
|
|
179
|
+
}
|
|
180
|
+
tag.textContent = css
|
|
181
|
+
const root = document.documentElement.style
|
|
182
|
+
root.setProperty('--dsw-font-family', ${uiFamily})
|
|
183
|
+
root.setProperty('--ds-font-family-code', ${codeFamily})
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.warn('dsh-font: pre-paint bootstrap failed', error)
|
|
186
|
+
}
|
|
187
|
+
})()`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The bootstrap as a webserver index-injection row.
|
|
192
|
+
* @param section - resolved `ui-font` value.
|
|
193
|
+
* @returns the head script row.
|
|
194
|
+
*/
|
|
195
|
+
export function fontInjection(section) {
|
|
196
|
+
return {
|
|
197
|
+
kind: 'script',
|
|
198
|
+
placement: 'head',
|
|
199
|
+
text: fontBootstrapScript(section),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Read the registered `ui-font` section, or the schema defaults when the
|
|
205
|
+
* settings provider is absent or the section is unreadable.
|
|
206
|
+
* @param ctx - host context.
|
|
207
|
+
* @returns a complete settings section.
|
|
208
|
+
*/
|
|
209
|
+
function readSection(ctx) {
|
|
210
|
+
const fallback = {
|
|
211
|
+
[UI_FONT_FAMILY_FIELD]: DEFAULT_UI_FONT_FAMILY,
|
|
212
|
+
[CODE_FONT_FAMILY_FIELD]: DEFAULT_CODE_FONT_FAMILY,
|
|
213
|
+
[UI_FONT_SCALE_FIELD]: 1,
|
|
214
|
+
[CONTENT_FONT_SIZE_FIELD]: 14,
|
|
215
|
+
[CODE_FONT_SIZE_FIELD]: 12,
|
|
216
|
+
}
|
|
217
|
+
const settings = ctx.get('settings')
|
|
218
|
+
if (settings === null || typeof settings !== 'object') return fallback
|
|
219
|
+
if (typeof settings.get !== 'function') return fallback
|
|
220
|
+
const section = settings.get(FONT_SETTINGS_NAMESPACE)
|
|
221
|
+
if (section === null || typeof section !== 'object') return fallback
|
|
222
|
+
return { ...fallback, ...section }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Host plugin body: register the durable namespace when the optional settings
|
|
227
|
+
* provider is composed, and answer every index-injection collection with the
|
|
228
|
+
* current pre-paint bootstrap.
|
|
229
|
+
* @param ctx - host context that may acquire the settings service.
|
|
230
|
+
*/
|
|
231
|
+
export function apply(ctx) {
|
|
232
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
233
|
+
settingsCtx.settings.register(FONT_SETTINGS_NAMESPACE, FontSettingsSchema)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
ctx.on('webserver/index-inject', (table) => {
|
|
237
|
+
table.push(fontInjection(readSection(ctx)))
|
|
238
|
+
})
|
|
239
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@citisen/dsh-font",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: customize the Web GUI fonts (interface family, code family, and three font-size axes) from Settings",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "citisen",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/citisen/dsh-font.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/citisen/dsh-font#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/citisen/dsh-font/issues"
|
|
15
|
+
},
|
|
16
|
+
"main": "lib/index.js",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./lib/index.js",
|
|
19
|
+
"./client": "./lib/client.js",
|
|
20
|
+
"./src/*": "./src/*",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib/index.js",
|
|
25
|
+
"lib/client.js",
|
|
26
|
+
"cordis.patch.yml",
|
|
27
|
+
"src",
|
|
28
|
+
"scripts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"README.zh.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
},
|
|
40
|
+
"client": {
|
|
41
|
+
"platform": "web",
|
|
42
|
+
"immediately": true,
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-theme"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "node scripts/build-client.mjs",
|
|
50
|
+
"watch": "node scripts/build-client.mjs --watch",
|
|
51
|
+
"verify": "node scripts/verify-host.mjs && node scripts/verify-client.mjs",
|
|
52
|
+
"check": "node scripts/build-client.mjs --check && npm run verify"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"@deepseek-ai/cordis": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"keywords": [
|
|
63
|
+
"deepseek-harness",
|
|
64
|
+
"deepseek",
|
|
65
|
+
"dsh",
|
|
66
|
+
"dsh-plugin",
|
|
67
|
+
"dsh-bundle",
|
|
68
|
+
"font",
|
|
69
|
+
"fonts",
|
|
70
|
+
"font-family",
|
|
71
|
+
"font-size",
|
|
72
|
+
"theme",
|
|
73
|
+
"web-ui",
|
|
74
|
+
"web-gui"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build `lib/client.js` from `src/client.js`.
|
|
3
|
+
*
|
|
4
|
+
* DSH client bundles are classic scripts, not ES modules: the browser shell
|
|
5
|
+
* loads them with a plain `<script>` element and they may only register a lazy
|
|
6
|
+
* CJS factory with `window.__ModuleLoader__`. This script applies that envelope
|
|
7
|
+
* and rewrites the template's static ESM imports into the factory's CommonJS
|
|
8
|
+
* `require` form.
|
|
9
|
+
*
|
|
10
|
+
* The transformation is deliberately narrow — it supports exactly the import
|
|
11
|
+
* shapes the template uses — because a hand-rolled client bundle has no
|
|
12
|
+
* bundler to catch a mistake. Any unsupported syntax fails the build loudly.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node scripts/build-client.mjs # write lib/client.js
|
|
16
|
+
* node scripts/build-client.mjs --watch # rebuild on save (for dsh-client-hmr)
|
|
17
|
+
* node scripts/build-client.mjs --check # fail if lib/client.js is stale
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, watch, writeFileSync } from 'node:fs'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
|
|
24
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
25
|
+
const sourcePath = join(root, 'src', 'client.js')
|
|
26
|
+
const outputPath = join(root, 'lib', 'client.js')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The bundle id the shell's module table keys on. It must equal the package
|
|
30
|
+
* name: `dsh-client-modules` resolves a roster row by the loader entry name and
|
|
31
|
+
* matches it against the id the bundle registers. Read from package.json rather
|
|
32
|
+
* than duplicated here, so a rename cannot desynchronize the two.
|
|
33
|
+
*/
|
|
34
|
+
const PACKAGE_NAME = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The names the envelope exports from the compiled factory. The first entry is
|
|
38
|
+
* what the loader consumes; the rest exist so the verifier can unit-test the
|
|
39
|
+
* pure builders without re-parsing the source.
|
|
40
|
+
*/
|
|
41
|
+
const ENVELOPE_EXPORTS = ['apply', 'inject', 'fontStyleSheet', 'applyFonts']
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `import [default][, { named }] from 'spec'`, with no nested braces.
|
|
45
|
+
* Matches the whole line so no stray `import` survives the rewrite.
|
|
46
|
+
*/
|
|
47
|
+
const IMPORT_PATTERN =
|
|
48
|
+
/^import\s+(?:(?<default>[A-Za-z_$][\w$]*)\s*,\s*)?(?:\{\s*(?<named>[^{}]*?)\s*\})?\s*from\s*'(?<spec>[^']+)'\s*$/gm
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The only specifiers the shell seeds into the browser module table. Anything
|
|
52
|
+
* else has to be declared in `dsh.client.external` and shipped as its own
|
|
53
|
+
* graph row; a mistake would otherwise surface only at runtime in the browser.
|
|
54
|
+
*/
|
|
55
|
+
const PLATFORM_SINGLETONS = new Set([
|
|
56
|
+
'react',
|
|
57
|
+
'react/jsx-runtime',
|
|
58
|
+
'react-dom',
|
|
59
|
+
'react-dom/client',
|
|
60
|
+
'@deepseek-ai/cordis',
|
|
61
|
+
'@deepseek-ai/dsh-client-store',
|
|
62
|
+
'@deepseek-ai/dsh-client-ui-slots',
|
|
63
|
+
'@deepseek-ai/dsh-client-ui-primitives',
|
|
64
|
+
'@deepseek-ai/dsh-client-ui-dockkit',
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Turn one specifier into the alias the compiled bundle binds it to, matching
|
|
69
|
+
* the naming convention DSH's own bundles are emitted with.
|
|
70
|
+
* @param spec - module specifier, e.g. `react/jsx-runtime`.
|
|
71
|
+
* @returns the bound identifier.
|
|
72
|
+
*/
|
|
73
|
+
function aliasFor(spec) {
|
|
74
|
+
return `_${spec.replace(/^@/, '').replace(/[^A-Za-z0-9]+/g, '_').replace(/_+$/, '')}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Rewrite every static import into a `let <alias> = require('<spec>')`
|
|
79
|
+
* binding, exactly as the shipped bundles are emitted.
|
|
80
|
+
* @param source - the template source.
|
|
81
|
+
* @returns the transformed source and the specifiers it requests.
|
|
82
|
+
*/
|
|
83
|
+
function rewriteImports(source) {
|
|
84
|
+
const requested = []
|
|
85
|
+
const namedBindings = []
|
|
86
|
+
const defaultBindings = []
|
|
87
|
+
let matched = 0
|
|
88
|
+
|
|
89
|
+
const body = source.replace(IMPORT_PATTERN, (...args) => {
|
|
90
|
+
const groups = args.at(-1)
|
|
91
|
+
const { default: defaultName, named, spec } = groups
|
|
92
|
+
matched += 1
|
|
93
|
+
requested.push(spec)
|
|
94
|
+
|
|
95
|
+
const generated = aliasFor(spec)
|
|
96
|
+
// A default import binds the alias itself (`let _react = require("react")`
|
|
97
|
+
// is the whole module object); a named one reaches through a property read.
|
|
98
|
+
// Both are resolved to the alias so the emitted body never mentions the
|
|
99
|
+
// original local name.
|
|
100
|
+
if (defaultName !== undefined) {
|
|
101
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(defaultName)) {
|
|
102
|
+
throw new Error(`build-client: unsupported default import name "${defaultName}"`)
|
|
103
|
+
}
|
|
104
|
+
defaultBindings.push([spec, defaultName])
|
|
105
|
+
}
|
|
106
|
+
for (const entry of (named ?? '').split(',')) {
|
|
107
|
+
const trimmed = entry.trim()
|
|
108
|
+
if (trimmed === '') continue
|
|
109
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`build-client: unsupported named import "${trimmed}" from "${spec}" — write the local name identical to the exported name`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
namedBindings.push([spec, trimmed])
|
|
115
|
+
}
|
|
116
|
+
return `\t\tlet ${generated} = require(${JSON.stringify(spec)});`
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
if (matched === 0) throw new Error('build-client: no static imports found in src/client.js')
|
|
120
|
+
return { body, requested, namedBindings, defaultBindings }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve every imported identifier to its `require` alias, matching how DSH's
|
|
125
|
+
* own bundles consume the module table. The replacement is anchored so it can
|
|
126
|
+
* never touch an already-qualified access, a string, or a property name.
|
|
127
|
+
* @param body - source whose imports have been rewritten.
|
|
128
|
+
* @param bindings - the `rewriteImports` binding lists.
|
|
129
|
+
* @returns the rewritten source.
|
|
130
|
+
*/
|
|
131
|
+
function qualifyImports(body, { namedBindings, defaultBindings }) {
|
|
132
|
+
let out = body
|
|
133
|
+
const seen = new Set()
|
|
134
|
+
const replaceIdentifier = (name, replacement) => {
|
|
135
|
+
if (seen.has(name)) return
|
|
136
|
+
seen.add(name)
|
|
137
|
+
const pattern = new RegExp(`(?<![.\\w$'"\`])${name}(?=[\\s(.,;)\\]}])`, 'g')
|
|
138
|
+
out = out.replace(pattern, replacement)
|
|
139
|
+
}
|
|
140
|
+
for (const [spec, name] of defaultBindings) replaceIdentifier(name, aliasFor(spec))
|
|
141
|
+
for (const [spec, name] of namedBindings) replaceIdentifier(name, `${aliasFor(spec)}.${name}`)
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The plugin-identity declaration the template carries. The build substitutes
|
|
147
|
+
* the real package name, which is the value the theme registry pins an override
|
|
148
|
+
* layer to and the roster keys a bundle on. Keeping it single-sourced in
|
|
149
|
+
* package.json means a rename cannot desynchronize the bundle id, the theme
|
|
150
|
+
* layer, and the loader row.
|
|
151
|
+
*/
|
|
152
|
+
const IDENTITY_PATTERN = /\/\* dsh:plugin-id \*\/\s*(['"])[^'"]*\1/
|
|
153
|
+
|
|
154
|
+
/** The CSS namespace prefix. Purely cosmetic; never compared against npm. */
|
|
155
|
+
const STYLE_PREFIX = 'dsh-font'
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Substitute the template's identity declaration with the real package name.
|
|
159
|
+
* @param body - source after import rewriting.
|
|
160
|
+
* @returns the source with the single declaration substituted.
|
|
161
|
+
* @throws {Error} when the declaration is missing or repeated.
|
|
162
|
+
*/
|
|
163
|
+
function substituteIdentity(body) {
|
|
164
|
+
const matches = body.match(new RegExp(IDENTITY_PATTERN.source, 'g')) ?? []
|
|
165
|
+
if (matches.length !== 1) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`build-client: src/client.js must contain exactly one /* dsh:plugin-id */ identity declaration (found ${String(matches.length)})`,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
return body.replace(IDENTITY_PATTERN, JSON.stringify(PACKAGE_NAME))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Strip the template's `export` keywords. The envelope re-exports
|
|
175
|
+
* {@link ENVELOPE_EXPORTS} from the factory, so each of those only has to be a
|
|
176
|
+
* top-level declaration — exporting it is optional and is removed either way.
|
|
177
|
+
* @param body - transformed template source.
|
|
178
|
+
* @returns source without declaration exports.
|
|
179
|
+
*/
|
|
180
|
+
function stripExports(body) {
|
|
181
|
+
for (const name of ENVELOPE_EXPORTS) {
|
|
182
|
+
const declaration = new RegExp(
|
|
183
|
+
`^(?:export )?(?:const|let|var|function|class) ${name}\\b`,
|
|
184
|
+
'm',
|
|
185
|
+
)
|
|
186
|
+
if (!declaration.test(body)) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`build-client: src/client.js must declare \`${name}\` at the top level for the envelope to re-export`,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const stripped = body.replace(/^export (const|let|var|function|class) /gm, '$1 ')
|
|
193
|
+
if (/^\s*export\s/m.test(stripped)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
'build-client: src/client.js contains an export form the build cannot strip (only top-level declarations may be exported)',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
return stripped
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Wrap the transformed source in the DSH client-bundle envelope.
|
|
203
|
+
* @param body - transformed, export-stripped template source.
|
|
204
|
+
* @returns the complete bundle text.
|
|
205
|
+
*/
|
|
206
|
+
function wrap(body) {
|
|
207
|
+
const exports = ENVELOPE_EXPORTS.map((name) => `\t\texports.${name} = ${name};`).join('\n')
|
|
208
|
+
return `window.__ModuleLoader__.load({
|
|
209
|
+
\tid: ${JSON.stringify(PACKAGE_NAME)},
|
|
210
|
+
\tfactory: (require) => {
|
|
211
|
+
\t\tvar module = { exports: {} };
|
|
212
|
+
\t\tvar exports = module.exports;
|
|
213
|
+
\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
214
|
+
${body.trimEnd()}
|
|
215
|
+
${exports}
|
|
216
|
+
\t\treturn module.exports;
|
|
217
|
+
\t}
|
|
218
|
+
});
|
|
219
|
+
`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Compile the template into the complete bundle text.
|
|
224
|
+
* @returns the bundle and the specifiers it requests.
|
|
225
|
+
* @throws {Error} on any import shape the transformation cannot express.
|
|
226
|
+
*/
|
|
227
|
+
function compile() {
|
|
228
|
+
const template = readFileSync(sourcePath, 'utf8')
|
|
229
|
+
const { body, requested, namedBindings, defaultBindings } = rewriteImports(template)
|
|
230
|
+
|
|
231
|
+
for (const spec of requested) {
|
|
232
|
+
if (!PLATFORM_SINGLETONS.has(spec)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`build-client: src/client.js imports "${spec}", which is not a platform singleton; declare it in dsh.client.external and ship it as its own client bundle`,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
bundle: wrap(
|
|
241
|
+
substituteIdentity(qualifyImports(stripExports(body), { namedBindings, defaultBindings })),
|
|
242
|
+
),
|
|
243
|
+
requested,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Compile and write, reporting what changed. @returns whether the file changed. */
|
|
248
|
+
function build() {
|
|
249
|
+
const { bundle, requested } = compile()
|
|
250
|
+
let existing
|
|
251
|
+
try {
|
|
252
|
+
existing = readFileSync(outputPath, 'utf8')
|
|
253
|
+
} catch {
|
|
254
|
+
existing = undefined
|
|
255
|
+
}
|
|
256
|
+
if (existing === bundle) {
|
|
257
|
+
console.log('build-client: lib/client.js already up to date')
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
writeFileSync(outputPath, bundle, 'utf8')
|
|
261
|
+
console.log(
|
|
262
|
+
`build-client: wrote lib/client.js (${String(bundle.length)} bytes, requires ${requested.join(', ')})`,
|
|
263
|
+
)
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (process.argv.includes('--check')) {
|
|
268
|
+
let existing
|
|
269
|
+
try {
|
|
270
|
+
existing = readFileSync(outputPath, 'utf8')
|
|
271
|
+
} catch {
|
|
272
|
+
console.error('build-client: lib/client.js is missing; run `node scripts/build-client.mjs`')
|
|
273
|
+
process.exit(1)
|
|
274
|
+
}
|
|
275
|
+
if (existing !== compile().bundle) {
|
|
276
|
+
console.error('build-client: lib/client.js is stale; run `node scripts/build-client.mjs`')
|
|
277
|
+
process.exit(1)
|
|
278
|
+
}
|
|
279
|
+
console.log('build-client: lib/client.js is up to date')
|
|
280
|
+
} else if (process.argv.includes('--watch')) {
|
|
281
|
+
build()
|
|
282
|
+
console.log('build-client: watching src/client.js (Ctrl-C to stop)')
|
|
283
|
+
let timer
|
|
284
|
+
watch(sourcePath, () => {
|
|
285
|
+
clearTimeout(timer)
|
|
286
|
+
timer = setTimeout(() => {
|
|
287
|
+
try {
|
|
288
|
+
build()
|
|
289
|
+
} catch (error) {
|
|
290
|
+
// Keep watching: a syntax error mid-edit must not kill the watcher.
|
|
291
|
+
console.error(String(error instanceof Error ? error.message : error))
|
|
292
|
+
}
|
|
293
|
+
}, 50)
|
|
294
|
+
})
|
|
295
|
+
} else {
|
|
296
|
+
build()
|
|
297
|
+
}
|