Replace __DITTO_CONFIG__ global with import.meta.env.DITTO_CONFIG and remove ThemeSchemaCompat

Move build-time ditto.json injection from a Vite define global to
import.meta.env.DITTO_CONFIG (a JSON string parsed and validated at
runtime via DittoConfigSchema). Remove the global type declaration
from vite-env.d.ts.

Drop ThemeSchemaCompat and its legacy "black"/"pink" migration code
from AppProvider and NostrSync — invalid theme values now simply fail
Zod validation.

Fix a latent bug where a partial feedSettings from ditto.json would
replace the full hardcoded defaults; defaultConfig now deep-merges
feedSettings.
This commit is contained in:
Alex Gleason
2026-04-01 23:16:33 -05:00
parent cbfc8f149f
commit 22f13c1505
7 changed files with 30 additions and 44 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ const builtinThemes = {
};
```
Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
### ThemeConfig
+19 -3
View File
@@ -23,6 +23,7 @@ import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
@@ -149,15 +150,30 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
};
/**
* Parse and validate build-time ditto.json overrides from the env string.
* Returns an empty object when no config file was provided or validation fails.
*/
function parseDittoConfig(): DittoConfig {
try {
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
if (!json) return {};
return DittoConfigSchema.parse(json);
} catch {
return {};
}
}
/**
* Merge hardcoded defaults with build-time ditto.json overrides.
* Deep-merges feedSettings so a partial override doesn't erase defaults.
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
*/
const dittoConfig = parseDittoConfig();
const defaultConfig: AppConfig = {
...hardcodedConfig,
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
? __DITTO_CONFIG__
: {}),
...dittoConfig,
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
};
export function App() {
+1 -8
View File
@@ -1,7 +1,7 @@
import { ReactNode, useLayoutEffect, useEffect, useRef } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext';
import { builtinThemes, themePresets, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
import { builtinThemes, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
import { AppConfigSchema } from '@/lib/schemas';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils';
@@ -47,13 +47,6 @@ export function AppProvider(props: AppProviderProps) {
}
}
// Migrate legacy theme values ("black", "pink") to "custom" + customTheme
const legacyTheme = result.theme as string | undefined;
if (legacyTheme && legacyTheme in themePresets) {
result.theme = 'custom';
result.customTheme = { colors: themePresets[legacyTheme].colors };
}
// Migrate legacy blossomServers (string[]) to blossomServerMetadata
if (!result.blossomServerMetadata) {
const legacyServers = parsed.blossomServers;
+2 -14
View File
@@ -9,7 +9,7 @@ import { isSyncDone } from "@/hooks/useInitialSync";
import { parseBlossomServerList } from "@/lib/appBlossom";
import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent";
import type { ThemeConfig } from "@/themes";
import { themePresets } from "@/themes";
/**
* NostrSync - Syncs user's Nostr data
@@ -285,19 +285,7 @@ export function NostrSync() {
const updates = { ...current };
if (encryptedSettings.theme) {
// Migrate legacy theme values ("black", "pink") from older encrypted settings
const remoteTheme = encryptedSettings.theme as string;
if (remoteTheme in themePresets) {
if (
current.theme !== "custom" ||
JSON.stringify(current.customTheme?.colors) !==
JSON.stringify(themePresets[remoteTheme].colors)
) {
updates.theme = "custom";
updates.customTheme = { colors: themePresets[remoteTheme].colors };
changed = true;
}
} else if (encryptedSettings.theme !== current.theme) {
if (encryptedSettings.theme !== current.theme) {
updates.theme = encryptedSettings.theme;
changed = true;
}
+4 -11
View File
@@ -8,11 +8,6 @@ import type { CoreThemeColors, ThemeConfig, ThemesConfig } from '@/themes';
/** Zod schema for Theme validation */
export const ThemeSchema = z.enum(['dark', 'light', 'system', 'custom']) satisfies z.ZodType<Theme>;
/**
* Accepts current theme values as well as legacy values ("black", "pink")
* from older configs. Consumers should migrate legacy values to "custom".
*/
export const ThemeSchemaCompat = z.enum(['dark', 'light', 'system', 'custom', 'black', 'pink']);
/** HSL value string like "258 70% 55%" */
const HslValue = z.string().regex(/^\d/);
@@ -208,10 +203,8 @@ export const SavedFeedSchema = z.object({
/**
* Zod schema for the full AppConfig stored in localStorage.
*
* Uses compat sub-schemas (ThemeSchemaCompat, ThemeConfigCompatSchema) so
* legacy values parse successfully. Migration from legacy theme values
* ("black", "pink") to "custom" + customTheme is handled downstream by
* the AppProvider deserializer.
* Uses ThemeConfigCompatSchema for the customTheme field so legacy
* 19-token color objects still parse successfully.
*/
export const AppConfigSchema = z.object({
appName: z.string().optional(),
@@ -220,7 +213,7 @@ export const AppConfigSchema = z.object({
clientName: z.string().optional(),
client: z.string().optional(),
magicMouse: z.boolean().optional(),
theme: ThemeSchemaCompat,
theme: ThemeSchema,
customTheme: ThemeConfigCompatSchema.optional(),
autoShareTheme: z.boolean(),
themes: ThemesConfigSchema.optional(),
@@ -298,7 +291,7 @@ export const ContentFilterSchema = z.object({
* Uses looseObject to preserve unknown keys from newer app versions.
*/
export const EncryptedSettingsSchema = z.looseObject({
theme: ThemeSchemaCompat.optional(),
theme: ThemeSchema.optional(),
customTheme: ThemeConfigCompatSchema.optional(),
autoShareTheme: z.boolean().optional(),
useAppRelays: z.boolean().optional(),
+2 -6
View File
@@ -15,10 +15,6 @@ interface ImportMetaEnv {
readonly COMMIT_SHA: string;
/** Git tag for the current commit (e.g., "v2.0.0"). Empty string if untagged (pre-release build). */
readonly COMMIT_TAG: string;
/** Build-time configuration injected from ditto.json as a JSON string. `"null"` when no config file was provided. */
readonly DITTO_CONFIG: string;
}
/**
* Build-time configuration injected by Vite from ditto.json.
* `null` when no config file was provided at build time.
*/
declare const __DITTO_CONFIG__: Partial<import('@/contexts/AppContext').AppConfig> | null;
+1 -1
View File
@@ -136,7 +136,7 @@ export default defineConfig(() => {
...(publicDir ? [mergePublicDir(publicDir)] : []),
],
define: {
__DITTO_CONFIG__: JSON.stringify(dittoConfig ?? null),
'import.meta.env.DITTO_CONFIG': JSON.stringify(JSON.stringify(dittoConfig ?? null)),
'import.meta.env.VERSION': JSON.stringify(pkg.version),
'import.meta.env.BUILD_DATE': JSON.stringify(new Date().toISOString()),
'import.meta.env.COMMIT_SHA': JSON.stringify(getCommitSha()),