@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.
@@ -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(32),
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
- // Admin picks instance theme → user only toggles light/dark → server resolves.
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
- const instanceTheme = await getInstanceDefaultTheme();
23
- event.context.instanceTheme = instanceTheme;
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 resolved = resolveThemeForUser(instanceTheme, userScheme);
32
- event.context.resolvedTheme = resolved;
33
- event.context.isDarkMode = isDefaultDark(resolved);
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 = 300_000; // 5 minutes — admin changes take up to 5 min to propagate
15
+ const CACHE_TTL = 60_000; // 1 minute — admin changes propagate fast
10
16
 
11
- let cached: string | null = null;
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
- export async function getInstanceDefaultTheme(): Promise<string> {
15
- const now = Date.now();
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
- if (row?.value && typeof row.value === 'string' && VALID_THEME_IDS.has(row.value)) {
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 from DB, using fallback:', err instanceof Error ? err.message : String(err));
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 the final theme ID given the admin's chosen default and the
41
- * user's light/dark preference.
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 resolveThemeForUser(
44
- adminTheme: string,
92
+ export async function resolveThemeContext(
45
93
  userScheme: 'light' | 'dark' | null,
46
- ): string {
47
- const family = THEME_TO_FAMILY[adminTheme] ?? 'classic';
48
- const variants = FAMILY_VARIANTS[family] ?? FAMILY_VARIANTS.classic!;
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
- if (userScheme === null) return adminTheme;
51
- return userScheme === 'dark' ? variants.dark : variants.light;
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
+ }
@@ -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
+ }