diff --git a/locale/expectedTranslations.js b/locale/expectedTranslations.js deleted file mode 100644 index e3fa00ec5..000000000 --- a/locale/expectedTranslations.js +++ /dev/null @@ -1,165 +0,0 @@ -export default [ - 'contact.language', - 'confirm.save', - 'confirm.dismiss', - 'calendar.events.mspec_lesbian_day', - 'calendar.events.mspec_lesbian_week', - 'calendar.events.mspec_gay_day', - 'calendar.events.mspec_gay_week', - 'calendar.events.masc_lesbian_day', - 'calendar.events.masc_lesbian_week', - 'user.login.domainPlaceholder', - 'user.login.deprecated', - 'user.login.depreciationNotice', - 'profile.flagsAsterisk', - 'profile.verifiedLinks.header', - 'profile.verifiedLinks.info', - 'sources.submit.spoiler', - 'translationMode.header', - 'translationMode.action', - 'translationMode.welcome', - 'translationMode.logIn', - 'translationMode.changes', - 'translationMode.commit', - 'translationMode.revert', - 'translationMode.pause', - 'contact.contribute.header', - 'contact.contribute.intro', - 'contact.contribute.entries.header', - 'contact.contribute.entries.description', - 'contact.contribute.translations.header', - 'contact.contribute.translations.description', - 'contact.contribute.version.header', - 'contact.contribute.version.description', - 'contact.contribute.technical.header', - 'contact.contribute.technical.description', - 'contact.contribute.technical.footer', - 'footer.stats.header', - 'footer.stats.overall', - 'footer.stats.current', - 'footer.stats.keys.users', - 'footer.stats.keys.cards', - 'footer.stats.keys.visitors', - 'footer.stats.keys.pageviews', - 'footer.stats.keys.realTimeVisitors', - 'footer.stats.keys.visitDuration', - 'footer.stats.keys.uptime', - 'footer.stats.keys.responseTime', - 'footer.using', - 'footer.version', - 'privacy.header', - 'user.avatar.failed', - 'user.qr.header', - 'user.qr.download', - 'footer.stats.month', - 'profile.wordsColumnHeader', - 'profile.opinions.header', - 'profile.opinions.description', - 'profile.opinions.colours._', - 'profile.opinions.colours.pink', - 'profile.opinions.colours.red', - 'profile.opinions.colours.orange', - 'profile.opinions.colours.green', - 'profile.opinions.colours.blue', - 'profile.opinions.colours.grey', - 'profile.opinions.styles._', - 'profile.opinions.styles.bold', - 'profile.opinions.styles.italics', - 'profile.opinions.styles.small', - 'profile.opinions.validation.missingIcon', - 'profile.opinions.validation.missingDescription', - 'profile.opinions.validation.duplicateIcon', - 'profile.opinions.validation.duplicateDescription', - 'profile.opinions.validation.invalidOpinion', - 'profile.opinions.custom', - 'mode.reducedColours', - 'crud.loadAll', - 'crud.validation.genericForm', - 'crud.validation.listMaxLength', - 'profile.circles.header', - 'profile.circles.info', - 'profile.circles.relationship', - 'profile.circles.mutual', - 'profile.circles.yourMentions.header', - 'profile.circles.yourMentions.description', - 'profile.circles.validation.userNotFound', - 'profile.circles.validation.required', - 'profile.timezone.header', - 'profile.timezone.placeholder', - 'profile.timezone.info', - 'profile.timezone.detect', - 'profile.timezone.publishArea', - 'profile.timezone.publishLocation', - 'profile.timezone.time', - 'profile.timezone.approximate', - 'profile.timezone.areas.Africa', - 'profile.timezone.areas.America', - 'profile.timezone.areas.Antarctica', - 'profile.timezone.areas.Arctic', - 'profile.timezone.areas.Asia', - 'profile.timezone.areas.Atlantic', - 'profile.timezone.areas.Australia', - 'profile.timezone.areas.Europe', - 'profile.timezone.areas.Indian', - 'profile.timezone.areas.Pacific', - 'profile.sensitive.header', - 'profile.sensitive.info', - 'profile.sensitive.display', - 'profile.sensitive.hide', - 'profile.sensitive.email.subject', - 'profile.sensitive.email.content', - 'crud.validation.invalidLink', - 'profile.flagsCustomForm.label', - 'profile.flagsCustomForm.description', - 'profile.flagsCustomForm.link', - 'profile.flagsCustomForm.alt', - 'profile.flagsCustomForm.altExample', - 'crud.alt', - 'profile.circles.removeSelf.action', - 'profile.circles.removeSelf.confirm', - 'calendar.events.aplatonic_visibility_day', - 'calendar.events.aromantic_visibility_day', - 'mode.accessibility', - 'mode.reducedItems', - 'user.socialLookup', - 'user.socialLookupWhy', - 'footer.source', - 'error.invalidImage', - 'profile.banner', - 'profile.example', - 'calendar.onlyFirstDays', - 'calendar.start', - 'calendar.events.nonmonogamy_visibility_day', - 'profile.backup.header', - 'profile.backup.export.action', - 'profile.backup.export.success', - 'profile.backup.import.action', - 'profile.backup.import.success', - 'profile.backup.error.signature', - 'profile.share.customise', - 'profile.share.local', - 'profile.share.atAlternative', - 'profile.share.pronouns', - 'profile.expendableList.more', - 'profile.expendableList.show', - 'profile.opinions.colours.yellow', - 'profile.opinions.colours.teal', - 'profile.opinions.colours.purple', - 'profile.opinions.colours.brown', - 'profile.markdown.enable', - 'profile.markdown.features', - 'profile.markdown.examples', - 'profile.calendar.header', - 'profile.calendar.info', - 'profile.calendar.customEvents.header', - 'profile.calendar.customEvents.disclaimer', - 'profile.calendar.customEvents.name', - 'profile.calendar.customEvents.month', - 'profile.calendar.customEvents.day', - 'profile.calendar.customEvents.comment', - 'profile.calendar.customEvents.validation.missingName', - 'profile.calendar.customEvents.validation.missingDate', - 'profile.calendar.customEvents.validation.invalidDate', - 'profile.calendar.publicEvents.header', - 'profile.pronunciation.ipa', -]; diff --git a/src/helpers.js b/src/helpers.js index 14e83b86b..7f3800cca 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -23,7 +23,7 @@ export const deepGet = (obj, path) => { let value = obj; for (const part of path.split('.')) { value = value[part]; - if (value === undefined) { + if (value === undefined || value === null) { break; } } @@ -31,6 +31,18 @@ export const deepGet = (obj, path) => { return value; }; +export function* deepListKeys(obj) { + for (const [key, value] of Object.entries(obj)) { + if (value instanceof Object && !Array.isArray(value)) { + for (const subkey of deepListKeys(value)) { + yield `${key}.${subkey}`; + } + } else { + yield key; + } + } +} + export const head = ({ title, description, banner, noindex = false, keywords }) => { const meta = { meta: [] }; diff --git a/src/missingTranslations.js b/src/missingTranslations.js new file mode 100644 index 000000000..0b96d0822 --- /dev/null +++ b/src/missingTranslations.js @@ -0,0 +1,92 @@ +import { deepGet, deepListKeys } from './helpers.js'; + +export function listMissingTranslations(translations, baseTranslations, config) { + const expectedTranslations = [...deepListKeys(baseTranslations)]; + + return expectedTranslations.filter((k) => { + function keyMatches(...pats) { + for (const pat of pats) { + if (pat.endsWith('.')) { + if (k.startsWith(pat)) { + return true; + } + } else { + if (k === pat) { + return true; + } + } + } + return false; + } + + function has(k) { + return deepGet(translations, k) !== undefined; + } + + if (has(k)) { + return false; + } + + // FAQ entries are fully customizable for a language version. + if (keyMatches('faq.')) { + return false; + } + + // optional keys + if (keyMatches( + 'home.welcome', + 'contact.faq', + 'contact.technical', + 'contact.hate', + 'contact.team.extra', + 'contact.team.join.', + 'user.login.help', + )) { + return false; + } + + if (!has('home.welcome') && keyMatches('home.intro')) { + return false; + } + + if (!config.pronouns.enabled && keyMatches( + 'pronouns.', + 'home.header', + 'home.headerLong', + 'home.pronouns', + 'home.generator.', + 'profile.pronouns', + 'profile.pronounsInfo', + 'profile.pronounsNotFound', + 'profile.share.pronouns', + )) { + return false; + } + + if (!config.pronouns.comprehensive && keyMatches('pronouns.comprehensive.')) { + return false; + } + + if (!config.links.enabled && keyMatches('links.')) { + return false; + } + + if (!config.sources.enabled && keyMatches('sources.')) { + return false; + } + + if (!config.nouns.enabled && keyMatches('nouns.')) { + return false; + } + + if (!config.terminology.enabled && keyMatches('terminology.')) { + return false; + } + + if (!config.calendar?.enabled && keyMatches('calendar.')) { + return false; + } + + return true; + }); +} diff --git a/src/stats.js b/src/stats.js index 327af6481..70057ab64 100644 --- a/src/stats.js +++ b/src/stats.js @@ -2,8 +2,7 @@ import { decodeTime, ulid } from 'ulid'; import mailer from './mailer.js'; import Plausible from 'plausible-api'; import fetch from 'node-fetch'; -import expectedTranslations from '../locale/expectedTranslations.js'; -import { deepGet } from './helpers.js'; +import { listMissingTranslations } from './missingTranslations.js'; import fs from 'fs'; import Suml from 'suml'; @@ -124,13 +123,16 @@ export const calculateStats = async (db, allLocales, projectDir) => { }, }); + const baseTranslations = new Suml().parse(fs.readFileSync(`${projectDir}/locale/_base/translations.suml`).toString()); + for (const locale in allLocales) { if (!allLocales.hasOwnProperty(locale)) { continue; } const translations = new Suml().parse(fs.readFileSync(`${projectDir}/locale/${locale}/translations.suml`).toString()); - const missingTranslations = expectedTranslations.filter((key) => deepGet(translations, key) === undefined).length; + const config = new Suml().parse(fs.readFileSync(`${projectDir}/locale/${locale}/config.suml`).toString()); + const missingTranslations = listMissingTranslations(translations, baseTranslations, config).length; stats.push({ locale, diff --git a/src/translator.js b/src/translator.js index 6272dcdd6..e1a7c86ae 100644 --- a/src/translator.js +++ b/src/translator.js @@ -1,13 +1,14 @@ import { deepGet } from './helpers.js'; +import config from '../data/config.suml'; import translations from '../data/translations.suml'; import baseTranslations from '../locale/_base/translations.suml'; -import expectedTranslations from '../locale/expectedTranslations.js'; +import { listMissingTranslations } from './missingTranslations.js'; class Translator { - constructor(translations, baseTranslations, expectedTranslations) { + constructor(translations, baseTranslations, config) { this.translations = translations; this.baseTranslations = baseTranslations; - this.expectedTranslations = expectedTranslations; + this.config = config; } translate(key, params = {}, warn = false) { @@ -54,8 +55,8 @@ class Translator { } listMissingTranslations() { - return this.expectedTranslations.filter((k) => !this.has(k)); + return listMissingTranslations(this.translations, this.baseTranslations, this.config); } } -export default new Translator(translations, baseTranslations, expectedTranslations); +export default new Translator(translations, baseTranslations, config); diff --git a/test/fixtures/translations.js b/test/fixtures/translations.js index 09090a785..40aef504a 100644 --- a/test/fixtures/translations.js +++ b/test/fixtures/translations.js @@ -9,4 +9,7 @@ export const mockTranslations = (translations) => { jest.unstable_mockModule(`${__dirname}/../../locale/_base/translations.suml`, () => { return { default: {} }; }); + jest.unstable_mockModule(`${__dirname}/../../data/config.suml`, () => { + return { default: {} }; + }); }; diff --git a/test/translations.test.js b/test/translations.test.js index 93a5c75c3..bf996a44f 100644 --- a/test/translations.test.js +++ b/test/translations.test.js @@ -1,8 +1,6 @@ import { expect, test } from '@jest/globals'; import { loadSumlFromBase } from '../server/loader.js'; -import { deepGet } from '../src/helpers.js'; -import expectedTranslations from '../locale/expectedTranslations.js'; import locales from '../locale/locales.js'; const baseTranslations = loadSumlFromBase('locale/_base/translations'); @@ -59,9 +57,4 @@ test.each(locales)('translations of $code match schema of base translations', ({ expect(translations).toMatchBaseTranslationSchema(); }); -test('expected translations are defined in base translations', () => { - const undefinedTranslations = expectedTranslations.filter((key) => deepGet(baseTranslations, key) === undefined); - expect(undefinedTranslations).toEqual([]); -}); - expect.extend({ toMatchBaseTranslationSchema: toMatchBaseTranslationsSchema });