@monster-orange/den 0.2.8 → 0.3.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/CHANGELOG.md +1547 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +507 -4
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +9 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +33 -3
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +141 -5
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/environment-manager/default-workspace.ts +16 -1
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +78 -59
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +12 -3
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +7 -2
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +548 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +22 -1
- package/src/ui/mcp/tools.ts +50 -7
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +23 -44
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +532 -51
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +14 -2
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +12 -1
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +45 -3
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme routes (GET only):
|
|
3
|
+
*
|
|
4
|
+
* /theme.css the active theme as CSS custom properties, both
|
|
5
|
+
* modes (see theme-runtime.ts renderThemeCss)
|
|
6
|
+
* /theme/<id>/<file> a file the theme's validated manifest names (its
|
|
7
|
+
* mark .svg, its .woff2 fonts), nothing else
|
|
8
|
+
* /theme-favicon/<id>?mode=light|dark&v=<version>-<hash>
|
|
9
|
+
* theme <id>'s browser-tab icon in that mode (its
|
|
10
|
+
* `favicon`, else its mark's file, the dark variant
|
|
11
|
+
* in dark mode, else Den's own icon); `v` only makes
|
|
12
|
+
* the URL change with the file. /theme-favicon with
|
|
13
|
+
* no id is the active theme's, never cached.
|
|
14
|
+
* /api/themes the installed themes, the active one (with its
|
|
15
|
+
* favicon URLs per mode) and the org default (what
|
|
16
|
+
* appearance.theme null draws)
|
|
17
|
+
*
|
|
18
|
+
* Collaborators are injectable for tests.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "crypto";
|
|
21
|
+
import * as fs from "fs";
|
|
22
|
+
import * as http from "http";
|
|
23
|
+
import * as path from "path";
|
|
24
|
+
import { send404, sendJson } from "./http-helpers.js";
|
|
25
|
+
import type { AppearancePrefs } from "./appearance-prefs.js";
|
|
26
|
+
import type { ThemeManifest, ThemeMode } from "../../shared/theme/contract.js";
|
|
27
|
+
import {
|
|
28
|
+
faviconFile,
|
|
29
|
+
findTheme,
|
|
30
|
+
listThemes,
|
|
31
|
+
renderThemeCss,
|
|
32
|
+
resolveActiveTheme,
|
|
33
|
+
themeAsset,
|
|
34
|
+
themeAssetUrl,
|
|
35
|
+
type ActiveTheme,
|
|
36
|
+
type ThemeRoots,
|
|
37
|
+
} from "./theme-runtime.js";
|
|
38
|
+
|
|
39
|
+
export interface ThemeRouteDeps {
|
|
40
|
+
roots: () => ThemeRoots;
|
|
41
|
+
/** The user's appearance prefs (user.yml). */
|
|
42
|
+
prefs: () => AppearancePrefs;
|
|
43
|
+
/** The org config's `theme:`. */
|
|
44
|
+
distributionTheme: () => string | null | undefined;
|
|
45
|
+
warn: (line: string) => void;
|
|
46
|
+
/** Den's own tab icon (public/favicon.ico), for a theme with a text mark. */
|
|
47
|
+
denFavicon?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function denFaviconPath(deps: ThemeRouteDeps): string {
|
|
51
|
+
return (
|
|
52
|
+
deps.denFavicon ??
|
|
53
|
+
path.join(
|
|
54
|
+
deps.roots().engineRoot,
|
|
55
|
+
"src",
|
|
56
|
+
"ui",
|
|
57
|
+
"server",
|
|
58
|
+
"public",
|
|
59
|
+
"favicon.ico",
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The file and type theme `id` shows in the browser tab in `mode`: a file
|
|
66
|
+
* its manifest names (themeAsset's rule), else Den's own icon.
|
|
67
|
+
*/
|
|
68
|
+
function faviconAsset(
|
|
69
|
+
deps: ThemeRouteDeps,
|
|
70
|
+
id: string,
|
|
71
|
+
theme: ThemeManifest,
|
|
72
|
+
mode: ThemeMode,
|
|
73
|
+
): { file: string; type: string } | null {
|
|
74
|
+
const rel = faviconFile(theme, mode);
|
|
75
|
+
if (rel) return themeAsset(deps.roots(), id, rel);
|
|
76
|
+
const file = denFaviconPath(deps);
|
|
77
|
+
return fs.existsSync(file) ? { file, type: "image/x-icon" } : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** /theme-favicon/<id>?mode=<mode>&v=<version>-<content hash>. */
|
|
81
|
+
function faviconUrl(
|
|
82
|
+
deps: ThemeRouteDeps,
|
|
83
|
+
a: ActiveTheme,
|
|
84
|
+
mode: ThemeMode,
|
|
85
|
+
): string | null {
|
|
86
|
+
const asset = faviconAsset(deps, a.id, a.theme, mode);
|
|
87
|
+
if (!asset) return null;
|
|
88
|
+
const hash = createHash("sha256")
|
|
89
|
+
.update(fs.readFileSync(asset.file))
|
|
90
|
+
.digest("hex")
|
|
91
|
+
.slice(0, 10);
|
|
92
|
+
return `/theme-favicon/${a.id}?mode=${mode}&v=${encodeURIComponent(`${a.theme.version}-${hash}`)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The active theme for these deps (the user's, the distribution's, den). */
|
|
96
|
+
export function activeTheme(deps: ThemeRouteDeps): ActiveTheme | null {
|
|
97
|
+
let prefs: AppearancePrefs = { theme: null, mode: "system" };
|
|
98
|
+
let dist: string | null | undefined = null;
|
|
99
|
+
try {
|
|
100
|
+
prefs = deps.prefs();
|
|
101
|
+
} catch (e) {
|
|
102
|
+
deps.warn(`[theme] user config unreadable: ${(e as Error).message}`);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
dist = deps.distributionTheme();
|
|
106
|
+
} catch (e) {
|
|
107
|
+
deps.warn(`[theme] org config unreadable: ${(e as Error).message}`);
|
|
108
|
+
}
|
|
109
|
+
return resolveActiveTheme(
|
|
110
|
+
deps.roots(),
|
|
111
|
+
{ user: prefs.theme, distribution: typeof dist === "string" ? dist : null },
|
|
112
|
+
deps.warn,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The org default: the theme drawn when you have chosen none (the org
|
|
118
|
+
* config's `brand.theme` if it is installed and valid, else den).
|
|
119
|
+
*/
|
|
120
|
+
export function orgDefaultTheme(deps: ThemeRouteDeps): ActiveTheme | null {
|
|
121
|
+
let dist: string | null | undefined = null;
|
|
122
|
+
try {
|
|
123
|
+
dist = deps.distributionTheme();
|
|
124
|
+
} catch {
|
|
125
|
+
/* warned by activeTheme */
|
|
126
|
+
}
|
|
127
|
+
return resolveActiveTheme(
|
|
128
|
+
deps.roots(),
|
|
129
|
+
{ user: null, distribution: typeof dist === "string" ? dist : null },
|
|
130
|
+
deps.warn,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function markOf(a: ActiveTheme) {
|
|
135
|
+
const m = a.theme.mark ?? {};
|
|
136
|
+
return {
|
|
137
|
+
svg: m.svg ? themeAssetUrl(a.id, m.svg) : null,
|
|
138
|
+
svg_dark: m.svg_dark ? themeAssetUrl(a.id, m.svg_dark) : null,
|
|
139
|
+
png: m.png ? themeAssetUrl(a.id, m.png) : null,
|
|
140
|
+
png_dark: m.png_dark ? themeAssetUrl(a.id, m.png_dark) : null,
|
|
141
|
+
webp: m.webp ? themeAssetUrl(a.id, m.webp) : null,
|
|
142
|
+
webp_dark: m.webp_dark ? themeAssetUrl(a.id, m.webp_dark) : null,
|
|
143
|
+
text: m.text ?? null,
|
|
144
|
+
shape: m.shape ?? null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Handles the routes above; false when `pathname` is not one of them. */
|
|
149
|
+
export function handleThemeRoutes(
|
|
150
|
+
req: http.IncomingMessage,
|
|
151
|
+
res: http.ServerResponse,
|
|
152
|
+
pathname: string,
|
|
153
|
+
deps: ThemeRouteDeps,
|
|
154
|
+
): boolean {
|
|
155
|
+
const isAsset = pathname.startsWith("/theme/");
|
|
156
|
+
const isFavicon =
|
|
157
|
+
pathname === "/theme-favicon" || pathname.startsWith("/theme-favicon/");
|
|
158
|
+
if (
|
|
159
|
+
pathname !== "/theme.css" &&
|
|
160
|
+
pathname !== "/api/themes" &&
|
|
161
|
+
!isAsset &&
|
|
162
|
+
!isFavicon
|
|
163
|
+
)
|
|
164
|
+
return false;
|
|
165
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
166
|
+
res.writeHead(405, { Allow: "GET" });
|
|
167
|
+
res.end("read-only");
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (isFavicon) {
|
|
172
|
+
const id = pathname.slice("/theme-favicon/".length);
|
|
173
|
+
const q = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
174
|
+
const mode: ThemeMode = q.get("mode") === "dark" ? "dark" : "light";
|
|
175
|
+
let t: { id: string; theme: ThemeManifest } | null = null;
|
|
176
|
+
if (pathname === "/theme-favicon") t = activeTheme(deps);
|
|
177
|
+
else {
|
|
178
|
+
const found = /^[a-z][a-z0-9-]{1,39}$/.test(id)
|
|
179
|
+
? findTheme(deps.roots(), id)
|
|
180
|
+
: null;
|
|
181
|
+
if (found?.ok && found.theme) t = { id: found.id, theme: found.theme };
|
|
182
|
+
}
|
|
183
|
+
const asset = t && faviconAsset(deps, t.id, t.theme, mode);
|
|
184
|
+
if (!asset) {
|
|
185
|
+
send404(res);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
const headers: Record<string, string> = {
|
|
189
|
+
"Content-Type": asset.type,
|
|
190
|
+
"X-Content-Type-Options": "nosniff",
|
|
191
|
+
// The versioned URL names the file; the bare one follows your choice.
|
|
192
|
+
"Cache-Control": pathname === "/theme-favicon" ? "no-store" : "no-cache",
|
|
193
|
+
};
|
|
194
|
+
if (asset.type === "image/svg+xml")
|
|
195
|
+
headers["Content-Security-Policy"] =
|
|
196
|
+
"default-src 'none'; style-src 'unsafe-inline'";
|
|
197
|
+
res.writeHead(200, headers);
|
|
198
|
+
res.end(req.method === "HEAD" ? undefined : fs.readFileSync(asset.file));
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (pathname === "/theme.css") {
|
|
203
|
+
const a = activeTheme(deps);
|
|
204
|
+
res.writeHead(200, {
|
|
205
|
+
"Content-Type": "text/css; charset=utf-8",
|
|
206
|
+
// The theme follows the user's choice: never serve yesterday's.
|
|
207
|
+
"Cache-Control": "no-store",
|
|
208
|
+
});
|
|
209
|
+
res.end(a ? renderThemeCss(a.id, a.theme) : "/* no valid theme */\n");
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (pathname === "/api/themes") {
|
|
214
|
+
const a = activeTheme(deps);
|
|
215
|
+
let prefs: AppearancePrefs = { theme: null, mode: "system" };
|
|
216
|
+
try {
|
|
217
|
+
prefs = deps.prefs();
|
|
218
|
+
} catch {
|
|
219
|
+
/* warned by activeTheme */
|
|
220
|
+
}
|
|
221
|
+
const org = orgDefaultTheme(deps);
|
|
222
|
+
sendJson(res, {
|
|
223
|
+
// What "Org default" in the Theme menu means: appearance.theme null.
|
|
224
|
+
org_default: org ? { id: org.id, name: org.theme.name } : null,
|
|
225
|
+
active: a
|
|
226
|
+
? {
|
|
227
|
+
id: a.id,
|
|
228
|
+
name: a.theme.name,
|
|
229
|
+
version: a.theme.version,
|
|
230
|
+
source: a.source,
|
|
231
|
+
chosen_by: a.chosenBy,
|
|
232
|
+
default_mode: a.theme.default_mode ?? "dark",
|
|
233
|
+
mark: markOf(a),
|
|
234
|
+
// The tab icon per mode: the shell sets the one for the mode
|
|
235
|
+
// it draws in (appearance.js).
|
|
236
|
+
favicon: {
|
|
237
|
+
light: faviconUrl(deps, a, "light"),
|
|
238
|
+
dark: faviconUrl(deps, a, "dark"),
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
: null,
|
|
242
|
+
prefs,
|
|
243
|
+
themes: listThemes(deps.roots()).map((t) => ({
|
|
244
|
+
id: t.id,
|
|
245
|
+
name: t.name,
|
|
246
|
+
version: t.version,
|
|
247
|
+
source: t.source,
|
|
248
|
+
ok: t.ok,
|
|
249
|
+
...(t.problem ? { problem: t.problem } : {}),
|
|
250
|
+
})),
|
|
251
|
+
});
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// /theme/<id>/<file…>. Each segment is decoded and must be a plain name:
|
|
256
|
+
// an encoded slash, dot segment or NUL never reaches the lookup, which in
|
|
257
|
+
// turn only matches the exact paths the manifest names.
|
|
258
|
+
const [id, ...segs] = pathname.slice("/theme/".length).split("/");
|
|
259
|
+
let rel: string;
|
|
260
|
+
try {
|
|
261
|
+
const parts = segs.map((s) => decodeURIComponent(s));
|
|
262
|
+
if (
|
|
263
|
+
!id ||
|
|
264
|
+
parts.length === 0 ||
|
|
265
|
+
parts.some((p) => !p || p === "." || p === ".." || /[\\/\0]/.test(p))
|
|
266
|
+
) {
|
|
267
|
+
send404(res);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
rel = parts.join("/");
|
|
271
|
+
} catch {
|
|
272
|
+
send404(res);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
const asset = themeAsset(deps.roots(), id, rel);
|
|
276
|
+
if (!asset) {
|
|
277
|
+
send404(res);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
const headers: Record<string, string> = {
|
|
281
|
+
"Content-Type": asset.type,
|
|
282
|
+
"X-Content-Type-Options": "nosniff",
|
|
283
|
+
"Cache-Control": "no-cache",
|
|
284
|
+
};
|
|
285
|
+
// Opened directly, an SVG is a document: let it run nothing, fetch nothing.
|
|
286
|
+
if (asset.type === "image/svg+xml")
|
|
287
|
+
headers["Content-Security-Policy"] =
|
|
288
|
+
"default-src 'none'; style-src 'unsafe-inline'";
|
|
289
|
+
res.writeHead(200, headers);
|
|
290
|
+
res.end(req.method === "HEAD" ? undefined : fs.readFileSync(asset.file));
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The theme runtime: which theme the web UI draws with, and that theme as CSS.
|
|
3
|
+
*
|
|
4
|
+
* Themes come from three places (see src/ui/cli/theme-command.ts):
|
|
5
|
+
* built-in <engineRoot>/themes/den/ Den's own, always present
|
|
6
|
+
* locked <distRoot>/plugins/<id>/ + lock vendored by `theme add`
|
|
7
|
+
* local <distRoot>/plugins/<id>/, no lock the distribution's own
|
|
8
|
+
*
|
|
9
|
+
* The active theme is the user's choice (user.yml `appearance.theme`), else
|
|
10
|
+
* the distribution's (org config `brand.theme`), else den. A choice that is not
|
|
11
|
+
* installed or fails validation falls through to the next, ending at den,
|
|
12
|
+
* with one warning in the server log per bad choice. Only a validated
|
|
13
|
+
* manifest is ever rendered: its values passed the schema's CSS-value rule
|
|
14
|
+
* (no ; { } < > \ url() @import), so writing them into custom properties
|
|
15
|
+
* cannot break out of the declaration.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
import {
|
|
20
|
+
installedThemeIds,
|
|
21
|
+
readLock,
|
|
22
|
+
themeDirFor,
|
|
23
|
+
validationSummary,
|
|
24
|
+
} from "../cli/theme-command.js";
|
|
25
|
+
import {
|
|
26
|
+
themeDirIn,
|
|
27
|
+
validatePluginDir,
|
|
28
|
+
} from "../../shared/plugins/manifest.js";
|
|
29
|
+
import {
|
|
30
|
+
markImage,
|
|
31
|
+
themeFiles,
|
|
32
|
+
validateThemeDir,
|
|
33
|
+
type ColorToken,
|
|
34
|
+
type ThemeManifest,
|
|
35
|
+
type ThemeMode,
|
|
36
|
+
} from "../../shared/theme/contract.js";
|
|
37
|
+
|
|
38
|
+
export const BUILTIN_THEME = "den";
|
|
39
|
+
|
|
40
|
+
export type ThemeOrigin = "built-in" | "locked" | "local";
|
|
41
|
+
|
|
42
|
+
export interface ThemeRoots {
|
|
43
|
+
distRoot: string;
|
|
44
|
+
engineRoot: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InstalledTheme {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
version: string;
|
|
51
|
+
source: ThemeOrigin;
|
|
52
|
+
/** Validates under this Den: only then is it rendered or served. */
|
|
53
|
+
ok: boolean;
|
|
54
|
+
/** Why it is not ok. */
|
|
55
|
+
problem?: string;
|
|
56
|
+
dir: string;
|
|
57
|
+
theme?: ThemeManifest;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const COLOR_TOKENS: readonly ColorToken[] = [
|
|
61
|
+
"bg",
|
|
62
|
+
"surface",
|
|
63
|
+
"raised",
|
|
64
|
+
"sunken",
|
|
65
|
+
"text",
|
|
66
|
+
"muted",
|
|
67
|
+
"faint",
|
|
68
|
+
"border",
|
|
69
|
+
"border-strong",
|
|
70
|
+
"accent",
|
|
71
|
+
"accent-text",
|
|
72
|
+
"accent-fill",
|
|
73
|
+
"on-accent",
|
|
74
|
+
"ok",
|
|
75
|
+
"warn",
|
|
76
|
+
"bad",
|
|
77
|
+
"remote",
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export function builtinThemeDir(engineRoot: string): string {
|
|
81
|
+
return path.join(engineRoot, "themes", BUILTIN_THEME);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inspect(
|
|
85
|
+
id: string,
|
|
86
|
+
dir: string,
|
|
87
|
+
source: ThemeOrigin,
|
|
88
|
+
expectId?: string,
|
|
89
|
+
pluginDir?: string,
|
|
90
|
+
): InstalledTheme {
|
|
91
|
+
// The theme itself, to render; the verdict is `validate`'s for the whole
|
|
92
|
+
// plugin (author/maintainer, `den` range, README, capabilities), so what
|
|
93
|
+
// fails validate never loads and is never offered (PL-SPEC d4).
|
|
94
|
+
const v = validateThemeDir(dir, expectId ? { expectId } : {});
|
|
95
|
+
const verdict = validatePluginDir(
|
|
96
|
+
pluginDir ?? dir,
|
|
97
|
+
expectId ? { expectId } : {},
|
|
98
|
+
);
|
|
99
|
+
const ok = v.ok && verdict.ok;
|
|
100
|
+
return {
|
|
101
|
+
id,
|
|
102
|
+
name: v.theme?.name ?? id,
|
|
103
|
+
version: v.theme?.version ?? "?",
|
|
104
|
+
source,
|
|
105
|
+
ok,
|
|
106
|
+
...(ok ? {} : { problem: validationSummary(verdict.ok ? v : verdict) }),
|
|
107
|
+
dir,
|
|
108
|
+
...(v.theme ? { theme: v.theme } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Where plugin `id`'s theme is: plugins/<id>/ for a den-theme.json plugin,
|
|
114
|
+
* else the directory its den-plugin.json theme entry names.
|
|
115
|
+
*/
|
|
116
|
+
function pluginThemeDir(distRoot: string, id: string): string {
|
|
117
|
+
const dir = themeDirFor(distRoot, id);
|
|
118
|
+
return themeDirIn(dir) ?? dir;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Every theme this distribution can use: den first, then plugins/<id>/ sorted. */
|
|
122
|
+
export function listThemes(roots: ThemeRoots): InstalledTheme[] {
|
|
123
|
+
let lock: ReturnType<typeof readLock> = {};
|
|
124
|
+
try {
|
|
125
|
+
lock = readLock(roots.distRoot);
|
|
126
|
+
} catch {
|
|
127
|
+
/* an unreadable lock: every theme is then local; `theme list` reports it */
|
|
128
|
+
}
|
|
129
|
+
const out = [
|
|
130
|
+
inspect(BUILTIN_THEME, builtinThemeDir(roots.engineRoot), "built-in"),
|
|
131
|
+
];
|
|
132
|
+
for (const id of installedThemeIds(roots.distRoot)) {
|
|
133
|
+
if (id === BUILTIN_THEME) continue; // the built-in one is never shadowed
|
|
134
|
+
out.push(
|
|
135
|
+
inspect(
|
|
136
|
+
id,
|
|
137
|
+
pluginThemeDir(roots.distRoot, id),
|
|
138
|
+
lock[id]?.kinds?.includes("theme") ? "locked" : "local",
|
|
139
|
+
id,
|
|
140
|
+
themeDirFor(roots.distRoot, id),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One theme by id, or null when no such theme is installed. */
|
|
148
|
+
export function findTheme(
|
|
149
|
+
roots: ThemeRoots,
|
|
150
|
+
id: string,
|
|
151
|
+
): InstalledTheme | null {
|
|
152
|
+
if (id === BUILTIN_THEME)
|
|
153
|
+
return inspect(id, builtinThemeDir(roots.engineRoot), "built-in");
|
|
154
|
+
if (!/^[a-z][a-z0-9-]{1,39}$/.test(id)) return null;
|
|
155
|
+
if (!installedThemeIds(roots.distRoot).includes(id)) return null;
|
|
156
|
+
let locked = false;
|
|
157
|
+
try {
|
|
158
|
+
locked = !!readLock(roots.distRoot)[id]?.kinds?.includes("theme");
|
|
159
|
+
} catch {
|
|
160
|
+
/* see listThemes */
|
|
161
|
+
}
|
|
162
|
+
return inspect(
|
|
163
|
+
id,
|
|
164
|
+
pluginThemeDir(roots.distRoot, id),
|
|
165
|
+
locked ? "locked" : "local",
|
|
166
|
+
id,
|
|
167
|
+
themeDirFor(roots.distRoot, id),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ThemeChoice {
|
|
172
|
+
/** user.yml `appearance.theme`. */
|
|
173
|
+
user?: string | null;
|
|
174
|
+
/** The org config's `brand.theme`. */
|
|
175
|
+
distribution?: string | null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface ActiveTheme {
|
|
179
|
+
id: string;
|
|
180
|
+
theme: ThemeManifest;
|
|
181
|
+
dir: string;
|
|
182
|
+
source: ThemeOrigin;
|
|
183
|
+
/** Which choice won. */
|
|
184
|
+
chosenBy: "user" | "distribution" | "default";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const warned = new Set<string>();
|
|
188
|
+
|
|
189
|
+
/** Forget which warnings were logged (tests). */
|
|
190
|
+
export function resetThemeWarnings(): void {
|
|
191
|
+
warned.clear();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The theme to draw with. Never throws: a bad choice is warned about once
|
|
196
|
+
* and skipped. Null only if Den's own theme is unusable (a broken install).
|
|
197
|
+
*/
|
|
198
|
+
export function resolveActiveTheme(
|
|
199
|
+
roots: ThemeRoots,
|
|
200
|
+
choice: ThemeChoice,
|
|
201
|
+
warn: (line: string) => void = (l) => console.warn(l),
|
|
202
|
+
): ActiveTheme | null {
|
|
203
|
+
const candidates: [string, ActiveTheme["chosenBy"], string][] = [];
|
|
204
|
+
if (choice.user) candidates.push([choice.user, "user", "your appearance"]);
|
|
205
|
+
if (choice.distribution)
|
|
206
|
+
candidates.push([
|
|
207
|
+
choice.distribution,
|
|
208
|
+
"distribution",
|
|
209
|
+
"the distribution's `brand.theme`",
|
|
210
|
+
]);
|
|
211
|
+
candidates.push([BUILTIN_THEME, "default", "the default"]);
|
|
212
|
+
for (const [id, chosenBy, label] of candidates) {
|
|
213
|
+
let t: InstalledTheme | null = null;
|
|
214
|
+
try {
|
|
215
|
+
t = findTheme(roots, String(id));
|
|
216
|
+
} catch (e) {
|
|
217
|
+
t = null;
|
|
218
|
+
warnOnce(
|
|
219
|
+
warn,
|
|
220
|
+
`${id}:error`,
|
|
221
|
+
() =>
|
|
222
|
+
`theme "${id}" (${label}) could not be read: ${(e as Error).message}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (t?.ok && t.theme)
|
|
226
|
+
return {
|
|
227
|
+
id: t.id,
|
|
228
|
+
theme: t.theme,
|
|
229
|
+
dir: t.dir,
|
|
230
|
+
source: t.source,
|
|
231
|
+
chosenBy,
|
|
232
|
+
};
|
|
233
|
+
if (id === BUILTIN_THEME && chosenBy === "default") {
|
|
234
|
+
warnOnce(
|
|
235
|
+
warn,
|
|
236
|
+
"den:broken",
|
|
237
|
+
() =>
|
|
238
|
+
`the built-in theme is not valid (${t?.problem ?? "missing"}); the UI has no theme`,
|
|
239
|
+
);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
warnOnce(warn, `${id}:${t ? "invalid" : "missing"}`, () =>
|
|
243
|
+
t
|
|
244
|
+
? `theme "${id}" (${label}) is not valid under this Den: ${t.problem}; using the next choice`
|
|
245
|
+
: `theme "${id}" (${label}) is not installed; using the next choice`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function warnOnce(
|
|
252
|
+
warn: (line: string) => void,
|
|
253
|
+
key: string,
|
|
254
|
+
line: () => string,
|
|
255
|
+
): void {
|
|
256
|
+
if (warned.has(key)) return;
|
|
257
|
+
warned.add(key);
|
|
258
|
+
warn(`[theme] ${line()}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** URL of a file in a theme, as /theme/<id>/<file> serves it. */
|
|
262
|
+
export function themeAssetUrl(id: string, rel: string): string {
|
|
263
|
+
return `/theme/${id}/${rel.split("/").map(encodeURIComponent).join("/")}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const DEFAULT_TYPE = {
|
|
267
|
+
sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif',
|
|
268
|
+
mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
|
269
|
+
ja: '"Hiragino Sans", "Noto Sans JP", "Yu Gothic", Meiryo, sans-serif',
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/** A generic family ends a stack that can always resolve. */
|
|
273
|
+
const GENERIC_FAMILY =
|
|
274
|
+
/(?:^|,)\s*(?:sans-serif|serif|monospace|cursive|fantasy|system-ui|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|math|emoji)\s*$/i;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A theme's font stack, made safe to use alone: one that does not end in a
|
|
278
|
+
* generic family gets Den's stack after it, so a missing font never drops
|
|
279
|
+
* the UI to the browser's default serif. The pages use only var(--sans),
|
|
280
|
+
* var(--mono), var(--display); the fallbacks live here, nowhere else.
|
|
281
|
+
*/
|
|
282
|
+
export function fontStack(stack: string | undefined, fallback: string): string {
|
|
283
|
+
if (!stack?.trim()) return fallback;
|
|
284
|
+
return GENERIC_FAMILY.test(stack) ? stack : `${stack}, ${fallback}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Den's base rules, the same under every theme. A link in body content is
|
|
289
|
+
* underlined (subtly, in a tint of its own color), so it never reads by
|
|
290
|
+
* color alone: a theme whose accent text is red would otherwise make links
|
|
291
|
+
* look like errors next to --bad. Nav links, buttons and pills are not
|
|
292
|
+
* body content. :where() keeps the rule at zero specificity, so any page
|
|
293
|
+
* style that draws a link differently wins.
|
|
294
|
+
*/
|
|
295
|
+
const BASE_CSS = [
|
|
296
|
+
"/* Den base (every theme) */",
|
|
297
|
+
':where(a[href]:not(nav a, header a, .topbar a, [role="button"], [class*="btn"], [class*="pill"], [class*="tab"])) {',
|
|
298
|
+
" text-decoration-line: underline;",
|
|
299
|
+
" text-decoration-thickness: 1px;",
|
|
300
|
+
" text-underline-offset: 0.2em;",
|
|
301
|
+
" text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);",
|
|
302
|
+
"}",
|
|
303
|
+
':where(a[href]:not(nav a, header a, .topbar a, [role="button"], [class*="btn"], [class*="pill"], [class*="tab"])):hover {',
|
|
304
|
+
" text-decoration-color: currentColor;",
|
|
305
|
+
"}",
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The theme as CSS: @font-face for its fonts, type and shape on :root, and
|
|
310
|
+
* one block of color tokens per mode, selected by <html data-mode>. The
|
|
311
|
+
* theme's default mode also applies when data-mode is unset. Then Den's
|
|
312
|
+
* base rules (BASE_CSS).
|
|
313
|
+
*/
|
|
314
|
+
export function renderThemeCss(id: string, theme: ThemeManifest): string {
|
|
315
|
+
const lines: string[] = [
|
|
316
|
+
`/* ${theme.name} ${theme.version} (${id}, den.theme/v1). Generated by Den from den-theme.json. */`,
|
|
317
|
+
];
|
|
318
|
+
for (const f of theme.fonts ?? [])
|
|
319
|
+
for (const file of f.files) {
|
|
320
|
+
const [lo, hi] = (file.weight ?? "400").split(" ");
|
|
321
|
+
lines.push(
|
|
322
|
+
`@font-face { font-family: "${f.family}"; src: url("${themeAssetUrl(id, file.path)}") format("woff2"); font-weight: ${hi ? `${lo} ${hi}` : lo}; font-style: ${file.style ?? "normal"}; font-display: swap; }`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const type = theme.type ?? {};
|
|
326
|
+
const shape = theme.shape ?? {};
|
|
327
|
+
const sans = fontStack(type.sans, DEFAULT_TYPE.sans);
|
|
328
|
+
lines.push(
|
|
329
|
+
":root {",
|
|
330
|
+
` --sans: ${sans};`,
|
|
331
|
+
` --mono: ${fontStack(type.mono, DEFAULT_TYPE.mono)};`,
|
|
332
|
+
` --display: ${fontStack(type.display, sans)};`,
|
|
333
|
+
` --ja: ${fontStack(type.ja, DEFAULT_TYPE.ja)};`,
|
|
334
|
+
` --radius: ${shape.radius ?? "8px"};`,
|
|
335
|
+
` --radius-button: ${shape["radius-button"] ?? "6px"};`,
|
|
336
|
+
` --density: ${shape.density === "compact" ? "0.85" : "1"};`,
|
|
337
|
+
"}",
|
|
338
|
+
);
|
|
339
|
+
const def: ThemeMode = theme.default_mode ?? "dark";
|
|
340
|
+
for (const mode of [def, def === "dark" ? "light" : "dark"] as ThemeMode[]) {
|
|
341
|
+
const c = theme.modes[mode];
|
|
342
|
+
const sel =
|
|
343
|
+
mode === def
|
|
344
|
+
? `:root,\n:root[data-mode="${mode}"]`
|
|
345
|
+
: `:root[data-mode="${mode}"]`;
|
|
346
|
+
lines.push(`${sel} {`, ` color-scheme: ${mode};`);
|
|
347
|
+
for (const t of COLOR_TOKENS) lines.push(` --${t}: ${c[t]};`);
|
|
348
|
+
lines.push(
|
|
349
|
+
` --shadow: ${c.shadow ?? (mode === "dark" ? "0 12px 30px rgb(0 0 0 / 0.5)" : "0 8px 24px rgb(0 0 0 / 0.14)")};`,
|
|
350
|
+
` --bar: ${c.bar ?? "none"};`,
|
|
351
|
+
"}",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
lines.push(...BASE_CSS);
|
|
355
|
+
return `${lines.join("\n")}\n`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const ASSET_TYPES: Record<string, string> = {
|
|
359
|
+
".svg": "image/svg+xml",
|
|
360
|
+
".png": "image/png",
|
|
361
|
+
".webp": "image/webp",
|
|
362
|
+
".ico": "image/x-icon",
|
|
363
|
+
".woff2": "font/woff2",
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The theme's browser-tab icon in a mode, relative to its directory: its
|
|
368
|
+
* `favicon` (both modes), else its mark's image (the dark variant in dark
|
|
369
|
+
* mode, if it has one), else null (a text mark: Den's own icon).
|
|
370
|
+
*/
|
|
371
|
+
export function faviconFile(
|
|
372
|
+
theme: ThemeManifest,
|
|
373
|
+
mode: ThemeMode,
|
|
374
|
+
): string | null {
|
|
375
|
+
return theme.favicon ?? markImage(theme.mark, mode)?.file ?? null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The file /theme/<id>/<rel> may serve, or null: only a file the validated
|
|
380
|
+
* manifest names (its mark, its favicon, its fonts), only .svg, .png,
|
|
381
|
+
* .webp, .ico or .woff2, only inside the theme's directory. `rel` is the
|
|
382
|
+
* decoded remainder of the URL path.
|
|
383
|
+
*/
|
|
384
|
+
export function themeAsset(
|
|
385
|
+
roots: ThemeRoots,
|
|
386
|
+
id: string,
|
|
387
|
+
rel: string,
|
|
388
|
+
): { file: string; type: string } | null {
|
|
389
|
+
const type = ASSET_TYPES[path.extname(rel).toLowerCase()];
|
|
390
|
+
if (!type) return null;
|
|
391
|
+
const t = findTheme(roots, id);
|
|
392
|
+
if (!t?.ok || !t.theme) return null;
|
|
393
|
+
if (!themeFiles(t.theme).includes(rel)) return null;
|
|
394
|
+
const root = path.resolve(t.dir);
|
|
395
|
+
const file = path.resolve(root, rel);
|
|
396
|
+
if (!file.startsWith(root + path.sep)) return null;
|
|
397
|
+
try {
|
|
398
|
+
// Resolve links too: the real file must still be inside the theme.
|
|
399
|
+
const real = fs.realpathSync(file);
|
|
400
|
+
const realRoot = fs.realpathSync(root);
|
|
401
|
+
if (!real.startsWith(realRoot + path.sep) || !fs.statSync(real).isFile())
|
|
402
|
+
return null;
|
|
403
|
+
return { file: real, type };
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|