diff --git a/src/features/settings/composables/useSettingsConfig.js b/src/features/settings/composables/useSettingsConfig.js new file mode 100644 index 0000000..06d4bd8 --- /dev/null +++ b/src/features/settings/composables/useSettingsConfig.js @@ -0,0 +1,48 @@ +/* +Copyright 2026 Seekra + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ref, readonly } from 'vue'; +import { loadSettingsConfig } from '../utils/settingsParser.js'; + +const config = ref(null); +const error = ref(null); +const loading = ref(false); + +/** + * Provides reactive access to the parsed settings configuration. + * The config is loaded once and shared across all consumers. + */ +export function useSettingsConfig() { + async function load() { + loading.value = true; + error.value = null; + try { + config.value = await loadSettingsConfig(); + } catch (e) { + error.value = e.message; + config.value = null; + } finally { + loading.value = false; + } + } + + return { + config: readonly(config), + error: readonly(error), + loading: readonly(loading), + load, + }; +} \ No newline at end of file diff --git a/src/features/settings/settings.json b/src/features/settings/settings.json new file mode 100644 index 0000000..1e36b85 --- /dev/null +++ b/src/features/settings/settings.json @@ -0,0 +1,3 @@ +{ + "contents": [] +} \ No newline at end of file diff --git a/src/features/settings/types/settingsConfig.js b/src/features/settings/types/settingsConfig.js new file mode 100644 index 0000000..d2ee124 --- /dev/null +++ b/src/features/settings/types/settingsConfig.js @@ -0,0 +1,68 @@ +/* +Copyright 2026 Seekra + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * @typedef {'bool' | 'number' | 'string' | 'selection' | 'section'} SettingType + */ + +/** + * @typedef {Object} SelectionOption + * @property {string} name + * @property {string} i18n + */ + +/** + * @typedef {Object} BaseSettingConfig + * @property {SettingType} type + * @property {string} name + * @property {string} i18n + * @property {string} [description] + */ + +/** + * @typedef {BaseSettingConfig & { + * default: string | string[], + * allowMultiple: boolean, + * options: SelectionOption[] + * }} SelectionSettingConfig + */ + +/** + * @typedef {Object} SectionSettingConfig + * @property {'section'} type + * @property {string} name + * @property {string} [i18n] + * @property {string} [description] + * @property {SettingConfigEntry[]} content + */ + +/** + * @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry + */ + +/** + * @typedef {Object} SettingsConfig + * @property {SettingConfigEntry[]} contents + */ +/** + * @typedef {{ type: 'bool', name: string, i18n: string, description?: string, default: boolean }} BoolSettingConfig + * @typedef {{ type: 'number', name: string, i18n: string, description?: string, default: number }} NumberSettingConfig + * @typedef {{ type: 'string', name: string, i18n: string, description?: string, default: string }} StringSettingConfig + * @typedef {{ type: 'selection', name: string, i18n: string, description?: string, default: string | string[], allowMultiple?: boolean, options: SelectionOption[] }} SelectionSettingConfig + * @typedef {{ type: 'section', name: string, i18n?: string, description?: string, content: SettingConfigEntry[] }} SectionSettingConfig + * @typedef {BoolSettingConfig | NumberSettingConfig | StringSettingConfig | SelectionSettingConfig | SectionSettingConfig} SettingConfigEntry + * @typedef {{ contents: SettingConfigEntry[] }} SettingsConfig + */ \ No newline at end of file diff --git a/src/features/settings/utils/settingsParser.js b/src/features/settings/utils/settingsParser.js new file mode 100644 index 0000000..2b7e658 --- /dev/null +++ b/src/features/settings/utils/settingsParser.js @@ -0,0 +1,38 @@ +/* +Copyright 2026 Seekra + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { validateSettingsConfig } from './settingsValidator.js'; + +/** + * Loads and parses the settings configuration via dynamic import. + * @returns {Promise} + */ +export async function loadSettingsConfig() { + let raw; + + try { + raw = (await import('../settings.json')).default; + } catch (e) { + throw new Error(`[settings] Failed to load settings.json: ${e.message}`); + } + + const result = validateSettingsConfig(raw); + if (!result.valid) { + throw new Error(result.error); + } + + return result.config; +} \ No newline at end of file diff --git a/src/features/settings/utils/settingsValidator.js b/src/features/settings/utils/settingsValidator.js new file mode 100644 index 0000000..ec30713 --- /dev/null +++ b/src/features/settings/utils/settingsValidator.js @@ -0,0 +1,99 @@ +/* +Copyright 2026 Seekra + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const VALID_TYPES = ['bool', 'number', 'string', 'selection', 'section']; + +function assertString(value, path) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`[settings] "${path}" must be a non-empty string`); + } +} + +function assertType(value, path) { + if (!VALID_TYPES.includes(value)) { + throw new Error( + `[settings] "${path}" has invalid type "${value}". Must be one of: ${VALID_TYPES.join(', ')}` + ); + } +} + +function validateSelectionOptions(options, path) { + if (!Array.isArray(options) || options.length === 0) { + throw new Error(`[settings] "${path}.options" must be a non-empty array`); + } + options.forEach((opt, i) => { + assertString(opt.name, `${path}.options[${i}].name`); + assertString(opt.i18n, `${path}.options[${i}].i18n`); + }); +} + +function validateEntry(entry, path) { + assertType(entry.type, `${path}.type`); + + if (entry.type === 'section') { + assertString(entry.name, `${path}.name`); + if (!Array.isArray(entry.content)) { + throw new Error(`[settings] "${path}.content" must be an array`); + } + entry.content.forEach((child, i) => + validateEntry(child, `${path}.content[${i}]`) + ); + return; + } + + assertString(entry.name, `${path}.name`); + assertString(entry.i18n, `${path}.i18n`); + + if (entry.default !== undefined) { + if (entry.type === 'bool' && typeof entry.default !== 'boolean') { + throw new Error(`[settings] "${path}.default" must be a boolean`); + } + if (entry.type === 'number' && typeof entry.default !== 'number') { + throw new Error(`[settings] "${path}.default" must be a number`); + } + if (entry.type === 'string' && typeof entry.default !== 'string') { + throw new Error(`[settings] "${path}.default" must be a string`); + } + if (entry.type === 'selection') { + validateSelectionOptions(entry.options, path); + if (typeof entry.allowMultiple !== 'boolean') { + throw new Error(`[settings] "${path}.allowMultiple" must be a boolean`); + } + } + } +} + +/** + * Validates a raw settings config object. + * @param {unknown} raw + * @returns {{ valid: true, config: import('../types/settingsConfig').SettingsConfig } | { valid: false, error: string }} + */ +export function validateSettingsConfig(raw) { + try { + if (!raw || typeof raw !== 'object') { + throw new Error('[settings] Config must be an object'); + } + if (!Array.isArray(raw.contents)) { + throw new Error('[settings] "contents" must be an array'); + } + raw.contents.forEach((entry, i) => + validateEntry(entry, `contents[${i}]`) + ); + return { valid: true, config: raw }; + } catch (e) { + return { valid: false, error: e.message }; + } +} \ No newline at end of file