@mohou/host 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- package/themes/theme-zoro.css +4 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
/** Theme file names and pin sentinels. Spell them here, not at each call site. */
|
|
5
|
+
export const themeLayout = {
|
|
6
|
+
appCss: 'theme.css',
|
|
7
|
+
appPin: 'theme.json',
|
|
8
|
+
followHost: '__global__',
|
|
9
|
+
appFile: '__local__',
|
|
10
|
+
customPrefix: 'theme-',
|
|
11
|
+
customSuffix: '.css',
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
const CUSTOM_ID = /^[a-z0-9-]+$/
|
|
15
|
+
|
|
16
|
+
export function appThemeCss(appDir: string): string {
|
|
17
|
+
return path.join(appDir, themeLayout.appCss)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function appThemePin(appDir: string): string {
|
|
21
|
+
return path.join(appDir, themeLayout.appPin)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function customThemePath(themesDir: string, id: string): string {
|
|
25
|
+
return path.join(themesDir, `${themeLayout.customPrefix}${id}${themeLayout.customSuffix}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shipped theme files. Same contract as a custom file. Ids come from the filenames. */
|
|
29
|
+
export function builtinThemesDir(): string {
|
|
30
|
+
return fileURLToPath(new URL('../../themes', import.meta.url))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function builtinThemePath(id: string): string {
|
|
34
|
+
return customThemePath(builtinThemesDir(), id)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse `theme-<id>.css`. A name that does not match is not a palette id. */
|
|
38
|
+
export function customThemeId(filename: string): string | undefined {
|
|
39
|
+
if (!filename.startsWith(themeLayout.customPrefix) || !filename.endsWith(themeLayout.customSuffix)) return undefined
|
|
40
|
+
const id = filename.slice(themeLayout.customPrefix.length, -themeLayout.customSuffix.length)
|
|
41
|
+
return CUSTOM_ID.test(id) ? id : undefined
|
|
42
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import type { AppPin } from './pin.ts'
|
|
4
|
+
import { themeTokens } from './tokens.ts'
|
|
5
|
+
import { parseThemeCss } from './parse.ts'
|
|
6
|
+
import { appThemeCss, builtinThemePath, customThemePath } from './layout.ts'
|
|
7
|
+
|
|
8
|
+
export interface FirstPaint {
|
|
9
|
+
source: 'app-file' | 'custom' | 'builtin' | 'host'
|
|
10
|
+
paletteId: string | null
|
|
11
|
+
ignored?: string
|
|
12
|
+
light: Record<string, string>
|
|
13
|
+
dark: Record<string, string>
|
|
14
|
+
style: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Choose the palette that paints the first frame. Token names are copied, not renamed.
|
|
19
|
+
* A file that fails to parse is ignored. The next source in order is used.
|
|
20
|
+
* @param input - pin, host palette id, and the directories that hold theme files
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveFirstPaint(input: {
|
|
23
|
+
appDir: string
|
|
24
|
+
pin: AppPin
|
|
25
|
+
hostPalette: string
|
|
26
|
+
themesDir: string
|
|
27
|
+
}): Promise<FirstPaint> {
|
|
28
|
+
if (input.pin.kind === 'follow-host') return await hostPaint(input.hostPalette, input.themesDir)
|
|
29
|
+
if (input.pin.kind === 'palette') {
|
|
30
|
+
const chosen = await readPalette(input.themesDir, input.pin.id)
|
|
31
|
+
if (chosen !== undefined) return chosen
|
|
32
|
+
}
|
|
33
|
+
const local = await readPaint(appThemeCss(input.appDir), 'app', 'app-file')
|
|
34
|
+
const ignored = input.pin.kind === 'palette'
|
|
35
|
+
? `custom palette ${input.pin.id} was ignored`
|
|
36
|
+
: input.pin.kind === 'app-file' ? 'app theme.css was ignored' : undefined
|
|
37
|
+
if (local !== undefined) return { ...local, ...ignored === undefined ? {} : { ignored } }
|
|
38
|
+
const host = await hostPaint(input.hostPalette, input.themesDir)
|
|
39
|
+
return { ...host, ...ignored === undefined ? {} : { ignored } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Both modes from one theme file. The picker applies this without reloading a document. */
|
|
43
|
+
export function styleFromThemeCss(css: string): string {
|
|
44
|
+
return firstPaintStyle(varsFor(css, 'light'), varsFor(css, 'dark'))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** CSS to bake into the first HTML response. Empty when the source has no file. */
|
|
48
|
+
export function firstPaintStyle(light: Record<string, string>, dark: Record<string, string>): string {
|
|
49
|
+
return `${block('light', light)}${block('dark', dark)}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function hostPaint(paletteId: string, themesDir: string): Promise<FirstPaint> {
|
|
53
|
+
return await readPalette(themesDir, paletteId) ?? { source: 'host', paletteId, light: {}, dark: {}, style: '' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readPalette(themesDir: string, id: string): Promise<FirstPaint | undefined> {
|
|
57
|
+
const custom = await readPaint(customThemePath(themesDir, id), id, 'custom')
|
|
58
|
+
if (custom !== undefined) return { ...custom, paletteId: id }
|
|
59
|
+
const builtin = await readPaint(builtinThemePath(id), id, 'builtin')
|
|
60
|
+
return builtin === undefined ? undefined : { ...builtin, paletteId: id }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readPaint(
|
|
64
|
+
file: string,
|
|
65
|
+
fallbackName: string,
|
|
66
|
+
source: 'app-file' | 'custom' | 'builtin',
|
|
67
|
+
): Promise<FirstPaint | undefined> {
|
|
68
|
+
const css = await readFile(file, 'utf8').catch(() => undefined)
|
|
69
|
+
if (css === undefined) return undefined
|
|
70
|
+
const parsed = parseThemeCss(css, fallbackName)
|
|
71
|
+
if (!parsed.ok) return undefined
|
|
72
|
+
const light = varsFor(css, 'light')
|
|
73
|
+
const dark = varsFor(css, 'dark')
|
|
74
|
+
return { source, paletteId: null, light, dark, style: firstPaintStyle(light, dark) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function varsFor(css: string, mode: 'light' | 'dark'): Record<string, string> {
|
|
78
|
+
const body = modeBlock(css, mode)
|
|
79
|
+
if (body === undefined) return {}
|
|
80
|
+
const vars: Record<string, string> = {}
|
|
81
|
+
for (const token of themeTokens) {
|
|
82
|
+
const value = tokenValue(body, token)
|
|
83
|
+
if (value === undefined) continue
|
|
84
|
+
vars[`--${token}`] = value
|
|
85
|
+
}
|
|
86
|
+
return vars
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function modeBlock(css: string, mode: 'light' | 'dark'): string | undefined {
|
|
90
|
+
const at = css.indexOf(`data-mode="${mode}"`)
|
|
91
|
+
if (at < 0) return undefined
|
|
92
|
+
const open = css.indexOf('{', at)
|
|
93
|
+
const close = open < 0 ? -1 : css.indexOf('}', open)
|
|
94
|
+
if (open < 0 || close < 0) return undefined
|
|
95
|
+
return css.slice(open + 1, close)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tokenValue(block: string, token: string): string | undefined {
|
|
99
|
+
const match = new RegExp(`(?:^|[\\s;{])--${token}\\s*:\\s*([^;\\n]+)`).exec(block)
|
|
100
|
+
const value = match?.[1]?.trim()
|
|
101
|
+
return value === undefined || value.length === 0 ? undefined : value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function block(mode: 'light' | 'dark', vars: Record<string, string>): string {
|
|
105
|
+
const body = Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(';')
|
|
106
|
+
if (body.length === 0) return ''
|
|
107
|
+
return `:root[data-mode="${mode}"]{${body}}`
|
|
108
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { requiredThemeTokens } from './tokens.ts'
|
|
2
|
+
|
|
3
|
+
/** Token names required in both modes. A short name such as `--bg` does not count. */
|
|
4
|
+
|
|
5
|
+
export interface ParsedTheme {
|
|
6
|
+
/** Display name for English (and fallback). */
|
|
7
|
+
name: string
|
|
8
|
+
/** Optional Chinese display name. */
|
|
9
|
+
nameZh?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Admit a theme file. A missing required key ignores the file. It is not partially applied.
|
|
14
|
+
* @param css - file text
|
|
15
|
+
* @param fallbackName - id used when the header has no name
|
|
16
|
+
*/
|
|
17
|
+
export function parseThemeCss(css: string, fallbackName: string): { ok: true; theme: ParsedTheme } | { ok: false; reason: string } {
|
|
18
|
+
for (const mode of ['light', 'dark'] as const) {
|
|
19
|
+
const block = modeBlock(css, mode)
|
|
20
|
+
if (block === undefined) return { ok: false, reason: `missing ${mode} mode` }
|
|
21
|
+
for (const key of requiredThemeTokens) {
|
|
22
|
+
if (!hasShort(block, key)) return { ok: false, reason: `missing --${key} in ${mode}` }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { ok: true, theme: headerNames(css, fallbackName) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function modeBlock(css: string, mode: 'light' | 'dark'): string | undefined {
|
|
29
|
+
const at = css.indexOf(`data-mode="${mode}"`)
|
|
30
|
+
if (at < 0) return undefined
|
|
31
|
+
const open = css.indexOf('{', at)
|
|
32
|
+
const close = open < 0 ? -1 : css.indexOf('}', open)
|
|
33
|
+
if (open < 0 || close < 0) return undefined
|
|
34
|
+
return css.slice(open + 1, close)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasShort(block: string, key: string): boolean {
|
|
38
|
+
return new RegExp(`(?:^|[\\s;{])--${key}\\s*:`).test(block)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `/* name: English *\/` and optional `/* name-zh-CN: 中文 *\/`.
|
|
43
|
+
* A single `name` that is only Chinese still works as the sole label.
|
|
44
|
+
*/
|
|
45
|
+
function headerNames(css: string, fallbackName: string): ParsedTheme {
|
|
46
|
+
const name = headerField(css, 'name')
|
|
47
|
+
const nameZh = headerField(css, 'name-zh-CN')
|
|
48
|
+
if (name !== undefined && nameZh !== undefined) return { name, nameZh }
|
|
49
|
+
if (name !== undefined) return { name }
|
|
50
|
+
if (nameZh !== undefined) return { name: nameZh, nameZh }
|
|
51
|
+
return { name: fallbackName }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function headerField(css: string, field: string): string | undefined {
|
|
55
|
+
const match = new RegExp(`/\\*\\s*${field}:\\s*([^*]+?)\\s*\\*/`).exec(css)
|
|
56
|
+
const value = match?.[1]?.trim()
|
|
57
|
+
return value === undefined || value.length === 0 ? undefined : value
|
|
58
|
+
}
|
package/src/theme/pin.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import type { AppSummary } from '../apps/registry.ts'
|
|
4
|
+
import { builtinThemesDir, customThemeId, customThemePath, themeLayout, appThemeCss, appThemePin } from './layout.ts'
|
|
5
|
+
import { ThemeError } from './codes.ts'
|
|
6
|
+
import { styleFromThemeCss } from './paint.ts'
|
|
7
|
+
import { parseThemeCss } from './parse.ts'
|
|
8
|
+
|
|
9
|
+
export type AppPin =
|
|
10
|
+
| { kind: 'default' }
|
|
11
|
+
| { kind: 'follow-host' }
|
|
12
|
+
| { kind: 'app-file' }
|
|
13
|
+
| { kind: 'palette'; id: string }
|
|
14
|
+
|
|
15
|
+
export interface PaletteList {
|
|
16
|
+
palettes: Array<{ id: string; name: string; nameZh?: string; swatch: string; style: string; origin: 'builtin' | 'custom' }>
|
|
17
|
+
ignored: Array<{ file: string; reason: string }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Registry {
|
|
21
|
+
get: (appId: string) => Promise<AppSummary>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Owner pin and palette list. Panel HTTP calls these through the loopback routes.
|
|
26
|
+
* @param registry - registered apps
|
|
27
|
+
* @param themesDir - host-global custom theme directory; creating it is allowed, listing does not require it
|
|
28
|
+
*/
|
|
29
|
+
export function createThemePins(registry: Registry, themesDir: string) {
|
|
30
|
+
return {
|
|
31
|
+
listPalettes: () => listPalettes(themesDir),
|
|
32
|
+
readPin: (appId: string) => readPin(registry, appId),
|
|
33
|
+
appFile: (appId: string) => appFileParsed(registry, appId),
|
|
34
|
+
readAppTheme: (appId: string) => readAppTheme(registry, appId),
|
|
35
|
+
setPin: (appId: string, pin: AppPin) => setPin(registry, themesDir, appId, pin),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function listPalettes(themesDir: string): Promise<PaletteList> {
|
|
40
|
+
const palettes = await readThemeDir(builtinThemesDir(), [])
|
|
41
|
+
const ignored: PaletteList['ignored'] = []
|
|
42
|
+
const names = await readdir(themesDir).catch(() => [])
|
|
43
|
+
for (const file of names) {
|
|
44
|
+
const id = customThemeId(file)
|
|
45
|
+
if (id === undefined) {
|
|
46
|
+
ignored.push({ file, reason: 'file name is not theme-<id>.css' })
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
const text = await readFile(customThemePath(themesDir, id), 'utf8').catch(() => undefined)
|
|
50
|
+
if (text === undefined) {
|
|
51
|
+
ignored.push({ file, reason: 'theme file is unreadable' })
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
const parsed = parseThemeCss(text, id)
|
|
55
|
+
if (!parsed.ok) {
|
|
56
|
+
ignored.push({ file, reason: parsed.reason })
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const at = palettes.findIndex(item => item.id === id)
|
|
60
|
+
if (at >= 0) palettes.splice(at, 1)
|
|
61
|
+
palettes.push(paletteRow(id, parsed.theme, text, 'custom'))
|
|
62
|
+
}
|
|
63
|
+
return { palettes, ignored }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readThemeDir(dir: string, ignored: PaletteList['ignored']): Promise<PaletteList['palettes']> {
|
|
67
|
+
const palettes: PaletteList['palettes'] = []
|
|
68
|
+
const names = await readdir(dir).catch(() => [])
|
|
69
|
+
for (const file of names) {
|
|
70
|
+
const id = customThemeId(file)
|
|
71
|
+
if (id === undefined) continue
|
|
72
|
+
const text = await readFile(customThemePath(dir, id), 'utf8').catch(() => undefined)
|
|
73
|
+
if (text === undefined) continue
|
|
74
|
+
const parsed = parseThemeCss(text, id)
|
|
75
|
+
if (!parsed.ok) {
|
|
76
|
+
ignored.push({ file, reason: parsed.reason })
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
palettes.push(paletteRow(id, parsed.theme, text, 'builtin'))
|
|
80
|
+
}
|
|
81
|
+
return palettes
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function paletteRow(
|
|
85
|
+
id: string,
|
|
86
|
+
theme: { name: string; nameZh?: string },
|
|
87
|
+
text: string,
|
|
88
|
+
origin: 'builtin' | 'custom',
|
|
89
|
+
): PaletteList['palettes'][number] {
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
name: theme.name,
|
|
93
|
+
...theme.nameZh === undefined ? {} : { nameZh: theme.nameZh },
|
|
94
|
+
swatch: swatchOf(text),
|
|
95
|
+
style: styleFromThemeCss(text),
|
|
96
|
+
origin,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function swatchOf(css: string): string {
|
|
101
|
+
const light = css.split('[data-mode="dark"]')[0] ?? css
|
|
102
|
+
const match = /--primary:\s*([^;]+)/.exec(light)
|
|
103
|
+
const value = match?.[1]?.trim()
|
|
104
|
+
return value !== undefined && value.length > 0 ? value : '#888'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readPin(registry: Registry, appId: string): Promise<AppPin> {
|
|
108
|
+
const app = await registry.get(appId)
|
|
109
|
+
const text = await readFile(appThemePin(app.directory), 'utf8').catch(() => undefined)
|
|
110
|
+
if (text === undefined) return { kind: 'default' }
|
|
111
|
+
return admitPin(text)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function setPin(registry: Registry, themesDir: string, appId: string, pin: AppPin): Promise<AppPin> {
|
|
115
|
+
const app = await registry.get(appId)
|
|
116
|
+
const file = appThemePin(app.directory)
|
|
117
|
+
if (pin.kind === 'default') {
|
|
118
|
+
await rm(file, { force: true })
|
|
119
|
+
return { kind: 'default' }
|
|
120
|
+
}
|
|
121
|
+
if (pin.kind === 'palette') await assertPalette(themesDir, pin.id)
|
|
122
|
+
if (pin.kind === 'app-file') await assertAppFile(app.directory)
|
|
123
|
+
const previous = await readFile(file, 'utf8').catch(() => undefined)
|
|
124
|
+
try {
|
|
125
|
+
await writeFile(file, `${JSON.stringify({ palette: stored(pin) })}\n`)
|
|
126
|
+
return pin
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (previous === undefined) await rm(file, { force: true })
|
|
129
|
+
else await writeFile(file, previous)
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function assertPalette(themesDir: string, id: string): Promise<void> {
|
|
135
|
+
const listed = await listPalettes(themesDir)
|
|
136
|
+
if (listed.palettes.some(item => item.id === id)) return
|
|
137
|
+
throw new ThemeError('theme-invalid', `unknown palette: ${id}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function assertAppFile(appDir: string): Promise<void> {
|
|
141
|
+
const parsed = await readAppFile(appDir)
|
|
142
|
+
if (parsed === undefined) throw new ThemeError('theme-invalid', 'app theme.css is missing')
|
|
143
|
+
if (!parsed.ok) throw new ThemeError('theme-invalid', parsed.reason)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function appFileParsed(registry: Registry, appId: string): Promise<boolean> {
|
|
147
|
+
const app = await registry.get(appId)
|
|
148
|
+
const parsed = await readAppFile(app.directory)
|
|
149
|
+
return parsed?.ok === true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readAppTheme(registry: Registry, appId: string): Promise<{
|
|
153
|
+
name?: string
|
|
154
|
+
nameZh?: string
|
|
155
|
+
swatch: string
|
|
156
|
+
style: string
|
|
157
|
+
} | null> {
|
|
158
|
+
const app = await registry.get(appId)
|
|
159
|
+
const text = await readFile(appThemeCss(app.directory), 'utf8').catch(() => undefined)
|
|
160
|
+
if (text === undefined) return null
|
|
161
|
+
const parsed = parseThemeCss(text, 'app')
|
|
162
|
+
if (!parsed.ok) return null
|
|
163
|
+
const named = parsed.theme.name !== 'app'
|
|
164
|
+
return {
|
|
165
|
+
...named ? { name: parsed.theme.name } : {},
|
|
166
|
+
...parsed.theme.nameZh === undefined ? {} : { nameZh: parsed.theme.nameZh },
|
|
167
|
+
swatch: swatchOf(text),
|
|
168
|
+
style: styleFromThemeCss(text),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function readAppFile(appDir: string) {
|
|
173
|
+
const text = await readFile(appThemeCss(appDir), 'utf8').catch(() => undefined)
|
|
174
|
+
if (text === undefined) return undefined
|
|
175
|
+
return parseThemeCss(text, themeLayout.appFile)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function stored(pin: Exclude<AppPin, { kind: 'default' }>): string {
|
|
179
|
+
if (pin.kind === 'follow-host') return themeLayout.followHost
|
|
180
|
+
if (pin.kind === 'app-file') return themeLayout.appFile
|
|
181
|
+
return pin.id
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function admitPin(text: string): AppPin {
|
|
185
|
+
let parsed: unknown
|
|
186
|
+
try {
|
|
187
|
+
parsed = JSON.parse(text) as unknown
|
|
188
|
+
} catch {
|
|
189
|
+
return { kind: 'default' }
|
|
190
|
+
}
|
|
191
|
+
if (typeof parsed !== 'object' || parsed === null || !('palette' in parsed) || typeof parsed.palette !== 'string') {
|
|
192
|
+
return { kind: 'default' }
|
|
193
|
+
}
|
|
194
|
+
if (parsed.palette === themeLayout.followHost) return { kind: 'follow-host' }
|
|
195
|
+
if (parsed.palette === themeLayout.appFile) return { kind: 'app-file' }
|
|
196
|
+
return { kind: 'palette', id: parsed.palette }
|
|
197
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme tokens. The theme file declares these names. There is no second spelling.
|
|
3
|
+
* A missing optional token is omitted. No invented fallback colour.
|
|
4
|
+
*/
|
|
5
|
+
export const themeTokens = [
|
|
6
|
+
'background',
|
|
7
|
+
'foreground',
|
|
8
|
+
'card',
|
|
9
|
+
'card-foreground',
|
|
10
|
+
'border',
|
|
11
|
+
'muted',
|
|
12
|
+
'muted-foreground',
|
|
13
|
+
'primary',
|
|
14
|
+
'primary-foreground',
|
|
15
|
+
'secondary',
|
|
16
|
+
'secondary-foreground',
|
|
17
|
+
'accent',
|
|
18
|
+
'accent-foreground',
|
|
19
|
+
'destructive',
|
|
20
|
+
'destructive-foreground',
|
|
21
|
+
'ring',
|
|
22
|
+
'input',
|
|
23
|
+
'radius',
|
|
24
|
+
'shadow',
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
export type ThemeToken = (typeof themeTokens)[number]
|
|
28
|
+
|
|
29
|
+
/** Required in both modes. A file missing one is ignored, not partially applied. */
|
|
30
|
+
export const requiredThemeTokens = ['background', 'foreground', 'primary'] as const
|