@commonpub/layer 0.21.22 → 0.22.1
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/components/admin/theme/AdminThemeFamilyCard.vue +277 -0
- package/components/admin/theme/AdminThemeOverridesPanel.vue +222 -0
- package/components/admin/theme/AdminThemePreviewPane.vue +218 -0
- package/components/admin/theme/AdminThemeSceneAdmin.vue +189 -0
- package/components/admin/theme/AdminThemeSceneGallery.vue +353 -0
- package/components/admin/theme/AdminThemeSceneProse.vue +140 -0
- package/components/admin/theme/AdminThemeTokenGroup.vue +98 -0
- package/components/admin/theme/AdminThemeTokenInput.vue +278 -0
- package/composables/useTheme.ts +24 -14
- package/composables/useThemeAdmin.ts +167 -0
- package/package.json +7 -7
- package/pages/admin/theme/edit/[id].vue +595 -0
- package/pages/admin/theme/index.vue +449 -0
- package/plugins/theme.ts +25 -7
- package/server/api/admin/themes/[id].delete.ts +40 -0
- package/server/api/admin/themes/[id].get.ts +20 -0
- package/server/api/admin/themes/[id].put.ts +45 -0
- package/server/api/admin/themes/discover.get.ts +22 -0
- package/server/api/admin/themes/index.get.ts +40 -0
- package/server/api/admin/themes/index.post.ts +46 -0
- package/server/api/profile/theme.put.ts +2 -1
- package/server/middleware/theme.ts +23 -9
- package/server/utils/instanceTheme.ts +145 -25
- package/types/theme.ts +54 -0
- package/utils/themeDiscovery.ts +67 -0
- package/utils/themeIO.ts +79 -0
- package/utils/themeIds.ts +25 -0
- package/pages/admin/theme.vue +0 -502
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/admin/themes
|
|
3
|
+
*
|
|
4
|
+
* Create a new custom theme. The ID must be unique (case-insensitive) among
|
|
5
|
+
* built-in IDs and existing custom IDs. Returns the saved record.
|
|
6
|
+
*/
|
|
7
|
+
import { customThemeSchema } from '@commonpub/schema';
|
|
8
|
+
import { BUILT_IN_THEMES } from '@commonpub/ui';
|
|
9
|
+
import { listCustomThemes, saveCustomTheme } from '@commonpub/server';
|
|
10
|
+
|
|
11
|
+
const BUILT_IN_IDS = new Set(BUILT_IN_THEMES.map((t) => t.id));
|
|
12
|
+
|
|
13
|
+
export default defineEventHandler(async (event) => {
|
|
14
|
+
requireFeature('admin');
|
|
15
|
+
const admin = requireAdmin(event);
|
|
16
|
+
const db = useDB();
|
|
17
|
+
|
|
18
|
+
const input = await parseBody(event, customThemeSchema);
|
|
19
|
+
|
|
20
|
+
if (BUILT_IN_IDS.has(input.id)) {
|
|
21
|
+
throw createError({ statusCode: 409, statusMessage: `Cannot use built-in theme ID: ${input.id}` });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const existing = await listCustomThemes(db);
|
|
25
|
+
if (existing.some((t) => t.id === input.id)) {
|
|
26
|
+
throw createError({ statusCode: 409, statusMessage: `A custom theme with id "${input.id}" already exists` });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const saved = await saveCustomTheme(
|
|
30
|
+
db,
|
|
31
|
+
{
|
|
32
|
+
id: input.id,
|
|
33
|
+
name: input.name,
|
|
34
|
+
description: input.description ?? '',
|
|
35
|
+
family: input.family,
|
|
36
|
+
isDark: input.isDark,
|
|
37
|
+
pairId: input.pairId,
|
|
38
|
+
parentTheme: input.parentTheme,
|
|
39
|
+
tokens: input.tokens ?? {},
|
|
40
|
+
},
|
|
41
|
+
admin.id,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
invalidateThemeCache();
|
|
45
|
+
return saved;
|
|
46
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { setUserTheme } from '@commonpub/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
|
|
4
|
+
// Permissive length: `cpub-custom-<slug>` can be longer than 32 chars.
|
|
4
5
|
const themeSchema = z.object({
|
|
5
|
-
themeId: z.string().min(1).max(
|
|
6
|
+
themeId: z.string().min(1).max(96).regex(/^[a-z0-9][a-z0-9_-]*$/i),
|
|
6
7
|
});
|
|
7
8
|
|
|
8
9
|
export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// Resolves the active theme for SSR and injects it into the event context.
|
|
2
|
-
// Runs before page rendering so the theme plugin can set data-theme on <html
|
|
3
|
-
//
|
|
4
|
-
|
|
2
|
+
// Runs before page rendering so the theme plugin can set data-theme on <html>
|
|
3
|
+
// and inject inline token styles for custom/code-registered themes.
|
|
4
|
+
|
|
5
|
+
import { tokensToCss } from '@commonpub/ui';
|
|
5
6
|
|
|
6
7
|
declare module 'h3' {
|
|
7
8
|
interface H3EventContext {
|
|
8
|
-
/** Final resolved theme ID for this request */
|
|
9
|
+
/** Final resolved theme ID for this request (data-theme attr value) */
|
|
9
10
|
resolvedTheme: string;
|
|
10
11
|
/** The admin-configured instance default theme */
|
|
11
12
|
instanceTheme: string;
|
|
12
13
|
/** Whether the resolved theme is dark */
|
|
13
14
|
isDarkMode: boolean;
|
|
15
|
+
/** Inline CSS string to inject (custom theme tokens + instance overrides). Empty if none. */
|
|
16
|
+
themeInlineCss: string;
|
|
14
17
|
}
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -19,8 +22,9 @@ export default defineEventHandler(async (event) => {
|
|
|
19
22
|
const path = getRequestURL(event).pathname;
|
|
20
23
|
if (path.startsWith('/api') || path.startsWith('/_nuxt') || path.startsWith('/__nuxt')) return;
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
// Collect IDs of code-registered themes so we accept them as valid
|
|
26
|
+
const config = useConfig() as unknown as { themes?: Array<{ id: string }> };
|
|
27
|
+
const registeredIds = new Set((config.themes ?? []).map((t) => t.id));
|
|
24
28
|
|
|
25
29
|
// Read user's light/dark preference from cookie
|
|
26
30
|
const schemeCookie = getCookie(event, 'cpub-color-scheme');
|
|
@@ -28,7 +32,17 @@ export default defineEventHandler(async (event) => {
|
|
|
28
32
|
? schemeCookie
|
|
29
33
|
: null;
|
|
30
34
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
event.context.
|
|
35
|
+
const ctx = await resolveThemeContext(userScheme, registeredIds);
|
|
36
|
+
|
|
37
|
+
event.context.instanceTheme = ctx.instanceTheme;
|
|
38
|
+
event.context.resolvedTheme = ctx.resolvedTheme;
|
|
39
|
+
event.context.isDarkMode = ctx.isDark;
|
|
40
|
+
|
|
41
|
+
// Build the inline style block. We scope tokens to `:root` so they
|
|
42
|
+
// override the loaded theme CSS but lose to inline element styles.
|
|
43
|
+
// Token overrides are added last (already merged into injectedTokens
|
|
44
|
+
// in resolveThemeContext) so they win.
|
|
45
|
+
event.context.themeInlineCss = Object.keys(ctx.injectedTokens).length > 0
|
|
46
|
+
? tokensToCss(':root', ctx.injectedTokens)
|
|
47
|
+
: '';
|
|
34
48
|
});
|
|
@@ -4,64 +4,184 @@
|
|
|
4
4
|
|
|
5
5
|
import { eq } from 'drizzle-orm';
|
|
6
6
|
import { instanceSettings } from '@commonpub/schema';
|
|
7
|
+
import {
|
|
8
|
+
getCustomTokenOverrides,
|
|
9
|
+
listCustomThemes,
|
|
10
|
+
parseCustomThemeId,
|
|
11
|
+
type CustomThemeRecord,
|
|
12
|
+
} from '@commonpub/server';
|
|
7
13
|
import { THEME_TO_FAMILY, FAMILY_VARIANTS, IS_DARK, VALID_THEME_IDS } from '../../utils/themeConfig';
|
|
8
14
|
|
|
9
|
-
const CACHE_TTL =
|
|
15
|
+
const CACHE_TTL = 60_000; // 1 minute — admin changes propagate fast
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
interface CachedThemeState {
|
|
18
|
+
/** The admin's chosen default theme (built-in id, custom data-attr, or registered id) */
|
|
19
|
+
defaultTheme: string;
|
|
20
|
+
/** All DB-stored custom themes, keyed by their data-theme attribute (`cpub-custom-<slug>`) */
|
|
21
|
+
customByAttr: Map<string, CustomThemeRecord>;
|
|
22
|
+
/** Instance-wide token overrides applied on top of the active theme */
|
|
23
|
+
tokenOverrides: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let cached: CachedThemeState | null = null;
|
|
12
27
|
let cacheTime = 0;
|
|
13
28
|
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
if (cached !== null && now - cacheTime < CACHE_TTL) return cached;
|
|
29
|
+
async function loadThemeState(): Promise<CachedThemeState> {
|
|
30
|
+
const db = useDB();
|
|
17
31
|
|
|
32
|
+
// 1. Default theme ID
|
|
33
|
+
let defaultTheme = 'base';
|
|
18
34
|
try {
|
|
19
|
-
const db = useDB();
|
|
20
35
|
const [row] = await db
|
|
21
36
|
.select({ value: instanceSettings.value })
|
|
22
37
|
.from(instanceSettings)
|
|
23
38
|
.where(eq(instanceSettings.key, 'theme.default'));
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
cached = row.value;
|
|
27
|
-
} else {
|
|
28
|
-
cached = 'base';
|
|
39
|
+
if (row?.value && typeof row.value === 'string' && row.value.length > 0) {
|
|
40
|
+
defaultTheme = row.value;
|
|
29
41
|
}
|
|
30
42
|
} catch (err) {
|
|
31
|
-
console.warn('[theme] Failed to read instance theme
|
|
32
|
-
cached = 'base';
|
|
43
|
+
console.warn('[theme] Failed to read instance theme:', err instanceof Error ? err.message : String(err));
|
|
33
44
|
}
|
|
34
45
|
|
|
46
|
+
// 2. DB-stored custom themes
|
|
47
|
+
let customByAttr = new Map<string, CustomThemeRecord>();
|
|
48
|
+
try {
|
|
49
|
+
const customs = await listCustomThemes(db);
|
|
50
|
+
customByAttr = new Map(customs.map((t) => [`cpub-custom-${t.id}`, t]));
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.warn('[theme] Failed to load custom themes:', err instanceof Error ? err.message : String(err));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Instance-wide token overrides
|
|
56
|
+
let tokenOverrides: Record<string, string> = {};
|
|
57
|
+
try {
|
|
58
|
+
tokenOverrides = await getCustomTokenOverrides(db);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.warn('[theme] Failed to load token overrides:', err instanceof Error ? err.message : String(err));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { defaultTheme, customByAttr, tokenOverrides };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function getState(): Promise<CachedThemeState> {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
if (cached !== null && now - cacheTime < CACHE_TTL) return cached;
|
|
69
|
+
cached = await loadThemeState();
|
|
35
70
|
cacheTime = now;
|
|
36
71
|
return cached;
|
|
37
72
|
}
|
|
38
73
|
|
|
74
|
+
/** Validate a theme ID against built-in, custom, and registered themes. */
|
|
75
|
+
function isKnownThemeId(id: string, state: CachedThemeState, registeredIds: Set<string>): boolean {
|
|
76
|
+
if (VALID_THEME_IDS.has(id)) return true;
|
|
77
|
+
if (state.customByAttr.has(id)) return true;
|
|
78
|
+
if (registeredIds.has(id)) return true;
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function getInstanceDefaultTheme(): Promise<string> {
|
|
83
|
+
const state = await getState();
|
|
84
|
+
return state.defaultTheme;
|
|
85
|
+
}
|
|
86
|
+
|
|
39
87
|
/**
|
|
40
|
-
* Resolve
|
|
41
|
-
*
|
|
88
|
+
* Resolve everything SSR needs for the active theme on this request:
|
|
89
|
+
* the data-theme attribute value, the inherited family/mode, and any
|
|
90
|
+
* inline tokens (custom theme + instance overrides) to inject.
|
|
42
91
|
*/
|
|
43
|
-
export function
|
|
44
|
-
adminTheme: string,
|
|
92
|
+
export async function resolveThemeContext(
|
|
45
93
|
userScheme: 'light' | 'dark' | null,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
94
|
+
registeredIds: Set<string>,
|
|
95
|
+
): Promise<{
|
|
96
|
+
/** Final data-theme value for <html> */
|
|
97
|
+
resolvedTheme: string;
|
|
98
|
+
/** Admin's chosen default (before light/dark override) */
|
|
99
|
+
instanceTheme: string;
|
|
100
|
+
/** Whether the resolved theme is dark */
|
|
101
|
+
isDark: boolean;
|
|
102
|
+
/** Token map to inject as inline :root style (custom theme tokens + overrides). Empty when not needed. */
|
|
103
|
+
injectedTokens: Record<string, string>;
|
|
104
|
+
}> {
|
|
105
|
+
const state = await getState();
|
|
49
106
|
|
|
50
|
-
|
|
51
|
-
|
|
107
|
+
// Validate the admin's choice — fall back to base if missing/unknown
|
|
108
|
+
const admin = isKnownThemeId(state.defaultTheme, state, registeredIds) ? state.defaultTheme : 'base';
|
|
109
|
+
|
|
110
|
+
// Light/dark resolution. We only flip variants for built-in family pairs;
|
|
111
|
+
// custom themes use their declared pair if present, otherwise stay put.
|
|
112
|
+
let resolved = admin;
|
|
113
|
+
if (userScheme !== null) {
|
|
114
|
+
// Built-in family flip
|
|
115
|
+
if (VALID_THEME_IDS.has(admin)) {
|
|
116
|
+
const family = THEME_TO_FAMILY[admin] ?? 'classic';
|
|
117
|
+
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
118
|
+
resolved = userScheme === 'dark' ? variants.dark : variants.light;
|
|
119
|
+
} else {
|
|
120
|
+
// Custom theme — use pairId if defined
|
|
121
|
+
const custom = state.customByAttr.get(admin);
|
|
122
|
+
if (custom?.pairId) {
|
|
123
|
+
const pairAttr = `cpub-custom-${custom.pairId}`;
|
|
124
|
+
const pair = state.customByAttr.get(pairAttr);
|
|
125
|
+
if (pair && pair.isDark === (userScheme === 'dark')) {
|
|
126
|
+
resolved = pairAttr;
|
|
127
|
+
} else if (custom.isDark === (userScheme === 'dark')) {
|
|
128
|
+
resolved = admin;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// For registered themes (no pair info available server-side), we leave it alone
|
|
132
|
+
// — the layer-app author can declare a pair via the future RegisteredTheme.pairId
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// isDark detection
|
|
137
|
+
let isDark = false;
|
|
138
|
+
if (VALID_THEME_IDS.has(resolved)) {
|
|
139
|
+
isDark = IS_DARK[resolved] ?? false;
|
|
140
|
+
} else {
|
|
141
|
+
const custom = state.customByAttr.get(resolved);
|
|
142
|
+
if (custom) isDark = custom.isDark;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Tokens to inject inline. Built-in themes don't need injection (their
|
|
146
|
+
// CSS files are already loaded). Custom themes always inject. Token
|
|
147
|
+
// overrides apply on top of whatever theme is active.
|
|
148
|
+
const injectedTokens: Record<string, string> = {};
|
|
149
|
+
if (state.customByAttr.has(resolved)) {
|
|
150
|
+
Object.assign(injectedTokens, state.customByAttr.get(resolved)!.tokens);
|
|
151
|
+
}
|
|
152
|
+
// Instance overrides always last so they win
|
|
153
|
+
Object.assign(injectedTokens, state.tokenOverrides);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
resolvedTheme: resolved,
|
|
157
|
+
instanceTheme: admin,
|
|
158
|
+
isDark,
|
|
159
|
+
injectedTokens,
|
|
160
|
+
};
|
|
52
161
|
}
|
|
53
162
|
|
|
54
|
-
/** Whether a given theme ID is dark */
|
|
163
|
+
/** Whether a given theme ID is dark (built-in only; legacy callers). */
|
|
55
164
|
export function isDefaultDark(themeId: string): boolean {
|
|
56
165
|
return IS_DARK[themeId] ?? false;
|
|
57
166
|
}
|
|
58
167
|
|
|
59
|
-
/** Call after admin changes the instance default theme */
|
|
168
|
+
/** Call after admin changes the instance default theme or saves a custom theme */
|
|
60
169
|
export function invalidateThemeCache(): void {
|
|
61
170
|
cached = null;
|
|
62
171
|
cacheTime = 0;
|
|
63
172
|
}
|
|
64
173
|
|
|
65
174
|
export function isServerValidThemeId(id: string): boolean {
|
|
66
|
-
return VALID_THEME_IDS.has(id);
|
|
175
|
+
return VALID_THEME_IDS.has(id) || parseCustomThemeId(id) !== null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Legacy export, used by /api/profile/theme.put.ts and similar. */
|
|
179
|
+
export function resolveThemeForUser(
|
|
180
|
+
adminTheme: string,
|
|
181
|
+
userScheme: 'light' | 'dark' | null,
|
|
182
|
+
): string {
|
|
183
|
+
const family = THEME_TO_FAMILY[adminTheme] ?? 'classic';
|
|
184
|
+
const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
|
|
185
|
+
if (userScheme === null) return adminTheme;
|
|
186
|
+
return userScheme === 'dark' ? variants.dark : variants.light;
|
|
67
187
|
}
|
package/types/theme.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side type shapes for the admin theme system.
|
|
3
|
+
*
|
|
4
|
+
* Server-side equivalents live in `@commonpub/server` (`CustomThemeRecord`)
|
|
5
|
+
* and `@commonpub/config` (`RegisteredTheme`). Duplicating them here lets
|
|
6
|
+
* the admin UI consume the `/api/admin/themes` payload without pulling
|
|
7
|
+
* Node-only server modules into the browser bundle.
|
|
8
|
+
*/
|
|
9
|
+
import type { ThemeDefinition } from '@commonpub/ui';
|
|
10
|
+
|
|
11
|
+
export interface CustomThemeRecord {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
family: string;
|
|
16
|
+
isDark: boolean;
|
|
17
|
+
pairId?: string;
|
|
18
|
+
parentTheme: string;
|
|
19
|
+
tokens: Record<string, string>;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RegisteredThemeRecord {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
family: string;
|
|
29
|
+
isDark: boolean;
|
|
30
|
+
pairId?: string;
|
|
31
|
+
preview?: { bg?: string; surface?: string; accent?: string; text?: string; border?: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Response shape of GET /api/admin/themes. */
|
|
35
|
+
export interface ThemesPayload {
|
|
36
|
+
builtIn: ThemeDefinition[];
|
|
37
|
+
registered: RegisteredThemeRecord[];
|
|
38
|
+
custom: CustomThemeRecord[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ThemeFamilyView {
|
|
42
|
+
/** Family slug — `classic`, `agora`, `deveco`, etc. */
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
description: string;
|
|
46
|
+
/** Which source produced this family. Custom > registered > built-in. */
|
|
47
|
+
source: 'builtin' | 'registered' | 'custom';
|
|
48
|
+
light: { id: string; name: string } | null;
|
|
49
|
+
dark: { id: string; name: string } | null;
|
|
50
|
+
preview: {
|
|
51
|
+
light: { bg: string; surface: string; accent: string; text: string; border: string };
|
|
52
|
+
dark: { bg: string; surface: string; accent: string; text: string; border: string };
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery helpers — read what's currently rendered on the page and
|
|
3
|
+
* diff against the canonical token defaults. Used by the list page to
|
|
4
|
+
* surface "Your site has a custom theme — capture it" when a thin layer
|
|
5
|
+
* app ships its own `:root` overrides via a CSS file.
|
|
6
|
+
*
|
|
7
|
+
* Client-only (reads `document.documentElement` + `getComputedStyle`).
|
|
8
|
+
* Returns empty/safe values on the server.
|
|
9
|
+
*/
|
|
10
|
+
import { TOKEN_SPECS } from '@commonpub/ui';
|
|
11
|
+
|
|
12
|
+
export interface DiscoveredTheme {
|
|
13
|
+
count: number;
|
|
14
|
+
tokens: Record<string, string>;
|
|
15
|
+
isDark: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read `getComputedStyle(:root)` for every canonical token and return
|
|
20
|
+
* the subset that differs from `TOKEN_SPECS[i].default`.
|
|
21
|
+
*/
|
|
22
|
+
export function detectAppliedOverrides(): DiscoveredTheme {
|
|
23
|
+
if (typeof window === 'undefined') return { count: 0, tokens: {}, isDark: false };
|
|
24
|
+
const root = document.documentElement;
|
|
25
|
+
const cs = getComputedStyle(root);
|
|
26
|
+
const overrides: Record<string, string> = {};
|
|
27
|
+
for (const spec of TOKEN_SPECS) {
|
|
28
|
+
const actual = cs.getPropertyValue(`--${spec.key}`).trim();
|
|
29
|
+
if (!actual) continue;
|
|
30
|
+
if (normalize(actual) !== normalize(spec.default)) {
|
|
31
|
+
overrides[spec.key] = actual;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const bg = cs.getPropertyValue('--bg').trim() || '#ffffff';
|
|
35
|
+
return {
|
|
36
|
+
count: Object.keys(overrides).length,
|
|
37
|
+
tokens: overrides,
|
|
38
|
+
isDark: estimateLuminance(bg) < 0.5,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalize(s: string): string {
|
|
43
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Rec. 709 luma estimate for a color. Accepts `#rgb`, `#rrggbb`,
|
|
48
|
+
* `rgb()`, or `rgba()`. Returns a number in [0, 1]. Anything we can't
|
|
49
|
+
* parse returns 1 (treated as light).
|
|
50
|
+
*/
|
|
51
|
+
export function estimateLuminance(color: string): number {
|
|
52
|
+
let r = 255, g = 255, b = 255;
|
|
53
|
+
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
54
|
+
if (hex) {
|
|
55
|
+
const h = hex[1]!;
|
|
56
|
+
const exp = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
|
57
|
+
r = parseInt(exp.slice(0, 2), 16);
|
|
58
|
+
g = parseInt(exp.slice(2, 4), 16);
|
|
59
|
+
b = parseInt(exp.slice(4, 6), 16);
|
|
60
|
+
} else {
|
|
61
|
+
const rgb = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
|
62
|
+
if (rgb) {
|
|
63
|
+
r = Number(rgb[1]); g = Number(rgb[2]); b = Number(rgb[3]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
67
|
+
}
|
package/utils/themeIO.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import / export helpers for custom themes. Versioned JSON format so a
|
|
3
|
+
* future breaking change can be detected and either migrated or rejected.
|
|
4
|
+
*
|
|
5
|
+
* Exported files are named `<theme-id>.cpub-theme.json`.
|
|
6
|
+
*/
|
|
7
|
+
import type { CustomThemeRecord } from '../types/theme';
|
|
8
|
+
|
|
9
|
+
const EXPORT_FORMAT_VERSION = 1 as const;
|
|
10
|
+
|
|
11
|
+
export interface ThemeExportFile {
|
|
12
|
+
filename: string;
|
|
13
|
+
content: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Serialize a theme to a downloadable file payload. */
|
|
17
|
+
export function buildExportFile(theme: CustomThemeRecord): ThemeExportFile {
|
|
18
|
+
const body = {
|
|
19
|
+
formatVersion: EXPORT_FORMAT_VERSION,
|
|
20
|
+
exportedAt: new Date().toISOString(),
|
|
21
|
+
theme,
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
filename: `${theme.id}.cpub-theme.json`,
|
|
25
|
+
content: JSON.stringify(body, null, 2),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a theme export file. Throws a human-readable error for any
|
|
31
|
+
* recoverable failure (invalid JSON, missing fields, wrong version).
|
|
32
|
+
*/
|
|
33
|
+
export function parseExportFile(text: string): CustomThemeRecord {
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(text);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error('Not valid JSON');
|
|
39
|
+
}
|
|
40
|
+
if (!parsed || typeof parsed !== 'object') throw new Error('Expected an object');
|
|
41
|
+
const p = parsed as Record<string, unknown>;
|
|
42
|
+
if (p.formatVersion !== EXPORT_FORMAT_VERSION) {
|
|
43
|
+
throw new Error(`Unsupported export format version: ${String(p.formatVersion)}`);
|
|
44
|
+
}
|
|
45
|
+
if (!p.theme || typeof p.theme !== 'object') throw new Error('Missing `theme` payload');
|
|
46
|
+
const t = p.theme as Record<string, unknown>;
|
|
47
|
+
if (typeof t.id !== 'string' || typeof t.name !== 'string' || typeof t.family !== 'string') {
|
|
48
|
+
throw new Error('Theme payload missing required fields');
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
id: String(t.id),
|
|
52
|
+
name: String(t.name),
|
|
53
|
+
description: typeof t.description === 'string' ? t.description : '',
|
|
54
|
+
family: String(t.family),
|
|
55
|
+
isDark: Boolean(t.isDark),
|
|
56
|
+
pairId: typeof t.pairId === 'string' ? t.pairId : undefined,
|
|
57
|
+
parentTheme: typeof t.parentTheme === 'string' ? t.parentTheme : 'base',
|
|
58
|
+
tokens: (typeof t.tokens === 'object' && t.tokens !== null ? t.tokens : {}) as Record<string, string>,
|
|
59
|
+
createdAt: typeof t.createdAt === 'string' ? t.createdAt : new Date().toISOString(),
|
|
60
|
+
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Trigger a download of an export file. Browser-only — the caller is
|
|
66
|
+
* responsible for skipping this on the server.
|
|
67
|
+
*/
|
|
68
|
+
export function downloadThemeFile(theme: CustomThemeRecord): void {
|
|
69
|
+
const { filename, content } = buildExportFile(theme);
|
|
70
|
+
const blob = new Blob([content], { type: 'application/json' });
|
|
71
|
+
const url = URL.createObjectURL(blob);
|
|
72
|
+
const a = document.createElement('a');
|
|
73
|
+
a.href = url;
|
|
74
|
+
a.download = filename;
|
|
75
|
+
document.body.appendChild(a);
|
|
76
|
+
a.click();
|
|
77
|
+
document.body.removeChild(a);
|
|
78
|
+
URL.revokeObjectURL(url);
|
|
79
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom-theme ID helpers. Mirrors `CUSTOM_THEME_PREFIX` + `parseCustomThemeId`
|
|
3
|
+
* in `@commonpub/server` — duplicated because the server module is Node-only
|
|
4
|
+
* (it imports `drizzle-orm` and `@commonpub/schema`), so the browser bundle
|
|
5
|
+
* can't pull from it.
|
|
6
|
+
*
|
|
7
|
+
* **CONTRACT**: any change to the prefix here MUST also change the matching
|
|
8
|
+
* constant in `packages/server/src/theme.ts`. Both are pinned by the
|
|
9
|
+
* `custom-themes.integration.test.ts` round-trip test.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const CUSTOM_THEME_PREFIX = 'cpub-custom-';
|
|
13
|
+
|
|
14
|
+
/** Returns the raw custom theme ID for `cpub-custom-foo` → `foo`, or null. */
|
|
15
|
+
export function parseCustomThemeId(themeId: string): string | null {
|
|
16
|
+
if (themeId.startsWith(CUSTOM_THEME_PREFIX)) {
|
|
17
|
+
return themeId.slice(CUSTOM_THEME_PREFIX.length);
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The data-theme attribute value for a DB-stored custom theme. */
|
|
23
|
+
export function customThemeDataAttr(id: string): string {
|
|
24
|
+
return `${CUSTOM_THEME_PREFIX}${id}`;
|
|
25
|
+
}
|