{"version":3,"file":"settings-BZCtBXR1.mjs","names":[],"sources":["../src/settings/index.ts"],"sourcesContent":["/**\n * Site Settings API\n *\n * Functions for getting and setting global site configuration.\n * Settings are stored in the options table with 'site:' prefix.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { after } from \"../after.js\";\nimport { MediaRepository } from \"../database/repositories/media.js\";\nimport { OptionsRepository } from \"../database/repositories/options.js\";\nimport type { Database } from \"../database/types.js\";\nimport { getDb } from \"../loader.js\";\nimport { cachedQuery, invalidateObjectCache } from \"../object-cache/index.js\";\nimport { peekRequestCache, requestCached } from \"../request-cache.js\";\n\n/** Object-cache namespace for site settings. */\nconst SETTINGS_CACHE_NAMESPACE = \"settings\";\nimport type { Storage } from \"../storage/types.js\";\nimport {\n\tcreateSingleFlightCache,\n\ttype SingleFlightCache,\n\tinvalidateSingleFlightCache,\n\tsingleFlightCached,\n} from \"../utils/single-flight-cache.js\";\nimport type { SiteSettings, SiteSettingKey, MediaReference, SeoSettings } from \"./types.js\";\n\n/** Prefix for site settings in the options table */\nconst SETTINGS_PREFIX = \"site:\";\nconst TRAILING_SLASH_RE = /\\/$/;\n\n/**\n * Worker-isolate cache for the resolved `site:*` settings.\n *\n * Site settings (title, logo, SEO defaults) change rarely but are read on\n * every public request. Caching across the isolate's lifetime drops the\n * `options WHERE name LIKE 'site:%'` prefix scan from once-per-request to\n * once-per-isolate. Cross-isolate staleness is bounded by isolate lifetime\n * (workerd typically recycles within minutes); acceptable for chrome.\n *\n * Backed by single-flight-cache.ts: concurrent cold reads coalesce onto one\n * query via a reclaimable single-flight lock and the resolved *value* is\n * cached — never a shared in-flight promise, so a cancelled request can't\n * poison the isolate (see that file's header). Stored on globalThis with a\n * Symbol.for key so Vite SSR chunk duplication doesn't produce two\n * independent caches (same pattern as request-context.ts).\n */\nconst SITE_SETTINGS_CACHE_KEY = Symbol.for(\"emdash:site-settings\");\nconst g = globalThis as Record<symbol, unknown>;\nconst settingsCache: SingleFlightCache<Partial<SiteSettings>> =\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see request-context.ts)\n\t(g[SITE_SETTINGS_CACHE_KEY] as SingleFlightCache<Partial<SiteSettings>> | undefined) ??\n\t(() => {\n\t\tconst c = createSingleFlightCache<Partial<SiteSettings>>();\n\t\tg[SITE_SETTINGS_CACHE_KEY] = c;\n\t\treturn c;\n\t})();\n\n/**\n * Bump the isolate-wide site-settings cache version, forcing the next\n * `getSiteSettings()` to re-query the database.\n *\n * Called from every `site:*` write path. Other isolates still serve their\n * own cached copy until they expire — staleness bounded by isolate lifetime.\n */\nexport function invalidateSiteSettingsCache(): void {\n\tinvalidateSingleFlightCache(settingsCache);\n\t// Cross-isolate invalidation for the optional distributed object cache.\n\tinvalidateObjectCache(SETTINGS_CACHE_NAMESPACE);\n}\n\n/**\n * Type guard for MediaReference values\n */\nfunction isMediaReference(value: unknown): value is MediaReference {\n\treturn typeof value === \"object\" && value !== null && \"mediaId\" in value;\n}\n\n/**\n * Resolve a media reference to include the full URL plus content metadata.\n *\n * Pulls `mimeType` and intrinsic dimensions from the media row so callers\n * can emit correct head tags (e.g. `<link rel=\"icon\" type=\"image/svg+xml\">`,\n * which Chromium requires when the URL has no `.svg` extension) without\n * a second round-trip to the media table.\n */\nasync function resolveMediaReference(\n\tmediaRef: MediaReference | undefined,\n\tdb: Kysely<Database>,\n\t_storage: Storage | null,\n): Promise<MediaReference | undefined> {\n\tif (!mediaRef?.mediaId) {\n\t\treturn mediaRef;\n\t}\n\n\ttry {\n\t\tconst mediaRepo = new MediaRepository(db);\n\t\tconst media = await mediaRepo.findById(mediaRef.mediaId);\n\n\t\tif (media) {\n\t\t\t// Construct URL using the same pattern as API handlers\n\t\t\treturn {\n\t\t\t\t...mediaRef,\n\t\t\t\turl: `/_emdash/api/media/file/${media.storageKey}`,\n\t\t\t\tcontentType: media.mimeType,\n\t\t\t\t...(media.width !== null ? { width: media.width } : {}),\n\t\t\t\t...(media.height !== null ? { height: media.height } : {}),\n\t\t\t};\n\t\t}\n\t} catch {\n\t\t// If media not found or error, return the reference as-is\n\t}\n\n\treturn mediaRef;\n}\n\n/**\n * Get a single site setting by key\n *\n * Returns `undefined` if the setting has not been configured.\n * For media settings (logo, favicon), the URL is resolved automatically.\n *\n * @param key - The setting key (e.g., \"title\", \"logo\", \"social\")\n * @returns The setting value, or undefined if not set\n *\n * @example\n * ```ts\n * import { getSiteSetting } from \"@premium-cms/emdash\";\n *\n * const title = await getSiteSetting(\"title\");\n * const logo = await getSiteSetting(\"logo\");\n * console.log(logo?.url); // Resolved URL\n * ```\n */\nexport async function getSiteSetting<K extends SiteSettingKey>(\n\tkey: K,\n): Promise<SiteSettings[K] | undefined> {\n\t// If `getSiteSettings()` has already been called in this request,\n\t// read from that (request-cached) batch rather than firing a second\n\t// options-table query. Common layout: a Base template pulls the\n\t// whole settings object up-front, then `EmDashHead` or a plugin\n\t// asks for one key — no reason the singular call should round-trip\n\t// again.\n\tconst primed = peekRequestCache<Partial<SiteSettings>>(\"siteSettings\");\n\tif (primed) {\n\t\tconst settings = await primed;\n\t\treturn settings[key];\n\t}\n\n\t// Otherwise cache per-key. Templates that pull several settings\n\t// independently still share the in-flight query for each one.\n\treturn requestCached(`siteSetting:${key}`, async () => {\n\t\tconst db = await getDb();\n\t\treturn getSiteSettingWithDb(key, db);\n\t});\n}\n\n/**\n * Get a single site setting by key (with explicit db)\n *\n * @internal Use `getSiteSetting()` in templates. This variant is for admin routes\n * that already have a database handle.\n */\nexport async function getSiteSettingWithDb<K extends SiteSettingKey>(\n\tkey: K,\n\tdb: Kysely<Database>,\n\tstorage: Storage | null = null,\n): Promise<SiteSettings[K] | undefined> {\n\tconst options = new OptionsRepository(db);\n\tconst value = await options.get<SiteSettings[K]>(`${SETTINGS_PREFIX}${key}`);\n\n\tif (!value) {\n\t\treturn undefined;\n\t}\n\n\t// Resolve media references if needed.\n\t// TS cannot narrow generic K from key equality checks — this is a known limitation.\n\t// We use the non-generic getSiteSettingsWithDb for media resolution instead.\n\tif ((key === \"logo\" || key === \"favicon\") && isMediaReference(value)) {\n\t\tconst resolved = await resolveMediaReference(value, db, storage);\n\t\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- TS can't narrow generic K from key equality; resolved type is correct\n\t\treturn resolved as SiteSettings[K] | undefined;\n\t}\n\n\tif (key === \"seo\" && value && typeof value === \"object\") {\n\t\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- TS can't narrow generic K from key equality\n\t\tconst seo = value as SeoSettings;\n\t\tif (seo.defaultOgImage) {\n\t\t\tconst resolved = {\n\t\t\t\t...seo,\n\t\t\t\tdefaultOgImage: await resolveMediaReference(seo.defaultOgImage, db, storage),\n\t\t\t};\n\t\t\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- TS can't narrow generic K from key equality\n\t\t\treturn resolved as SiteSettings[K] | undefined;\n\t\t}\n\t}\n\n\treturn value;\n}\n\n/**\n * Get all site settings\n *\n * Returns all configured settings. Unset values are undefined.\n * Media references (logo/favicon) are resolved to include URLs.\n *\n * @example\n * ```ts\n * import { getSiteSettings } from \"@premium-cms/emdash\";\n *\n * const settings = await getSiteSettings();\n * console.log(settings.title); // \"My Site\"\n * console.log(settings.logo?.url); // \"/_emdash/api/media/file/abc123\"\n * ```\n */\nexport function getSiteSettings(): Promise<Partial<SiteSettings>> {\n\t// requestCached dedupes within a single request; singleFlightCached\n\t// coalesces across requests and caches the resolved value for the\n\t// global scope's lifetime without ever sharing an awaitable promise. The\n\t// distributed object cache (cachedQuery) sits beneath both, backing cold\n\t// isolates without a database round-trip.\n\treturn requestCached(\"siteSettings\", () =>\n\t\tsingleFlightCached(\n\t\t\tsettingsCache,\n\t\t\t() =>\n\t\t\t\tcachedQuery({\n\t\t\t\t\tnamespace: SETTINGS_CACHE_NAMESPACE,\n\t\t\t\t\tkey: \"all\",\n\t\t\t\t\tload: async () => {\n\t\t\t\t\t\tconst db = await getDb();\n\t\t\t\t\t\treturn getSiteSettingsWithDb(db);\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t{ anchor: (promise) => after(() => promise), ownerTimeoutMs: 30_000 },\n\t\t),\n\t);\n}\n\n/**\n * Get all site settings (with explicit db)\n *\n * @internal Use `getSiteSettings()` in templates. This variant is for admin routes\n * that already have a database handle.\n */\nexport async function getSiteSettingsWithDb(\n\tdb: Kysely<Database>,\n\tstorage: Storage | null = null,\n): Promise<Partial<SiteSettings>> {\n\tconst options = new OptionsRepository(db);\n\tconst allOptions = await options.getByPrefix(SETTINGS_PREFIX);\n\n\tconst settings: Record<string, unknown> = {};\n\n\t// Convert Map to settings object, removing the prefix\n\tfor (const [key, value] of allOptions) {\n\t\tconst settingKey = key.replace(SETTINGS_PREFIX, \"\");\n\t\tsettings[settingKey] = value;\n\t}\n\n\tconst typedSettings = settings as Partial<SiteSettings>;\n\n\t// Resolve media references\n\tif (typedSettings.logo) {\n\t\ttypedSettings.logo = await resolveMediaReference(typedSettings.logo, db, storage);\n\t}\n\tif (typedSettings.favicon) {\n\t\ttypedSettings.favicon = await resolveMediaReference(typedSettings.favicon, db, storage);\n\t}\n\tif (typedSettings.seo?.defaultOgImage) {\n\t\ttypedSettings.seo = {\n\t\t\t...typedSettings.seo,\n\t\t\tdefaultOgImage: await resolveMediaReference(typedSettings.seo.defaultOgImage, db, storage),\n\t\t};\n\t}\n\n\treturn typedSettings;\n}\n\n/**\n * Set site settings (internal function used by admin API)\n *\n * Merges provided settings with existing ones. Only provided fields are updated.\n * Media references should include just the mediaId; URLs are resolved on read.\n *\n * @param settings - Partial settings object with values to update\n * @param db - Kysely database instance\n * @returns Promise that resolves when settings are saved\n *\n * @internal\n *\n * @example\n * ```ts\n * // Update multiple settings at once\n * await setSiteSettings({\n *   title: \"My Site\",\n *   tagline: \"Welcome\",\n *   logo: { mediaId: \"med_123\", alt: \"Logo\" }\n * }, db);\n * ```\n */\nexport async function setSiteSettings(\n\tsettings: Partial<SiteSettings>,\n\tdb: Kysely<Database>,\n): Promise<void> {\n\tconst options = new OptionsRepository(db);\n\n\t// Convert settings to options format\n\tconst updates: Record<string, unknown> = {};\n\tfor (const [key, value] of Object.entries(settings)) {\n\t\tif (value !== undefined) {\n\t\t\tupdates[`${SETTINGS_PREFIX}${key}`] = value;\n\t\t}\n\t}\n\t// The runtime's own origin (outbound links, auth redirects, passkey rpId)\n\t// follows the canonical Site URL.\n\tif (typeof settings.url === \"string\" && settings.url.trim()) {\n\t\tupdates[\"emdash:site_url\"] = settings.url.trim().replace(TRAILING_SLASH_RE, \"\");\n\t}\n\n\ttry {\n\t\tawait options.setMany(updates);\n\t} finally {\n\t\tinvalidateSiteSettingsCache();\n\t}\n}\n\n/**\n * Get a single plugin setting by key.\n *\n * Plugin settings are stored in the options table under\n * `plugin:<pluginId>:settings:<key>`.\n */\nexport async function getPluginSetting<T = unknown>(\n\tpluginId: string,\n\tkey: string,\n): Promise<T | undefined> {\n\tconst db = await getDb();\n\treturn getPluginSettingWithDb<T>(pluginId, key, db);\n}\n\n/**\n * Get a single plugin setting by key (with explicit db).\n *\n * @internal Use `getPluginSetting()` in templates and plugin rendering code.\n */\nexport async function getPluginSettingWithDb<T = unknown>(\n\tpluginId: string,\n\tkey: string,\n\tdb: Kysely<Database>,\n): Promise<T | undefined> {\n\tconst options = new OptionsRepository(db);\n\tconst value = await options.get<T>(`plugin:${pluginId}:settings:${key}`);\n\treturn value ?? undefined;\n}\n\n/**\n * Get all persisted plugin settings for a plugin.\n *\n * Defaults declared in `admin.settingsSchema` are not materialized\n * automatically; callers should apply their own fallback defaults.\n */\nexport async function getPluginSettings(pluginId: string): Promise<Record<string, unknown>> {\n\tconst db = await getDb();\n\treturn getPluginSettingsWithDb(pluginId, db);\n}\n\n/**\n * Get all persisted plugin settings for a plugin (with explicit db).\n *\n * @internal Use `getPluginSettings()` in templates and plugin rendering code.\n */\nexport async function getPluginSettingsWithDb(\n\tpluginId: string,\n\tdb: Kysely<Database>,\n): Promise<Record<string, unknown>> {\n\tconst prefix = `plugin:${pluginId}:settings:`;\n\tconst options = new OptionsRepository(db);\n\tconst allOptions = await options.getByPrefix(prefix);\n\n\tconst settings: Record<string, unknown> = {};\n\tfor (const [key, value] of allOptions) {\n\t\tif (!key.startsWith(prefix)) {\n\t\t\tcontinue;\n\t\t}\n\t\tsettings[key.slice(prefix.length)] = value;\n\t}\n\n\treturn settings;\n}\n\n/**\n * The site's applied color scheme (tweakcn-adapted EmDash tokens), or null.\n * Read from the `emdash:color_scheme` option. Request-cached so `EmDashHead`\n * can inject it into every page without repeat queries. See\n * `api/handlers/color-schemes.ts` for how it is applied from the marketplace.\n */\nexport function getColorScheme(): Promise<{ id: string; title: string; declarations: string; adminCss: string } | null> {\n\treturn requestCached(\"colorScheme\", async () => {\n\t\tconst db = await getDb();\n\t\tconst scheme = await new OptionsRepository(db).get<{\n\t\t\tid?: unknown;\n\t\t\ttitle?: unknown;\n\t\t\tdeclarations?: unknown;\n\t\t\tadminCss?: unknown;\n\t\t}>(\"emdash:color_scheme\");\n\t\tif (!scheme || typeof scheme.declarations !== \"string\" || !scheme.declarations) return null;\n\t\treturn {\n\t\t\tid: String(scheme.id ?? \"\"),\n\t\t\ttitle: String(scheme.title ?? \"\"),\n\t\t\tdeclarations: scheme.declarations,\n\t\t\tadminCss: typeof scheme.adminCss === \"string\" ? scheme.adminCss : \"\",\n\t\t};\n\t});\n}\n"],"mappings":"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"}