diff --git a/src/lib/HAXCMS.js b/src/lib/HAXCMS.js index 760bbf23..c7a3cebe 100644 --- a/src/lib/HAXCMS.js +++ b/src/lib/HAXCMS.js @@ -3229,6 +3229,15 @@ class HAXCMSClass { if (!this.config.security.loginRateLimit) { this.config.security.loginRateLimit = {}; } + // localization settings (parity with PHP HAXCMSLocalizationSettingsService): + // stored as a localization block on config.json so it is readable at boot + // before _config/settings/ is scanned. defaultLanguage is a BCP-47 tag. + if (!this.config.localization) { + this.config.localization = {}; + } + if (!this.config.localization.defaultLanguage) { + this.config.localization.defaultLanguage = 'en-US'; + } // load in core theme data let themeData = JSON.parse(fs.readFileSync(path.join(this.coreConfigPath, "themes.json"), {encoding:'utf8', flag:'r'}, 'utf8')); diff --git a/src/lib/SystemRoutesMap.js b/src/lib/SystemRoutesMap.js index 7ac94bd2..f4c31868 100644 --- a/src/lib/SystemRoutesMap.js +++ b/src/lib/SystemRoutesMap.js @@ -209,6 +209,25 @@ addRouteHandler( 'configuration/media', settingsRoutes.configurationMedia, ); + +addRouteHandler( + SystemRoutesMap, + 'get', + 'configuration/localization', + settingsRoutes.configurationLocalization, +); +addRouteHandler( + SystemRoutesMap, + 'post', + 'configuration/localization', + settingsRoutes.configurationLocalization, +); +addRouteHandler( + SystemRoutesMap, + 'patch', + 'configuration/localization', + settingsRoutes.configurationLocalization, +); addRouteHandler( SystemRoutesMap, 'post', @@ -315,6 +334,7 @@ const SystemV1AdminRoutes = [ 'schemas', 'configuration/api-keys', 'configuration/media', + 'configuration/localization', 'configuration/schema-files/operations', 'themes', 'blocks', diff --git a/src/lib/localizationSettings.js b/src/lib/localizationSettings.js new file mode 100644 index 00000000..fc145a12 --- /dev/null +++ b/src/lib/localizationSettings.js @@ -0,0 +1,152 @@ +const fs = require('fs-extra'); +const path = require('path'); + +const DEFAULT_LANGUAGE = 'en-US'; +const BCP47_LANGUAGE_REGEX = /^[a-zA-Z]{2,3}(-[a-zA-Z]{2,4})?$/; +const DEFAULT_LOCALIZATION_SETTINGS = { + defaultLanguage: DEFAULT_LANGUAGE, +}; + +function getLocalizationSettingsFilePath(haxcms) { + const configDirectory = ( + haxcms && + typeof haxcms.configDirectory === 'string' && + haxcms.configDirectory + ) ? haxcms.configDirectory : path.join(process.cwd(), '_config'); + return path.join(configDirectory, 'config.json'); +} + +function normalizeDefaultLanguage(value) { + if (value === null || typeof value === 'undefined' || value === '') { + return null; + } + if (typeof value !== 'string') { + value = String(value); + } + value = value.trim(); + if (value === '') { + return null; + } + if (!BCP47_LANGUAGE_REGEX.test(value)) { + return null; + } + const parts = value.split('-'); + const primary = parts[0].toLowerCase(); + if (parts.length > 1 && parts[1] !== '') { + const region = parts[1].toUpperCase(); + return primary + '-' + region; + } + return primary; +} + +function normalizeLocalizationSettings(input = {}) { + const source = ( + input && + typeof input === 'object' && + !Array.isArray(input) + ) ? input : {}; + return { + defaultLanguage: normalizeDefaultLanguage(source.defaultLanguage), + }; +} + +function getEffectiveLocalizationSettings(settings = {}) { + const source = ( + settings && + typeof settings === 'object' && + !Array.isArray(settings) + ) ? settings : {}; + return { + defaultLanguage: source.defaultLanguage == null ? DEFAULT_LANGUAGE : source.defaultLanguage, + }; +} + +function hasSupportedLocalizationSettingsPayload(input = {}) { + const source = ( + input && + typeof input === 'object' && + !Array.isArray(input) + ) ? input : {}; + return Object.prototype.hasOwnProperty.call(source, 'defaultLanguage'); +} + +function isValidDefaultLanguagePayloadValue(value) { + if (value === null || typeof value === 'undefined' || value === '') { + return true; + } + return normalizeDefaultLanguage(value) !== null; +} + +async function readLocalizationSettings(haxcms) { + const filePath = getLocalizationSettingsFilePath(haxcms); + let localizationBlock = {}; + if (await fs.pathExists(filePath)) { + try { + const fullConfig = JSON.parse(await fs.readFile(filePath, 'utf8')); + if ( + fullConfig && + typeof fullConfig === 'object' && + !Array.isArray(fullConfig) && + fullConfig.localization + ) { + localizationBlock = fullConfig.localization; + } + } + catch (e) { + localizationBlock = {}; + } + } + return normalizeLocalizationSettings(localizationBlock); +} + +async function writeLocalizationSettings(haxcms, settings = {}) { + const filePath = getLocalizationSettingsFilePath(haxcms); + const source = ( + settings && + typeof settings === 'object' && + !Array.isArray(settings) + ) ? settings : {}; + let fullConfig = {}; + if (await fs.pathExists(filePath)) { + try { + fullConfig = JSON.parse(await fs.readFile(filePath, 'utf8')); + } + catch (e) { + fullConfig = {}; + } + } + if (!fullConfig || typeof fullConfig !== 'object' || Array.isArray(fullConfig)) { + fullConfig = {}; + } + const existingLocalization = ( + fullConfig.localization && + typeof fullConfig.localization === 'object' && + !Array.isArray(fullConfig.localization) + ) ? fullConfig.localization : {}; + const nextSettings = normalizeLocalizationSettings(existingLocalization); + if (Object.prototype.hasOwnProperty.call(source, 'defaultLanguage')) { + nextSettings.defaultLanguage = normalizeDefaultLanguage(source.defaultLanguage); + } + fullConfig.localization = nextSettings; + await fs.writeFile( + filePath, + `${JSON.stringify(fullConfig, null, 2)}\n`, + 'utf8', + ); + if (haxcms && haxcms.config && typeof haxcms.config === 'object') { + haxcms.config.localization = nextSettings; + } + return nextSettings; +} + +module.exports = { + getLocalizationSettingsFilePath, + normalizeDefaultLanguage, + normalizeLocalizationSettings, + getEffectiveLocalizationSettings, + hasSupportedLocalizationSettingsPayload, + isValidDefaultLanguagePayloadValue, + DEFAULT_LOCALIZATION_SETTINGS, + readLocalizationSettings, + writeLocalizationSettings, +}; diff --git a/src/openapi/system-spec.yaml b/src/openapi/system-spec.yaml index 802a20ef..0357c97e 100644 --- a/src/openapi/system-spec.yaml +++ b/src/openapi/system-spec.yaml @@ -981,6 +981,87 @@ paths: additionalProperties: true "403": $ref: "#/components/responses/Forbidden" + /system/api/v1/configuration/localization: + get: + tags: + - settings + operationId: getLocalizationSettings + summary: Return localization configuration + security: + - bearerAuth: [] + userTokenHeader: [] + responses: + "200": + description: Localization settings + content: + application/json: + schema: + type: object + properties: + status: + type: integer + data: + $ref: "#/components/schemas/LocalizationSettingsData" + additionalProperties: true + "403": + $ref: "#/components/responses/Forbidden" + post: + tags: + - settings + operationId: getLocalizationSettingsPost + summary: Return localization configuration (read alias of GET) + description: > + Read-only alias of GET /configuration/localization. POST is accepted for + callers that prefer POST over GET but does not write; use PATCH to + update localization settings. Single-user deployment assumption: the + NodeJS backend does not model an admin/superUser tier; these settings + are written by the single authenticated dashboard user. + security: + - bearerAuth: [] + userTokenHeader: [] + responses: + "200": + description: Localization settings + content: + application/json: + schema: + $ref: "#/components/schemas/ApiEnvelope" + "403": + $ref: "#/components/responses/Forbidden" + patch: + tags: + - settings + operationId: saveLocalizationSettingsPatch + summary: Update localization configuration + description: > + Write operation — updates localization configuration (defaultLanguage, + a BCP-47 tag). Single-user deployment assumption: the NodeJS backend + does not model an admin/superUser tier; settings are written by the + single authenticated dashboard user. + security: + - bearerAuth: [] + userTokenHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LocalizationSettings" + responses: + "200": + description: Localization settings update response + content: + application/json: + schema: + type: object + properties: + status: + type: integer + data: + $ref: "#/components/schemas/LocalizationSettingsData" + additionalProperties: true + "403": + $ref: "#/components/responses/Forbidden" /system/api/v1/configuration/schema-files/operations: post: tags: @@ -3004,6 +3085,23 @@ components: acceptedFormats: type: string additionalProperties: true + LocalizationSettings: + type: object + description: Localization settings payload (defaultLanguage BCP-47 tag) + properties: + defaultLanguage: + type: string + description: BCP-47 language tag (e.g. en-US, es-ES, fr-FR) + additionalProperties: true + LocalizationSettingsData: + type: object + description: > + Localization settings returned by getLocalizationSettings and + saveLocalizationSettingsPatch. The front-end reads defaultLanguage. + properties: + defaultLanguage: + type: string + additionalProperties: true SchemaFileOperationData: type: object description: > diff --git a/src/systemRoutes/v1/routes/createSite.js b/src/systemRoutes/v1/routes/createSite.js index 65df9a1d..bc9c4e1c 100644 --- a/src/systemRoutes/v1/routes/createSite.js +++ b/src/systemRoutes/v1/routes/createSite.js @@ -832,7 +832,8 @@ async function createSite(req, res) { schema.metadata.site.settings = {}; } if (!schema.metadata.site.settings.lang) { - schema.metadata.site.settings.lang = 'en-US'; + var systemDefaultLang = (HAXCMS.config.localization && HAXCMS.config.localization.defaultLanguage) || 'en-US'; + schema.metadata.site.settings.lang = systemDefaultLang; } if (typeof schema.metadata.site.settings.publishPagesOn === 'undefined') { schema.metadata.site.settings.publishPagesOn = true; diff --git a/src/systemRoutes/v1/routes/getLocalizationSettings.js b/src/systemRoutes/v1/routes/getLocalizationSettings.js new file mode 100644 index 00000000..622cfb7b --- /dev/null +++ b/src/systemRoutes/v1/routes/getLocalizationSettings.js @@ -0,0 +1,35 @@ +const { HAXCMS } = require('../../../lib/HAXCMS.js'); +const { + readLocalizationSettings, + getEffectiveLocalizationSettings, +} = require('../../../lib/localizationSettings.js'); + +/** + * @OA\Post( + * path="/getLocalizationSettings", + * tags={"cms","authenticated","settings"}, + * @OA\Response( + * response="200", + * description="Load saved localization settings" + * ) + * ) + */ +async function getLocalizationSettings(req, res) { + try { + const localizationSettings = await readLocalizationSettings(HAXCMS); + return res.json({ + status: 200, + data: getEffectiveLocalizationSettings(localizationSettings), + }); + } + catch (e) { + return res.status(500).json({ + status: 500, + data: { + message: 'Unable to load localization settings', + }, + }); + } +} + +module.exports = getLocalizationSettings; diff --git a/src/systemRoutes/v1/routes/saveLocalizationSettings.js b/src/systemRoutes/v1/routes/saveLocalizationSettings.js new file mode 100644 index 00000000..9eb679e4 --- /dev/null +++ b/src/systemRoutes/v1/routes/saveLocalizationSettings.js @@ -0,0 +1,91 @@ +const { HAXCMS } = require('../../../lib/HAXCMS.js'); +const { + hasSupportedLocalizationSettingsPayload, + isValidDefaultLanguagePayloadValue, + writeLocalizationSettings, + getEffectiveLocalizationSettings, +} = require('../../../lib/localizationSettings.js'); + +function getUserTokenFromHeader(req) { + if (!req || !req.headers || typeof req.headers !== 'object') { + return ''; + } + const rawValue = req.headers['x-haxcms-user-token']; + if (Array.isArray(rawValue)) { + return rawValue.length > 0 ? String(rawValue[0] || '').trim() : ''; + } + if (typeof rawValue === 'string') { + return rawValue.trim(); + } + return ''; +} + +/** + * @OA\Post( + * path="/saveLocalizationSettings", + * tags={"cms","authenticated","settings"}, + * @OA\Response( + * response="200", + * description="Persist localization settings" + * ) + * ) + */ +async function saveLocalizationSettings(req, res) { + const userToken = getUserTokenFromHeader(req); + if ( + !userToken || + !HAXCMS.validateRequestToken(userToken, HAXCMS.getActiveUserName()) + ) { + return res.status(403).json({ + status: 403, + data: { + message: 'invalid request token', + }, + }); + } + const payload = ( + req.body && + req.body.localizationSettings && + typeof req.body.localizationSettings === 'object' && + !Array.isArray(req.body.localizationSettings) + ) ? req.body.localizationSettings : req.body; + if (!hasSupportedLocalizationSettingsPayload(payload)) { + return res.status(400).json({ + status: 400, + data: { + message: 'Missing localization settings payload', + }, + }); + } + if ( + Object.prototype.hasOwnProperty.call(payload, 'defaultLanguage') && + payload.defaultLanguage !== null && + typeof payload.defaultLanguage !== 'undefined' && + payload.defaultLanguage !== '' && + !isValidDefaultLanguagePayloadValue(payload.defaultLanguage) + ) { + return res.status(400).json({ + status: 400, + data: { + message: 'Invalid defaultLanguage value', + }, + }); + } + try { + const localizationSettings = await writeLocalizationSettings(HAXCMS, payload); + return res.json({ + status: 200, + data: localizationSettings, + }); + } + catch (e) { + return res.status(500).json({ + status: 500, + data: { + message: 'Unable to save localization settings', + }, + }); + } +} + +module.exports = saveLocalizationSettings; diff --git a/src/systemRoutes/v1/settings.js b/src/systemRoutes/v1/settings.js index c800c64d..3b03f7ce 100644 --- a/src/systemRoutes/v1/settings.js +++ b/src/systemRoutes/v1/settings.js @@ -4,6 +4,8 @@ const getApiKeysRoute = require('./routes/getApiKeys.js'); const saveApiKeysRoute = require('./routes/saveApiKeys.js'); const getMediaSettingsRoute = require('./routes/getMediaSettings.js'); const saveMediaSettingsRoute = require('./routes/saveMediaSettings.js'); +const getLocalizationSettingsRoute = require('./routes/getLocalizationSettings.js'); +const saveLocalizationSettingsRoute = require('./routes/saveLocalizationSettings.js'); const saveEnabledSkeletonsRoute = require('./routes/saveEnabledSkeletons.js'); const schemaFileOperationRoute = require('./routes/schemaFileOperation.js'); const saveEnabledThemesRoute = require('./routes/saveEnabledThemes.js'); @@ -60,6 +62,21 @@ async function saveMediaSettings(req, res, next) { return saveMediaSettingsRoute(req, res, next); } +async function getLocalizationSettings(req, res, next) { + return getLocalizationSettingsRoute(req, res, next); +} +async function configurationLocalization(req, res, next) { + const method = String(req.method || '').toUpperCase(); + if (method === 'PATCH') { + return saveLocalizationSettingsRoute(req, res, next); + } + return getLocalizationSettingsRoute(req, res, next); +} + +async function saveLocalizationSettings(req, res, next) { + return saveLocalizationSettingsRoute(req, res, next); +} + async function saveEnabledSkeletons(req, res, next) { return saveEnabledSkeletonsRoute(req, res, next); } @@ -174,6 +191,9 @@ module.exports = { getMediaSettings, configurationMedia, saveMediaSettings, + getLocalizationSettings, + configurationLocalization, + saveLocalizationSettings, saveEnabledSkeletons, schemaFileOperation, saveEnabledThemes, diff --git a/test/api-conformance/site-spec.conformance.test.cjs b/test/api-conformance/site-spec.conformance.test.cjs index d3fcc251..f650a388 100644 --- a/test/api-conformance/site-spec.conformance.test.cjs +++ b/test/api-conformance/site-spec.conformance.test.cjs @@ -2294,6 +2294,7 @@ test('system API route groups match normalized v1 path structure', async () => { systemStatusGet: '/system/api/v1/status', getApiKeys: '/system/api/v1/configuration/api-keys', getMediaSettings: '/system/api/v1/configuration/media', + getLocalizationSettings: '/system/api/v1/configuration/localization', schemaFileOperation: '/system/api/v1/configuration/schema-files/operations', systemThemesGet: '/system/api/v1/themes', saveEnabledThemesPatch: '/system/api/v1/themes', diff --git a/test/e2e/admin-system.e2e.test.cjs b/test/e2e/admin-system.e2e.test.cjs index aa73f551..49c3414f 100644 --- a/test/e2e/admin-system.e2e.test.cjs +++ b/test/e2e/admin-system.e2e.test.cjs @@ -504,6 +504,100 @@ test( ) }) + // 5b. Localization settings: GET + PATCH (save). + await t.test( + 'API: GET configuration/localization returns settings object', + async () => { + const resp = await systemApiGet('configuration/localization') + assert.strictEqual( + resp.status, + 200, + 'getLocalizationSettings GET returned 200', + ) + let body = null + try { + body = JSON.parse(String(resp.data || '')) + } catch (e) { + body = null + } + assert.ok( + body && body.status === 200, + 'getLocalizationSettings body status 200', + ) + const data = body && body.data + assert.ok( + data && typeof data === 'object', + 'localization settings data is an object', + ) + assert.ok( + typeof data.defaultLanguage === 'string' && + data.defaultLanguage.length > 0, + 'defaultLanguage is a non-empty string', + ) + t.diagnostic( + '[e2e] localization settings defaultLanguage: ' + + data.defaultLanguage, + ) + }, + ) + + await t.test( + 'API: PATCH configuration/localization persists a setting', + async () => { + // Save a valid BCP-47 tag, verify via GET, then restore. + const testLang = 'es-ES' + const patchResp = await systemApiPatch( + 'configuration/localization', + { localizationSettings: { defaultLanguage: testLang } }, + ) + assert.strictEqual( + patchResp.status, + 200, + 'saveLocalizationSettings PATCH returned 200', + ) + let patchBody = null + try { + patchBody = JSON.parse(String(patchResp.data || '')) + } catch (e) { + patchBody = null + } + assert.ok( + patchBody && patchBody.status === 200, + 'saveLocalizationSettings body status 200', + ) + const savedData = patchBody && patchBody.data + assert.ok( + savedData && typeof savedData === 'object', + 'saveLocalizationSettings data is an object', + ) + // Verify via a fresh GET. + const getResp = await systemApiGet('configuration/localization') + let getBody = null + try { + getBody = JSON.parse(String(getResp.data || '')) + } catch (e) { + getBody = null + } + const getData = getBody && getBody.data + assert.ok( + getData && + typeof getData.defaultLanguage === 'string' && + getData.defaultLanguage === testLang, + 'saved defaultLanguage is present in subsequent GET', + ) + // Restore to en-US. + await systemApiPatch( + 'configuration/localization', + { localizationSettings: { defaultLanguage: 'en-US' } }, + ) + t.diagnostic( + '[e2e] localization settings: saved defaultLanguage=' + + testLang + + ' + verified + restored', + ) + }, + ) + // 6. System status + version. await t.test('API: GET status returns a sane system status report', async () => { const resp = await systemApiGet('status') diff --git a/test/unit/settings.test.cjs b/test/unit/settings.test.cjs index f28a2e5f..b742a0c9 100644 --- a/test/unit/settings.test.cjs +++ b/test/unit/settings.test.cjs @@ -9,6 +9,7 @@ const path = require('path') const media = require('../../src/lib/mediaSettings.js') const theme = require('../../src/lib/themeSettings.js') const skeleton = require('../../src/lib/skeletonSettings.js') +const localization = require('../../src/lib/localizationSettings.js') // Faithful mirror of HAXCMS.generateMachineName so the settings helpers see the // real machine-name convention without pulling the entire HAXCMS class into the @@ -351,6 +352,247 @@ describe('mediaSettings read/write round-trip', () => { }) }) +// --------------------------------------------------------------------------- +// localizationSettings +// --------------------------------------------------------------------------- +describe('localizationSettings.normalizeDefaultLanguage', () => { + test('returns null for null, undefined, and empty string', () => { + assert.equal(localization.normalizeDefaultLanguage(null), null) + assert.equal(localization.normalizeDefaultLanguage(undefined), null) + assert.equal(localization.normalizeDefaultLanguage(''), null) + }) + + test('returns null for values that do not match the BCP-47 shape', () => { + assert.equal(localization.normalizeDefaultLanguage('e'), null) + assert.equal(localization.normalizeDefaultLanguage('english'), null) + assert.equal(localization.normalizeDefaultLanguage('en-US-extra'), null) + assert.equal(localization.normalizeDefaultLanguage('en_Us'), null) + assert.equal(localization.normalizeDefaultLanguage('123'), null) + }) + + test('normalizes primary to lowercase and region to uppercase', () => { + assert.equal(localization.normalizeDefaultLanguage('EN-US'), 'en-US') + assert.equal(localization.normalizeDefaultLanguage('en-us'), 'en-US') + assert.equal(localization.normalizeDefaultLanguage('Es-Es'), 'es-ES') + assert.equal(localization.normalizeDefaultLanguage('FR-fr'), 'fr-FR') + }) + + test('passes through a lone primary subtag in lowercase', () => { + assert.equal(localization.normalizeDefaultLanguage('en'), 'en') + assert.equal(localization.normalizeDefaultLanguage('EN'), 'en') + assert.equal(localization.normalizeDefaultLanguage('fil'), 'fil') + }) + + test('accepts a 4-character script subtag', () => { + assert.equal(localization.normalizeDefaultLanguage('zh-Hant'), 'zh-HANT') + }) + + test('coerces non-string values to string before validating', () => { + assert.equal(localization.normalizeDefaultLanguage(42), null) + }) +}) + +describe('localizationSettings.normalizeLocalizationSettings', () => { + test('returns a null defaultLanguage for an empty object', () => { + assert.deepEqual(localization.normalizeLocalizationSettings({}), { + defaultLanguage: null, + }) + }) + + test('returns a null defaultLanguage for non-object input', () => { + assert.deepEqual(localization.normalizeLocalizationSettings(null), { + defaultLanguage: null, + }) + assert.deepEqual(localization.normalizeLocalizationSettings('nope'), { + defaultLanguage: null, + }) + }) + + test('normalizes the defaultLanguage field', () => { + assert.deepEqual( + localization.normalizeLocalizationSettings({ defaultLanguage: 'ES-es' }), + { defaultLanguage: 'es-ES' }, + ) + }) +}) + +describe('localizationSettings.getEffectiveLocalizationSettings', () => { + test('fills the en-US default when defaultLanguage is null', () => { + assert.deepEqual(localization.getEffectiveLocalizationSettings({}), { + defaultLanguage: 'en-US', + }) + }) + + test('preserves a provided non-null defaultLanguage', () => { + assert.deepEqual( + localization.getEffectiveLocalizationSettings({ defaultLanguage: 'es-ES' }), + { defaultLanguage: 'es-ES' }, + ) + }) + + test('DEFAULT_LOCALIZATION_SETTINGS matches the documented default', () => { + assert.deepEqual(localization.DEFAULT_LOCALIZATION_SETTINGS, { + defaultLanguage: 'en-US', + }) + }) +}) + +describe('localizationSettings.hasSupportedLocalizationSettingsPayload', () => { + test('is true when defaultLanguage key is present', () => { + assert.equal( + localization.hasSupportedLocalizationSettingsPayload({ defaultLanguage: 'en-US' }), + true, + ) + }) + + test('is false when defaultLanguage key is absent', () => { + assert.equal( + localization.hasSupportedLocalizationSettingsPayload({}), + false, + ) + assert.equal( + localization.hasSupportedLocalizationSettingsPayload({ unrelated: true }), + false, + ) + }) +}) + +describe('localizationSettings.isValidDefaultLanguagePayloadValue', () => { + test('is true for null, undefined, and empty string (clearable)', () => { + assert.equal(localization.isValidDefaultLanguagePayloadValue(null), true) + assert.equal(localization.isValidDefaultLanguagePayloadValue(undefined), true) + assert.equal(localization.isValidDefaultLanguagePayloadValue(''), true) + }) + + test('is true for a valid BCP-47 tag', () => { + assert.equal(localization.isValidDefaultLanguagePayloadValue('en-US'), true) + assert.equal(localization.isValidDefaultLanguagePayloadValue('fr'), true) + }) + + test('is false for an invalid tag', () => { + assert.equal(localization.isValidDefaultLanguagePayloadValue('english'), false) + assert.equal(localization.isValidDefaultLanguagePayloadValue('en-US-1'), false) + }) +}) + +describe('localizationSettings.getLocalizationSettingsFilePath', () => { + test('resolves under /config.json', () => { + const haxcms = makeHaxcms({ configDirectory: '/tmp/fake-cfg' }) + assert.equal( + localization.getLocalizationSettingsFilePath(haxcms), + path.join('/tmp/fake-cfg', 'config.json'), + ) + }) + + test('falls back to /_config when configDirectory is missing', () => { + assert.equal( + localization.getLocalizationSettingsFilePath({}), + path.join(process.cwd(), '_config', 'config.json'), + ) + }) +}) + +describe('localizationSettings read/write round-trip', () => { + test('write then read returns the same normalized value', async () => { + const dir = tmpConfigDir('l10n-rt-') + const haxcms = makeHaxcms({ configDirectory: dir }) + try { + const written = await localization.writeLocalizationSettings(haxcms, { + defaultLanguage: 'es-ES', + }) + assert.deepEqual(written, { defaultLanguage: 'es-ES' }) + const read = await localization.readLocalizationSettings(haxcms) + assert.deepEqual(read, { defaultLanguage: 'es-ES' }) + const filePath = localization.getLocalizationSettingsFilePath(haxcms) + const raw = fs.readFileSync(filePath, 'utf8') + assert.ok(raw.indexOf('"defaultLanguage": "es-ES"') !== -1) + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + test('write normalizes the case before persisting', async () => { + const dir = tmpConfigDir('l10n-case-') + const haxcms = makeHaxcms({ configDirectory: dir }) + try { + await localization.writeLocalizationSettings(haxcms, { + defaultLanguage: 'FR-fr', + }) + const read = await localization.readLocalizationSettings(haxcms) + assert.equal(read.defaultLanguage, 'fr-FR') + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + test('write preserves other keys in config.json', async () => { + const dir = tmpConfigDir('l10n-preserve-') + const haxcms = makeHaxcms({ configDirectory: dir }) + try { + const filePath = localization.getLocalizationSettingsFilePath(haxcms) + fs.writeFileSync( + filePath, + JSON.stringify({ themes: { clean: true }, security: { allowedHosts: [] } }), + ) + await localization.writeLocalizationSettings(haxcms, { + defaultLanguage: 'de-DE', + }) + const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')) + assert.deepEqual(raw.themes, { clean: true }) + assert.deepEqual(raw.security, { allowedHosts: [] }) + assert.equal(raw.localization.defaultLanguage, 'de-DE') + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + test('read returns a null defaultLanguage when no localization block exists', async () => { + const dir = tmpConfigDir('l10n-missing-') + const haxcms = makeHaxcms({ configDirectory: dir }) + try { + const filePath = localization.getLocalizationSettingsFilePath(haxcms) + fs.writeFileSync(filePath, JSON.stringify({ themes: {} })) + const read = await localization.readLocalizationSettings(haxcms) + assert.deepEqual(read, { defaultLanguage: null }) + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + test('read returns a null defaultLanguage when config.json does not exist', async () => { + const dir = tmpConfigDir('l10n-no-file-') + const haxcms = makeHaxcms({ configDirectory: dir }) + try { + const read = await localization.readLocalizationSettings(haxcms) + assert.deepEqual(read, { defaultLanguage: null }) + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + test('write updates the in-memory haxcms.config.localization', async () => { + const dir = tmpConfigDir('l10n-mem-') + const haxcms = makeHaxcms({ + configDirectory: dir, + config: { themes: {} }, + }) + try { + await localization.writeLocalizationSettings(haxcms, { + defaultLanguage: 'ja-JP', + }) + assert.deepEqual(haxcms.config.localization, { defaultLanguage: 'ja-JP' }) + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) +}) + // --------------------------------------------------------------------------- // themeSettings // ---------------------------------------------------------------------------