diff --git a/.gitignore b/.gitignore index 5a7b417a..2e1f0e0c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ deploy.sh .env.* !.env.example +# Build-time configuration +ditto.json + # Android build outputs and sensitive files *.aab resources/ diff --git a/config/schema.ts b/config/schema.ts new file mode 100644 index 00000000..242f84fb --- /dev/null +++ b/config/schema.ts @@ -0,0 +1,114 @@ +/** + * Canonical Zod schemas for Ditto configuration. + * + * This file is deliberately free of path-alias (`@/`) imports so it can be + * consumed from both the Vite config (Node / esbuild context) and from + * runtime application code via `src/lib/schemas.ts`. + * + * These are the "strict" versions of each schema. Legacy/compat wrappers + * (for old localStorage formats, encrypted settings, etc.) are layered on + * top in `src/lib/schemas.ts`. + */ +import { z } from 'zod'; + +// ─── Primitives ─────────────────────────────────────────────────────── + +/** HSL value string like "258 70% 55%" */ +export const HslValue = z.string().regex(/^\d/); + +export const CoreThemeColorsSchema = z.object({ + background: HslValue, + text: HslValue, + primary: HslValue, +}); + +export const ThemeFontSchema = z.object({ + family: z.string(), + url: z.string().optional(), +}); + +export const ThemeBackgroundSchema = z.object({ + url: z.string(), + mode: z.enum(['cover', 'tile']).optional(), + dimensions: z.string().optional(), + mimeType: z.string().optional(), + blurhash: z.string().optional(), +}); + +export const ThemeConfigSchema = z.object({ + title: z.string().optional(), + colors: CoreThemeColorsSchema, + font: ThemeFontSchema.optional(), + background: ThemeBackgroundSchema.optional(), +}); + +export const ThemesConfigSchema = z.object({ + light: ThemeConfigSchema, + dark: ThemeConfigSchema, +}); + +export const ThemeSchema = z.enum(['dark', 'light', 'system', 'custom']); +export const ContentWarningPolicySchema = z.enum(['blur', 'hide', 'show']); + +export const RelayMetadataSchema = z.object({ + relays: z.array(z.object({ + url: z.string().url(), + read: z.boolean(), + write: z.boolean(), + })), + updatedAt: z.number(), +}); + +export const FeedSettingsSchema = z.object({ + feedIncludePosts: z.boolean().optional(), + feedIncludeReposts: z.boolean().optional(), + feedIncludeArticles: z.boolean().optional(), + showArticles: z.boolean().optional(), + showVines: z.boolean().optional(), + showPolls: z.boolean().optional(), + showTreasures: z.boolean().optional(), + showTreasureGeocaches: z.boolean().optional(), + showTreasureFoundLogs: z.boolean().optional(), + showColors: z.boolean().optional(), + showPacks: z.boolean().optional(), + showStreams: z.boolean().optional(), + feedIncludeVines: z.boolean().optional(), + feedIncludePolls: z.boolean().optional(), + feedIncludeTreasureGeocaches: z.boolean().optional(), + feedIncludeTreasureFoundLogs: z.boolean().optional(), + feedIncludeColors: z.boolean().optional(), + feedIncludePacks: z.boolean().optional(), + feedIncludeStreams: z.boolean().optional(), + showDecks: z.boolean().optional(), + feedIncludeDecks: z.boolean().optional(), + showProfileThemes: z.boolean().optional(), + feedIncludeProfileThemes: z.boolean().optional(), + showCustomProfileThemes: z.boolean().optional(), +}).passthrough(); + +// ─── DittoConfigSchema (build-time ditto.json) ─────────────────────── + +/** + * Schema for the build-time `ditto.json` configuration file. + * All fields are optional — only the values provided will override + * the hardcoded defaults at build time. + */ +export const DittoConfigSchema = z.object({ + theme: ThemeSchema.optional(), + customTheme: ThemeConfigSchema.optional(), + themes: ThemesConfigSchema.optional(), + relayMetadata: RelayMetadataSchema.optional(), + useAppRelays: z.boolean().optional(), + feedSettings: FeedSettingsSchema.optional(), + sidebarOrder: z.array(z.string()).optional(), + nip85StatsPubkey: z.string().optional(), + blossomServers: z.array(z.string().url()).optional(), + defaultZapComment: z.string().optional(), + faviconUrl: z.string().optional(), + linkPreviewUrl: z.string().optional(), + corsProxy: z.string().optional(), + contentWarningPolicy: ContentWarningPolicySchema.optional(), +}).strict(); + +/** Inferred type for the build-time configuration. */ +export type DittoConfig = z.infer; diff --git a/src/App.tsx b/src/App.tsx index 50c90b7f..fcb2d0a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,7 +36,8 @@ const queryClient = new QueryClient({ }, }); -const defaultConfig: AppConfig = { +/** Hardcoded fallback values. Always provides every required field. */ +const hardcodedConfig: AppConfig = { theme: "system", useAppRelays: true, relayMetadata: { @@ -79,6 +80,15 @@ const defaultConfig: AppConfig = { contentWarningPolicy: 'blur', }; +/** + * Merge hardcoded defaults with build-time ditto.json overrides. + * Precedence (handled by AppProvider): user localStorage > build-time > hardcoded. + */ +const defaultConfig: AppConfig = { + ...hardcodedConfig, + ...(__DITTO_CONFIG__ ?? {}), +}; + export function App() { useEffect(() => { // Initialize StatusBar for mobile apps diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 41fe853a..f94888ff 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -1,9 +1,8 @@ import { ReactNode, useLayoutEffect, useEffect } from 'react'; -import { z } from 'zod'; import { useLocalStorage } from '@/hooks/useLocalStorage'; -import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext'; +import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext'; import { builtinThemes, themePresets, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes'; -import { ThemeSchemaCompat, ThemeConfigCompatSchema, ThemesConfigSchema, FeedSettingsSchema, ContentWarningPolicySchema } from '@/lib/schemas'; +import { AppConfigSchema } from '@/lib/schemas'; import { loadAndApplyFont } from '@/lib/fontLoader'; interface AppProviderProps { @@ -14,46 +13,6 @@ interface AppProviderProps { defaultConfig: AppConfig; } -// Zod schema for RelayMetadata validation -const RelayMetadataSchema = z.object({ - relays: z.array(z.object({ - url: z.url(), - read: z.boolean(), - write: z.boolean(), - })), - updatedAt: z.number(), -}) satisfies z.ZodType; - -/** - * Schema for customTheme in AppConfig localStorage. - * Accepts ThemeConfig, bare CoreThemeColors, and legacy ThemeTokens format, - * normalizing to ThemeConfig. - */ -const CustomThemeStorageSchema = ThemeConfigCompatSchema; - -// Zod schema for AppConfig validation. -// Uses ThemeSchemaCompat so legacy "black"/"pink" values parse successfully. -// Migration to "custom" happens in the deserializer below. -const AppConfigSchema = z.object({ - theme: ThemeSchemaCompat, - customTheme: CustomThemeStorageSchema.optional(), - themes: ThemesConfigSchema.optional(), - relayMetadata: RelayMetadataSchema, - useAppRelays: z.boolean(), - feedSettings: FeedSettingsSchema, - sidebarOrder: z.array(z.string()), - nip85StatsPubkey: z.string().refine( - (val) => val.length === 0 || (val.length === 64 && /^[0-9a-f]{64}$/i.test(val)), - { message: 'Must be empty or a valid 64-character hex pubkey' } - ), - blossomServers: z.array(z.url()), - defaultZapComment: z.string(), - faviconUrl: z.string(), - linkPreviewUrl: z.string(), - corsProxy: z.string(), - contentWarningPolicy: ContentWarningPolicySchema, -}); - export function AppProvider(props: AppProviderProps) { const { children, diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index f03e5d73..ff939b2e 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -3,6 +3,31 @@ import { z } from 'zod'; import type { Theme, ContentWarningPolicy } from '@/contexts/AppContext'; import type { CoreThemeColors, ThemeConfig, ThemesConfig } from '@/themes'; +// Re-export canonical schemas from the shared config module so existing +// consumers (`AppProvider`, `useEncryptedSettings`, etc.) keep working +// without changing their import paths. +export { + CoreThemeColorsSchema, + ThemeFontSchema, + ThemeBackgroundSchema, + ThemeConfigSchema, + ThemesConfigSchema, + ContentWarningPolicySchema, + RelayMetadataSchema, + FeedSettingsSchema, +} from '../../config/schema'; + +import { + CoreThemeColorsSchema, + ThemeConfigSchema, + ThemesConfigSchema, + ContentWarningPolicySchema, + RelayMetadataSchema, + FeedSettingsSchema, +} from '../../config/schema'; + +// ─── Type-constrained re-exports ───────────────────────────────────── + /** Zod schema for Theme validation */ export const ThemeSchema = z.enum(['dark', 'light', 'system', 'custom']) satisfies z.ZodType; @@ -12,15 +37,7 @@ export const ThemeSchema = z.enum(['dark', 'light', 'system', 'custom']) satisfi */ export const ThemeSchemaCompat = z.enum(['dark', 'light', 'system', 'custom', 'black', 'pink']); -/** HSL value string like "258 70% 55%" */ -const HslValue = z.string().regex(/^\d/); - -/** Zod schema for CoreThemeColors (the 3 core colors) */ -export const CoreThemeColorsSchema = z.object({ - background: HslValue, - text: HslValue, - primary: HslValue, -}) satisfies z.ZodType; +// ─── Legacy / Compat Schemas ───────────────────────────────────────── /** * Legacy schema that accepts the old 19-token ThemeTokens format. @@ -28,9 +45,9 @@ export const CoreThemeColorsSchema = z.object({ * Extracts core colors from legacy format. */ export const LegacyThemeTokensSchema = z.object({ - background: HslValue, - foreground: HslValue, - primary: HslValue, + background: z.string().regex(/^\d/), + foreground: z.string().regex(/^\d/), + primary: z.string().regex(/^\d/), }).passthrough(); /** @@ -38,10 +55,10 @@ export const LegacyThemeTokensSchema = z.object({ * Strips the secondary field and normalizes to CoreThemeColors. */ export const LegacyFourColorSchema = z.object({ - background: HslValue, - text: HslValue, - primary: HslValue, - secondary: HslValue, + background: z.string().regex(/^\d/), + text: z.string().regex(/^\d/), + primary: z.string().regex(/^\d/), + secondary: z.string().regex(/^\d/), }).transform(({ background, text, primary }): CoreThemeColors => ({ background, text, @@ -62,39 +79,6 @@ export const ThemeColorsCompatSchema = z.union([ })), ]); -// ─── ThemeConfig Schemas ────────────────────────────────────────────── - -/** Zod schema for ThemeFont */ -export const ThemeFontSchema = z.object({ - family: z.string(), - url: z.string().optional(), -}); - - - -/** Zod schema for ThemeBackground */ -export const ThemeBackgroundSchema = z.object({ - url: z.string(), - mode: z.enum(['cover', 'tile']).optional(), - dimensions: z.string().optional(), - mimeType: z.string().optional(), - blurhash: z.string().optional(), -}); - -/** Zod schema for the full ThemeConfig */ -export const ThemeConfigSchema = z.object({ - title: z.string().optional(), - colors: CoreThemeColorsSchema, - font: ThemeFontSchema.optional(), - background: ThemeBackgroundSchema.optional(), -}); - -/** Zod schema for ThemesConfig (light + dark theme configs) */ -export const ThemesConfigSchema = z.object({ - light: z.lazy(() => ThemeConfigSchema), - dark: z.lazy(() => ThemeConfigSchema), -}) satisfies z.ZodType; - /** * Compat schema that accepts either the new ThemeConfig format or the old * bare CoreThemeColors format (and all legacy color variants), normalizing @@ -106,43 +90,38 @@ export const ThemeConfigCompatSchema = z.union([ ThemeColorsCompatSchema.transform((colors): ThemeConfig => ({ colors })), ]); -/** Zod schema for ContentWarningPolicy validation */ -export const ContentWarningPolicySchema = z.enum(['blur', 'hide', 'show']) satisfies z.ZodType; +// ─── AppConfigSchema ───────────────────────────────────────────────── /** - * Zod schema for FeedSettings validation. - * All fields use .optional() so data with missing keys - * (from older encrypted settings) doesn't reject the whole object. - * Uses looseObject to preserve extra keys from newer encrypted settings. - * Missing fields get filled in by the defaultConfig merge downstream. + * 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. */ -export const FeedSettingsSchema = z.looseObject({ - feedIncludePosts: z.boolean().optional(), - feedIncludeReposts: z.boolean().optional(), - feedIncludeArticles: z.boolean().optional(), - showArticles: z.boolean().optional(), - showVines: z.boolean().optional(), - showPolls: z.boolean().optional(), - showTreasures: z.boolean().optional(), - showTreasureGeocaches: z.boolean().optional(), - showTreasureFoundLogs: z.boolean().optional(), - showColors: z.boolean().optional(), - showPacks: z.boolean().optional(), - showStreams: z.boolean().optional(), - feedIncludeVines: z.boolean().optional(), - feedIncludePolls: z.boolean().optional(), - feedIncludeTreasureGeocaches: z.boolean().optional(), - feedIncludeTreasureFoundLogs: z.boolean().optional(), - feedIncludeColors: z.boolean().optional(), - feedIncludePacks: z.boolean().optional(), - feedIncludeStreams: z.boolean().optional(), - showDecks: z.boolean().optional(), - feedIncludeDecks: z.boolean().optional(), - showProfileThemes: z.boolean().optional(), - feedIncludeProfileThemes: z.boolean().optional(), - showCustomProfileThemes: z.boolean().optional(), +export const AppConfigSchema = z.object({ + theme: ThemeSchemaCompat, + customTheme: ThemeConfigCompatSchema.optional(), + themes: ThemesConfigSchema.optional(), + relayMetadata: RelayMetadataSchema, + useAppRelays: z.boolean(), + feedSettings: FeedSettingsSchema, + sidebarOrder: z.array(z.string()), + nip85StatsPubkey: z.string().refine( + (val) => val.length === 0 || (val.length === 64 && /^[0-9a-f]{64}$/i.test(val)), + { message: 'Must be empty or a valid 64-character hex pubkey' } + ), + blossomServers: z.array(z.string().url()), + defaultZapComment: z.string(), + faviconUrl: z.string(), + linkPreviewUrl: z.string(), + corsProxy: z.string(), + contentWarningPolicy: ContentWarningPolicySchema, }); +// ─── Content Filter Schemas ────────────────────────────────────────── + /** Zod schema for FilterRule validation */ export const FilterRuleSchema = z.object({ type: z.enum(['kind', 'content-regex', 'tag', 'author-metadata']), @@ -162,6 +141,8 @@ export const ContentFilterSchema = z.object({ updatedAt: z.number(), }); +// ─── EncryptedSettings Schema ──────────────────────────────────────── + /** * Zod schema for EncryptedSettings validation. * All fields are optional since settings are incrementally synced. diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a8f9a6e7..5a942620 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,3 +3,9 @@ // Fontsource packages export CSS — they have no TS declarations declare module '@fontsource-variable/*'; declare module '@fontsource/comic-relief/*'; + +/** + * Build-time configuration injected by Vite from ditto.json. + * `null` when no config file was provided at build time. + */ +declare const __DITTO_CONFIG__: Partial | null; diff --git a/tsconfig.json b/tsconfig.json index a7596daf..f47f2461 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ "@/*": ["./src/*"] } }, - "include": ["src"] + "include": ["src", "config"] } diff --git a/vite.config.ts b/vite.config.ts index 50775182..25fceb8b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,34 @@ +import fs from "node:fs"; import path from "node:path"; import react from "@vitejs/plugin-react-swc"; import { defineConfig } from "vitest/config"; +import { DittoConfigSchema } from "./config/schema"; + +/** + * Load and validate the build-time ditto.json configuration file. + * Returns the parsed config object, or `undefined` if the file doesn't exist. + * Set the CONFIG_FILE env var to override the default path ("./ditto.json"). + */ +function loadDittoConfig(): object | undefined { + const configPath = path.resolve(process.env.CONFIG_FILE ?? "./ditto.json"); + + let raw: string; + try { + raw = fs.readFileSync(configPath, "utf-8"); + } catch { + // File not found — no build-time config + return undefined; + } + + const json = JSON.parse(raw); + const result = DittoConfigSchema.parse(json); + return result; +} + +const dittoConfig = loadDittoConfig(); + // https://vitejs.dev/config/ export default defineConfig(() => ({ server: { @@ -12,6 +38,9 @@ export default defineConfig(() => ({ plugins: [ react(), ], + define: { + __DITTO_CONFIG__: JSON.stringify(dittoConfig ?? null), + }, test: { globals: true, environment: 'jsdom',