import type { Static, TSchema } from "typebox"; import { checkSchema } from "./config/schema.ts"; import { ensureXSettingsRegistry, type ListDefinition, type SettingApply, type SettingCategory, type SettingDefinition, type SettingOption, type SettingOptions, type SettingPage, type SettingPreview, type SettingValue, } from "./protocol/settings.ts"; import { resolveSettingOptions } from "./runtime/options.ts"; export type { ListDefinition, ListItemField, SettingPage } from "./protocol/settings.ts"; interface DefinitionBase { scope?: "global" | "session"; label: string; description: string; category: SettingCategory; page?: SettingPage; section?: string; preview?: SettingPreview; apply?: SettingApply; } export interface ListSettingDefinitionInput extends DefinitionBase { type: "list"; default: Static & SettingValue[]; schema: Schema; list: ListDefinition; } export interface StringListSettingDefinitionInput extends DefinitionBase { type: "string-list"; default: readonly string[]; minItems: number; } export function stringListSetting( definition: Omit, ): StringListSettingDefinitionInput { return { ...definition, type: "string-list" }; } export function listSetting( schema: Schema, definition: Omit, "schema" | "type">, ): ListSettingDefinitionInput { return { ...definition, schema, type: "list" }; } export type SettingDefinitionInput = | (DefinitionBase & { type: "boolean"; default: boolean }) | (DefinitionBase & { type: "string"; default: string }) | StringListSettingDefinitionInput | (DefinitionBase & { type: "enum"; default: SettingOption["value"]; options: SettingOptions }) | (DefinitionBase & { type: "multi-enum"; default: readonly string[]; options: readonly SettingOption[]; ordered: boolean; }) | ListSettingDefinitionInput; type OptionValue = Definition extends { options: readonly (infer Option)[]; } ? Option extends { value: infer Value extends SettingOption["value"] } ? Value : never : string; type ValueOf = Definition extends { type: "boolean" } ? boolean : Definition extends { type: "string" } ? string : Definition extends { type: "string-list" } ? string[] : Definition extends { type: "enum" } ? OptionValue : Definition extends { type: "multi-enum" } ? Array, string>> : Definition extends { type: "list"; schema: infer Schema extends TSchema } ? Static : never; export type SettingsOf> = { -readonly [Key in keyof Definitions]: ValueOf; }; export interface SettingsClient> { readonly defaults: Readonly>; get(sessionId?: string): SettingsOf; register(onValues?: (settings: Readonly>) => void | Promise): () => void; } export interface CreateSettingsOptions> { namespace: string; label: string; apply?: SettingApply; definitions: Definitions; } export function createSettings>( options: CreateSettingsOptions, ): SettingsClient { const definitions = Object.entries(options.definitions).map(([key, definition]) => toProtocolDefinition(key, definition, options.apply), ); const defaults = resolveValues(options.definitions, Object.create(null) as Readonly>); let current = cloneSettings(defaults); return { defaults, get: (sessionId) => { const scoped = sessionId ? ensureXSettingsRegistry().sessionValues?.[sessionId]?.[options.namespace] : undefined; return scoped ? resolveValues(options.definitions, { ...current, ...scoped }) : cloneSettings(current); }, register(onValues) { const unregister = ensureXSettingsRegistry().register({ namespace: options.namespace, label: options.label, definitions, async onValues(values) { current = resolveValues(options.definitions, values); await onValues?.(cloneSettings(current)); }, }); return () => { unregister(); }; }, }; } function toProtocolDefinition( key: string, definition: SettingDefinitionInput, defaultApply?: SettingApply, ): SettingDefinition { const apply = definition.apply ?? defaultApply ?? "reload"; if (definition.type === "list") { if (!validValue(definition, definition.default)) { throw new Error(`Invalid default for list setting "${key}".`); } return { ...definition, key, apply, default: cloneValue(definition.default), }; } if (definition.type === "string-list") { const defaultValue = [...definition.default]; if (!validValue(definition, defaultValue)) { throw new Error(`Invalid default for string-list setting "${key}".`); } return { ...definition, key, apply, default: defaultValue }; } return { ...definition, key, apply, ...(definition.type === "multi-enum" ? { default: [...definition.default] } : {}), } as SettingDefinition; } function resolveValues>( definitions: Definitions, values: Readonly>, ): SettingsOf { const resolved: Record = Object.create(null) as Record; for (const [key, definition] of Object.entries(definitions)) { if (definition.type === "enum" && !Array.isArray(definition.options)) continue; resolved[key] = resolveValue(definition, values[key], resolved); } for (const [key, definition] of Object.entries(definitions)) { if (definition.type !== "enum" || Array.isArray(definition.options)) continue; resolved[key] = resolveValue(definition, values[key], resolved); } return resolved as SettingsOf; } function resolveValue( definition: SettingDefinitionInput, value: SettingValue | undefined, values: Readonly>, ): SettingValue { if (definition.type === "list") { return validValue(definition, value) ? cloneValue(value) : cloneValue(definition.default); } switch (definition.type) { case "boolean": return typeof value === "boolean" ? value : definition.default; case "string": return typeof value === "string" ? value : definition.default; case "string-list": return Array.isArray(value) && value.length >= definition.minItems && value.every((entry) => typeof entry === "string") ? ([...value] as string[]) : [...definition.default]; case "enum": { const options = resolveSettingOptions(definition.options, values); return (typeof value === "boolean" || typeof value === "string" || typeof value === "number") && options.some((option) => option.value === value) ? value : options.some((option) => option.value === definition.default) ? definition.default : (options[0]?.value ?? definition.default); } case "multi-enum": { const allowed = new Set( definition.options.flatMap((option) => (typeof option.value === "string" ? [option.value] : [])), ); return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value.filter((entry) => allowed.has(entry)) : [...definition.default]; } } } function validValue(definition: SettingDefinitionInput, value: SettingValue | undefined): value is SettingValue { if (value === undefined) return false; if (definition.type === "list") return Array.isArray(value) && checkSchema(definition.schema, value); if (definition.type === "boolean") return typeof value === "boolean"; if (definition.type === "string") return typeof value === "string"; if (definition.type === "string-list") { return ( Array.isArray(value) && Number.isInteger(definition.minItems) && definition.minItems >= 0 && value.length >= definition.minItems && value.every((entry) => typeof entry === "string") ); } if (definition.type === "multi-enum") { const allowed = new Set( definition.options.flatMap((option) => (typeof option.value === "string" ? [option.value] : [])), ); return Array.isArray(value) && value.every((entry) => typeof entry === "string" && allowed.has(entry)); } if (!Array.isArray(definition.options)) return typeof value === "boolean" || typeof value === "string" || typeof value === "number"; return ( (typeof value === "boolean" || typeof value === "string" || typeof value === "number") && definition.options.some((option) => option.value === value) ); } function cloneSettings>( settings: SettingsOf, ): SettingsOf { return Object.fromEntries( Object.entries(settings).map(([key, value]) => [key, cloneValue(value)]), ) as SettingsOf; } function cloneValue(value: Value): Value { if (Array.isArray(value)) return value.map((entry) => cloneValue(entry)) as Value; if (typeof value === "object") { return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)])) as Value; } return value; }