mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
ci(i18n): enforce controlled crowdin localization workflow and pr validation
Add Crowdin pull request validation script, centralize locale configuration, and update GitHub workflows to enforce sparse target catalog validation.
This commit is contained in:
@@ -61,7 +61,7 @@ AI assistance is welcome on this project; unreviewed AI output is not. See the
|
|||||||
- [ ] I read every line of the final diff
|
- [ ] I read every line of the final diff
|
||||||
- [ ] `pnpm verify` passes against the final diff
|
- [ ] `pnpm verify` passes against the final diff
|
||||||
- [ ] Tests added or updated per the [testing expectations](https://github.com/bookorbit/bookorbit/blob/main/docs/CONTRIBUTING.md#phase-7-write-and-run-tests)
|
- [ ] Tests added or updated per the [testing expectations](https://github.com/bookorbit/bookorbit/blob/main/docs/CONTRIBUTING.md#phase-7-write-and-run-tests)
|
||||||
- [ ] User-facing text is localized and accessibility was checked, where applicable
|
- [ ] User-facing text uses Vue I18n keys added only to `client/src/locales/en.json`; non-English catalogs are unchanged, and accessibility was checked
|
||||||
- [ ] Documentation updated, where applicable
|
- [ ] Documentation updated, where applicable
|
||||||
- [ ] No unintended files, secrets, build artifacts, or personal configuration included
|
- [ ] No unintended files, secrets, build artifacts, or personal configuration included
|
||||||
- [ ] No new dependencies, or the addition was discussed and approved in the linked issue
|
- [ ] No new dependencies, or the addition was discussed and approved in the linked issue
|
||||||
|
|||||||
@@ -190,6 +190,25 @@ jobs:
|
|||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||||
|
|
||||||
|
- name: Enforce localization change policy
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
IS_CROWDIN: ${{ steps.crowdin.outputs.is_crowdin }}
|
||||||
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
validator="$RUNNER_TEMP/validate-locale-pr.mjs"
|
||||||
|
if git cat-file -e "$PR_BASE_SHA:scripts/validate-locale-pr.mjs" 2>/dev/null; then
|
||||||
|
git show "$PR_BASE_SHA:scripts/validate-locale-pr.mjs" > "$validator"
|
||||||
|
else
|
||||||
|
git show "$PR_HEAD_SHA:scripts/validate-locale-pr.mjs" > "$validator"
|
||||||
|
fi
|
||||||
|
node "$validator" "$PR_BASE_SHA" "$PR_HEAD_SHA" "$IS_CROWDIN"
|
||||||
|
|
||||||
|
- name: Test PR policy scripts
|
||||||
|
run: node --test scripts/validate-locale-pr.test.mjs
|
||||||
|
|
||||||
- name: Lint PR title
|
- name: Lint PR title
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
CROWDIN_ALLOWED_TRANSLATION_LOSSES: ${{ inputs.allowed_translation_losses }}
|
CROWDIN_ALLOWED_TRANSLATION_LOSSES: ${{ inputs.allowed_translation_losses }}
|
||||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||||
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
|
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
|
||||||
run: node client/scripts/sync-crowdin-translations.mjs
|
run: pnpm --filter client sync:crowdin
|
||||||
|
|
||||||
- name: Validate translation catalogs
|
- name: Validate translation catalogs
|
||||||
run: pnpm --filter client validate:locales
|
run: pnpm --filter client validate:locales
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ Try the live instance before you install. No account required.
|
|||||||
### Platform & Delivery
|
### Platform & Delivery
|
||||||
|
|
||||||
- **Multi-User & SSO**: Granular per-user permissions and isolated reading data, with native support for Authentik, Keycloak, and Authelia via OIDC.
|
- **Multi-User & SSO**: Granular per-user permissions and isolated reading data, with native support for Authentik, Keycloak, and Authelia via OIDC.
|
||||||
- **Multilingual Interface**: English, German, Spanish, French, Italian, Dutch, Polish, Brazilian Portuguese, and Slovenian, translated by the community on [Crowdin](https://crowdin.com/project/bookorbit).
|
- **Multilingual Interface**: Community translations are managed on [Crowdin](https://crowdin.com/project/bookorbit). See the [localization guide](docs/LOCALIZATION.md) for current language support and contributor instructions.
|
||||||
- **Content Delivery**: OPDS support for compatible apps, Send-to-Kindle via email, and browser drag-and-drop uploads.
|
- **Content Delivery**: OPDS support for compatible apps, Send-to-Kindle via email, and browser drag-and-drop uploads.
|
||||||
- **Automated Ingestion**: Configure a Book Dock drop folder for hands-free importing.
|
- **Automated Ingestion**: Configure a Book Dock drop folder for hands-free importing.
|
||||||
|
|
||||||
@@ -125,6 +125,8 @@ For local development, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). To contri
|
|||||||
|
|
||||||
Help translate BookOrbit into your language on [Crowdin](https://crowdin.com/project/bookorbit).
|
Help translate BookOrbit into your language on [Crowdin](https://crowdin.com/project/bookorbit).
|
||||||
|
|
||||||
|
When adding user-facing text in code, add the Vue I18n key only to `client/src/locales/en.json`. Do not edit non-English catalogs in a feature pull request; untranslated keys fall back to English until Crowdin provides a translation. See [docs/LOCALIZATION.md](docs/LOCALIZATION.md) for the complete workflow.
|
||||||
|
|
||||||
[](https://crowdin.com/project/bookorbit)
|
[](https://crowdin.com/project/bookorbit)
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"lint:oxlint:fix": "oxlint src --fix",
|
"lint:oxlint:fix": "oxlint src --fix",
|
||||||
"lint:eslint:check": "eslint src --cache",
|
"lint:eslint:check": "eslint src --cache",
|
||||||
"lint:eslint:fix": "eslint src --fix --cache",
|
"lint:eslint:fix": "eslint src --fix --cache",
|
||||||
|
"sync:crowdin": "node ./scripts/sync-crowdin-translations.mjs",
|
||||||
"validate:locales": "node ./scripts/validate-locales.mjs",
|
"validate:locales": "node ./scripts/validate-locales.mjs",
|
||||||
"validate:styles": "node ./scripts/validate-styles.mjs",
|
"validate:styles": "node ./scripts/validate-styles.mjs",
|
||||||
"format": "prettier --write --experimental-cli src/",
|
"format": "prettier --write --experimental-cli src/",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { validateLocaleMessage, validateSlotCountMessage } from './locale-messag
|
|||||||
|
|
||||||
export function flattenCatalog(value, prefix = '', output = new Map()) {
|
export function flattenCatalog(value, prefix = '', output = new Map()) {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
throw new Error(prefix || 'catalog root must be a message object')
|
throw new Error(`${prefix || 'catalog root'} must be a message object`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, child] of Object.entries(value)) {
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ function catalogs(target = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('locale catalog validation', () => {
|
describe('locale catalog validation', () => {
|
||||||
|
it('reports invalid catalog objects with their path', () => {
|
||||||
|
expect(() => flattenCatalog(null)).toThrow('catalog root must be a message object')
|
||||||
|
expect(() => flattenCatalog(null, 'common')).toThrow('common must be a message object')
|
||||||
|
})
|
||||||
|
|
||||||
it('accepts a sparse target catalog', () => {
|
it('accepts a sparse target catalog', () => {
|
||||||
expect(validateCatalogs({ catalogs: catalogs({ common: { save: 'Uložit' } }) })).toEqual([])
|
expect(validateCatalogs({ catalogs: catalogs({ common: { save: 'Uložit' } }) })).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const scriptDirectory = import.meta.dirname ?? path.join(process.cwd(), 'scripts')
|
||||||
|
const repositoryRoot = path.resolve(scriptDirectory, '../..')
|
||||||
|
const localeTypesPath = path.join(repositoryRoot, 'packages/types/src/locale.ts')
|
||||||
|
|
||||||
|
export async function readSupportedLocales(sourcePath = localeTypesPath) {
|
||||||
|
const source = await readFile(sourcePath, 'utf8')
|
||||||
|
const match = source.match(/SUPPORTED_LOCALES\s*=\s*(\[[\s\S]*?\])\s+as const/)
|
||||||
|
if (!match) throw new Error('Unable to read SUPPORTED_LOCALES from packages/types/src/locale.ts')
|
||||||
|
return JSON.parse(match[1].replace(/,(\s*])$/, '$1'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPORTED_LOCALES = await readSupportedLocales()
|
||||||
|
|
||||||
|
const CROWDIN_LANGUAGE_ID_OVERRIDES = new Map([
|
||||||
|
['es', 'es-ES'],
|
||||||
|
['pt', 'pt-BR'],
|
||||||
|
['sv', 'sv-SE'],
|
||||||
|
['zh', 'zh-CN'],
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const locale of CROWDIN_LANGUAGE_ID_OVERRIDES.keys()) {
|
||||||
|
if (!SUPPORTED_LOCALES.includes(locale)) {
|
||||||
|
throw new Error(`Crowdin language ID override references unsupported locale ${locale}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TARGET_CATALOGS = SUPPORTED_LOCALES.filter((locale) => locale !== 'en').map((locale) => ({
|
||||||
|
languageId: CROWDIN_LANGUAGE_ID_OVERRIDES.get(locale) ?? locale,
|
||||||
|
locale,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const expectedCatalogPaths = TARGET_CATALOGS.map(({ locale }) => `client/src/locales/${locale}.json`)
|
||||||
|
|
||||||
|
function parseCrowdinLanguageIds(source) {
|
||||||
|
const match = source.match(/^export_languages:\s*\n((?:\s+-\s+[^\n]+\n?)+)/m)
|
||||||
|
if (!match) throw new Error('Unable to read export_languages from crowdin.yml')
|
||||||
|
return [...match[1].matchAll(/^\s+-\s+([^\s#]+)\s*$/gm)].map((entry) => entry[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkflowCatalogPaths(source) {
|
||||||
|
const match = source.match(/^\s+add-paths:\s*\|\s*\n((?:\s+client\/src\/locales\/[^\n]+\n?)+)/m)
|
||||||
|
if (!match) throw new Error('Unable to read Crowdin add-paths from crowdin-translations.yml')
|
||||||
|
return [...match[1].matchAll(/^\s+(client\/src\/locales\/[^\s]+)\s*$/gm)].map((entry) => entry[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClassifierCatalogPaths(source) {
|
||||||
|
const match = source.match(/allowed_paths=\(\s*([\s\S]*?)\s*\)/)
|
||||||
|
if (!match) throw new Error('Unable to read allowed_paths from classify-crowdin-pr.sh')
|
||||||
|
return [...match[1].matchAll(/"(client\/src\/locales\/[^"]+)"/g)].map((entry) => entry[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExactList(label, actual, expected) {
|
||||||
|
if (new Set(actual).size !== actual.length) throw new Error(`${label} contains duplicate entries`)
|
||||||
|
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
||||||
|
throw new Error(`${label} must match the supported target locales: expected ${expected.join(', ')}, found ${actual.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCrowdinTargetConfiguration({ crowdinLanguageIds, workflowCatalogPaths, classifierCatalogPaths }) {
|
||||||
|
assertExactList(
|
||||||
|
'crowdin.yml export_languages',
|
||||||
|
crowdinLanguageIds,
|
||||||
|
TARGET_CATALOGS.map(({ languageId }) => languageId),
|
||||||
|
)
|
||||||
|
assertExactList('Crowdin workflow add-paths', workflowCatalogPaths, expectedCatalogPaths)
|
||||||
|
assertExactList('Crowdin PR classifier allowed_paths', classifierCatalogPaths, expectedCatalogPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertCrowdinTargetConfiguration({
|
||||||
|
crowdinConfigPath = path.join(repositoryRoot, 'crowdin.yml'),
|
||||||
|
workflowPath = path.join(repositoryRoot, '.github/workflows/crowdin-translations.yml'),
|
||||||
|
classifierPath = path.join(repositoryRoot, 'scripts/classify-crowdin-pr.sh'),
|
||||||
|
} = {}) {
|
||||||
|
const [crowdinConfig, workflow, classifier] = await Promise.all([
|
||||||
|
readFile(crowdinConfigPath, 'utf8'),
|
||||||
|
readFile(workflowPath, 'utf8'),
|
||||||
|
readFile(classifierPath, 'utf8'),
|
||||||
|
])
|
||||||
|
validateCrowdinTargetConfiguration({
|
||||||
|
crowdinLanguageIds: parseCrowdinLanguageIds(crowdinConfig),
|
||||||
|
workflowCatalogPaths: parseWorkflowCatalogPaths(workflow),
|
||||||
|
classifierCatalogPaths: parseClassifierCatalogPaths(classifier),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Buffer } from 'node:buffer'
|
import { Buffer } from 'node:buffer'
|
||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import { isIP } from 'node:net'
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { TARGET_CATALOGS, assertCrowdinTargetConfiguration } from './locale-configuration.mjs'
|
||||||
import { flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
import { flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
||||||
|
|
||||||
const API = 'https://api.crowdin.com/api/v2'
|
const API = 'https://api.crowdin.com/api/v2'
|
||||||
@@ -9,26 +11,11 @@ const SOURCE_PATH_SUFFIX = '/client/src/locales/en.json'
|
|||||||
const MAX_API_RESPONSE_BYTES = 2 * 1024 * 1024
|
const MAX_API_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||||
const MAX_CATALOG_BYTES = 10 * 1024 * 1024
|
const MAX_CATALOG_BYTES = 10 * 1024 * 1024
|
||||||
const REQUEST_TIMEOUT_MS = 30_000
|
const REQUEST_TIMEOUT_MS = 30_000
|
||||||
const clientRoot = path.basename(process.cwd()) === 'client' ? process.cwd() : path.join(process.cwd(), 'client')
|
const scriptDirectory = import.meta.dirname ?? path.join(process.cwd(), 'scripts')
|
||||||
|
const clientRoot = path.resolve(scriptDirectory, '..')
|
||||||
const localesDirectory = path.join(clientRoot, 'src/locales')
|
const localesDirectory = path.join(clientRoot, 'src/locales')
|
||||||
|
|
||||||
export const TARGET_CATALOGS = [
|
export { TARGET_CATALOGS }
|
||||||
{ languageId: 'cs', locale: 'cs' },
|
|
||||||
{ languageId: 'da', locale: 'da' },
|
|
||||||
{ languageId: 'de', locale: 'de' },
|
|
||||||
{ languageId: 'es-ES', locale: 'es' },
|
|
||||||
{ languageId: 'fi', locale: 'fi' },
|
|
||||||
{ languageId: 'fr', locale: 'fr' },
|
|
||||||
{ languageId: 'it', locale: 'it' },
|
|
||||||
{ languageId: 'nl', locale: 'nl' },
|
|
||||||
{ languageId: 'pl', locale: 'pl' },
|
|
||||||
{ languageId: 'pt-BR', locale: 'pt' },
|
|
||||||
{ languageId: 'ru', locale: 'ru' },
|
|
||||||
{ languageId: 'sl', locale: 'sl' },
|
|
||||||
{ languageId: 'sv-SE', locale: 'sv' },
|
|
||||||
{ languageId: 'uk', locale: 'uk' },
|
|
||||||
{ languageId: 'zh-CN', locale: 'zh' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TARGET_LOCALES = new Set(TARGET_CATALOGS.map(({ locale }) => locale))
|
const TARGET_LOCALES = new Set(TARGET_CATALOGS.map(({ locale }) => locale))
|
||||||
|
|
||||||
@@ -65,37 +52,77 @@ function parseJson(text, context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSafeDownloadUrl(value) {
|
function ipv4Value(hostname) {
|
||||||
|
return hostname.split('.').reduce((value, octet) => value * 256 + Number(octet), 0) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInIpv4Range(value, network, prefixLength) {
|
||||||
|
const mask = prefixLength === 0 ? 0 : (0xffffffff << (32 - prefixLength)) >>> 0
|
||||||
|
return (value & mask) === (network & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv4(hostname) {
|
||||||
|
const value = ipv4Value(hostname)
|
||||||
|
return [
|
||||||
|
[ipv4Value('0.0.0.0'), 8],
|
||||||
|
[ipv4Value('10.0.0.0'), 8],
|
||||||
|
[ipv4Value('100.64.0.0'), 10],
|
||||||
|
[ipv4Value('127.0.0.0'), 8],
|
||||||
|
[ipv4Value('169.254.0.0'), 16],
|
||||||
|
[ipv4Value('172.16.0.0'), 12],
|
||||||
|
[ipv4Value('192.0.0.0'), 24],
|
||||||
|
[ipv4Value('192.0.2.0'), 24],
|
||||||
|
[ipv4Value('192.168.0.0'), 16],
|
||||||
|
[ipv4Value('198.18.0.0'), 15],
|
||||||
|
[ipv4Value('198.51.100.0'), 24],
|
||||||
|
[ipv4Value('203.0.113.0'), 24],
|
||||||
|
[ipv4Value('224.0.0.0'), 4],
|
||||||
|
[ipv4Value('240.0.0.0'), 4],
|
||||||
|
].some(([network, prefixLength]) => isInIpv4Range(value, network, prefixLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipv6Groups(hostname) {
|
||||||
|
const [head, tail = ''] = hostname.split('::')
|
||||||
|
const headGroups = head ? head.split(':').map((group) => Number.parseInt(group, 16)) : []
|
||||||
|
const tailGroups = tail ? tail.split(':').map((group) => Number.parseInt(group, 16)) : []
|
||||||
|
return [...headGroups, ...Array(8 - headGroups.length - tailGroups.length).fill(0), ...tailGroups]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv6(hostname) {
|
||||||
|
const groups = ipv6Groups(hostname)
|
||||||
|
const first = groups[0]
|
||||||
|
const ipv4Mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff
|
||||||
|
if (ipv4Mapped) {
|
||||||
|
const embedded = `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`
|
||||||
|
return isBlockedIpv4(embedded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
groups.every((group) => group === 0) ||
|
||||||
|
(groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) ||
|
||||||
|
groups.slice(0, 6).every((group) => group === 0) ||
|
||||||
|
(first & 0xfe00) === 0xfc00 ||
|
||||||
|
(first & 0xffc0) === 0xfe80 ||
|
||||||
|
(first & 0xffc0) === 0xfec0 ||
|
||||||
|
(first & 0xff00) === 0xff00
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeDownloadUrl(value) {
|
||||||
const url = new URL(value)
|
const url = new URL(value)
|
||||||
if (url.protocol !== 'https:') throw new Error('Crowdin export URL must use HTTPS')
|
if (url.protocol !== 'https:') throw new Error('Crowdin export URL must use HTTPS')
|
||||||
|
|
||||||
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
const hostname = url.hostname
|
||||||
if (
|
.replace(/^\[|\]$/g, '')
|
||||||
hostname === 'localhost' ||
|
.replace(/\.$/, '')
|
||||||
hostname.endsWith('.localhost') ||
|
.toLowerCase()
|
||||||
hostname.endsWith('.local') ||
|
const addressType = isIP(hostname)
|
||||||
hostname === '::1' ||
|
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
|
||||||
hostname.startsWith('fc') ||
|
|
||||||
hostname.startsWith('fd') ||
|
|
||||||
hostname.startsWith('fe80:')
|
|
||||||
) {
|
|
||||||
throw new Error('Crowdin export URL must not target a local network host')
|
throw new Error('Crowdin export URL must not target a local network host')
|
||||||
}
|
}
|
||||||
|
if ((addressType === 4 && isBlockedIpv4(hostname)) || (addressType === 6 && isBlockedIpv6(hostname))) {
|
||||||
const octets = hostname.split('.').map(Number)
|
|
||||||
if (octets.length === 4 && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) {
|
|
||||||
const [first, second] = octets
|
|
||||||
if (
|
|
||||||
first === 0 ||
|
|
||||||
first === 10 ||
|
|
||||||
first === 127 ||
|
|
||||||
(first === 169 && second === 254) ||
|
|
||||||
(first === 172 && second >= 16 && second <= 31) ||
|
|
||||||
(first === 192 && second === 168)
|
|
||||||
) {
|
|
||||||
throw new Error('Crowdin export URL must not target a local network host')
|
throw new Error('Crowdin export URL must not target a local network host')
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
@@ -172,7 +199,7 @@ function orderedSparseCatalog(reference, messages, prefix = '') {
|
|||||||
const messageKey = prefix ? `${prefix}.${key}` : key
|
const messageKey = prefix ? `${prefix}.${key}` : key
|
||||||
if (typeof child === 'string') {
|
if (typeof child === 'string') {
|
||||||
const translated = messages.get(messageKey)
|
const translated = messages.get(messageKey)
|
||||||
if (translated) output[key] = translated
|
if (translated !== undefined) output[key] = translated
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,9 +279,15 @@ export function findTranslationLosses({ locale, reference, current, exported })
|
|||||||
return losses
|
return losses
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertTranslationRetention({ reference, currentCatalogs, exportedCatalogs, allowedLosses = new Set() }) {
|
export function assertTranslationRetention({
|
||||||
|
reference,
|
||||||
|
currentCatalogs,
|
||||||
|
exportedCatalogs,
|
||||||
|
allowedLosses = new Set(),
|
||||||
|
targetCatalogs = TARGET_CATALOGS,
|
||||||
|
}) {
|
||||||
const losses = []
|
const losses = []
|
||||||
for (const { locale } of TARGET_CATALOGS) {
|
for (const { locale } of targetCatalogs) {
|
||||||
const current = currentCatalogs.get(locale)
|
const current = currentCatalogs.get(locale)
|
||||||
const exported = exportedCatalogs.get(locale)
|
const exported = exportedCatalogs.get(locale)
|
||||||
if (!current || !exported) throw new Error(`Translation retention comparison is missing the ${locale} catalog`)
|
if (!current || !exported) throw new Error(`Translation retention comparison is missing the ${locale} catalog`)
|
||||||
@@ -282,14 +315,17 @@ export async function syncCrowdinTranslations({
|
|||||||
catalogDirectory = localesDirectory,
|
catalogDirectory = localesDirectory,
|
||||||
outputDirectory = catalogDirectory,
|
outputDirectory = catalogDirectory,
|
||||||
allowedLosses = new Set(),
|
allowedLosses = new Set(),
|
||||||
|
targetCatalogs = TARGET_CATALOGS,
|
||||||
|
assertTargetConfiguration = assertCrowdinTargetConfiguration,
|
||||||
}) {
|
}) {
|
||||||
if (!token) throw new Error('CROWDIN_TOKEN is required')
|
if (!token) throw new Error('CROWDIN_TOKEN is required')
|
||||||
|
await assertTargetConfiguration()
|
||||||
|
|
||||||
const reference = JSON.parse(await readFile(path.join(catalogDirectory, 'en.json'), 'utf8'))
|
const reference = JSON.parse(await readFile(path.join(catalogDirectory, 'en.json'), 'utf8'))
|
||||||
const referenceMessages = flattenCatalog(reference)
|
const referenceMessages = flattenCatalog(reference)
|
||||||
const currentCatalogs = new Map(
|
const currentCatalogs = new Map(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
TARGET_CATALOGS.map(async ({ locale }) => {
|
targetCatalogs.map(async ({ locale }) => {
|
||||||
const catalog = JSON.parse(await readFile(path.join(catalogDirectory, `${locale}.json`), 'utf8'))
|
const catalog = JSON.parse(await readFile(path.join(catalogDirectory, `${locale}.json`), 'utf8'))
|
||||||
return [locale, flattenCatalog(catalog)]
|
return [locale, flattenCatalog(catalog)]
|
||||||
}),
|
}),
|
||||||
@@ -307,7 +343,7 @@ export async function syncCrowdinTranslations({
|
|||||||
throw new Error(`Crowdin source is not synchronized with en.json\n${details.join('\n')}`)
|
throw new Error(`Crowdin source is not synchronized with en.json\n${details.join('\n')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloaded = await mapWithConcurrency(TARGET_CATALOGS, 4, async ({ languageId, locale }) => ({
|
const downloaded = await mapWithConcurrency(targetCatalogs, 4, async ({ languageId, locale }) => ({
|
||||||
locale,
|
locale,
|
||||||
catalog: normalizeCrowdinCatalog(await client.exportedCatalog(fileId, languageId), reference),
|
catalog: normalizeCrowdinCatalog(await client.exportedCatalog(fileId, languageId), reference),
|
||||||
}))
|
}))
|
||||||
@@ -321,6 +357,7 @@ export async function syncCrowdinTranslations({
|
|||||||
currentCatalogs,
|
currentCatalogs,
|
||||||
exportedCatalogs: catalogs,
|
exportedCatalogs: catalogs,
|
||||||
allowedLosses,
|
allowedLosses,
|
||||||
|
targetCatalogs,
|
||||||
})
|
})
|
||||||
|
|
||||||
await mkdir(outputDirectory, { recursive: true })
|
await mkdir(outputDirectory, { recursive: true })
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { assertCrowdinTargetConfiguration, validateCrowdinTargetConfiguration } from './locale-configuration.mjs'
|
||||||
import {
|
import {
|
||||||
TARGET_CATALOGS,
|
TARGET_CATALOGS,
|
||||||
|
assertSafeDownloadUrl,
|
||||||
assertTranslationRetention,
|
assertTranslationRetention,
|
||||||
createCrowdinClient,
|
createCrowdinClient,
|
||||||
findTranslationLosses,
|
findTranslationLosses,
|
||||||
normalizeCrowdinCatalog,
|
normalizeCrowdinCatalog,
|
||||||
parseAllowedTranslationLosses,
|
parseAllowedTranslationLosses,
|
||||||
sourceDrift,
|
sourceDrift,
|
||||||
|
syncCrowdinTranslations,
|
||||||
} from './sync-crowdin-translations.mjs'
|
} from './sync-crowdin-translations.mjs'
|
||||||
|
|
||||||
const reference = {
|
const reference = {
|
||||||
@@ -14,6 +20,47 @@ const reference = {
|
|||||||
books: { count: '{count, plural, one {# book} other {# books}}' },
|
books: { count: '{count, plural, one {# book} other {# books}}' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const temporaryDirectories = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createCatalogFixture(targetCatalogs, currentCatalogs = new Map()) {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), 'bookorbit-crowdin-sync-'))
|
||||||
|
temporaryDirectories.push(directory)
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
await writeFile(path.join(directory, 'en.json'), `${JSON.stringify({ common: { save: 'Save' } }, null, 2)}\n`)
|
||||||
|
await Promise.all(
|
||||||
|
targetCatalogs.map(({ locale }) =>
|
||||||
|
writeFile(path.join(directory, `${locale}.json`), `${JSON.stringify(currentCatalogs.get(locale) ?? {}, null, 2)}\n`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSynchronizationFetch({ identifiers = ['common.save'], catalogs = new Map(), onDownload = async () => {} } = {}) {
|
||||||
|
return vi.fn(async (input, init = {}) => {
|
||||||
|
const url = new URL(input)
|
||||||
|
if (url.hostname === 'api.crowdin.com' && url.pathname.endsWith('/files')) {
|
||||||
|
return new Response(JSON.stringify({ data: [{ data: { id: 7, path: '/client/src/locales/en.json' } }] }))
|
||||||
|
}
|
||||||
|
if (url.hostname === 'api.crowdin.com' && url.pathname.endsWith('/strings')) {
|
||||||
|
return new Response(JSON.stringify({ data: identifiers.map((identifier) => ({ data: { identifier } })) }))
|
||||||
|
}
|
||||||
|
if (url.hostname === 'api.crowdin.com' && url.pathname.includes('/translations/builds/files/')) {
|
||||||
|
const { targetLanguageId } = JSON.parse(init.body)
|
||||||
|
return new Response(JSON.stringify({ data: { url: `https://downloads.example.test/${encodeURIComponent(targetLanguageId)}.json` } }))
|
||||||
|
}
|
||||||
|
if (url.hostname === 'downloads.example.test') {
|
||||||
|
const languageId = decodeURIComponent(path.basename(url.pathname, '.json'))
|
||||||
|
await onDownload(languageId)
|
||||||
|
return new Response(JSON.stringify(catalogs.get(languageId) ?? { common: { save: `Translated ${languageId}` } }))
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request ${url.href}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('Crowdin translation synchronization', () => {
|
describe('Crowdin translation synchronization', () => {
|
||||||
it('removes empty untranslated messages and follows English key order', () => {
|
it('removes empty untranslated messages and follows English key order', () => {
|
||||||
const exported = {
|
const exported = {
|
||||||
@@ -127,11 +174,152 @@ describe('Crowdin translation synchronization', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects private-network export URLs returned by Crowdin', async () => {
|
it('rejects non-HTTPS export URLs returned by Crowdin', () => {
|
||||||
const fetchImpl = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ data: { url: 'http://127.0.0.1/catalog.json' } })))
|
expect(() => assertSafeDownloadUrl('http://127.0.0.1/catalog.json')).toThrow('Crowdin export URL must use HTTPS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'https://127.0.0.1/catalog.json',
|
||||||
|
'https://127.0.0.1./catalog.json',
|
||||||
|
'https://10.0.0.5/catalog.json',
|
||||||
|
'https://2130706433/catalog.json',
|
||||||
|
'https://[::1]/catalog.json',
|
||||||
|
'https://[::ffff:127.0.0.1]/catalog.json',
|
||||||
|
'https://[fd00::1]/catalog.json',
|
||||||
|
'https://[fe90::1]/catalog.json',
|
||||||
|
'https://localhost./catalog.json',
|
||||||
|
])('rejects private or local export URL %s', (url) => {
|
||||||
|
expect(() => assertSafeDownloadUrl(url)).toThrow('Crowdin export URL must not target a local network host')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['https://fdn.example.com/x.json', 'https://fc-cdn.example.com/x.json'])(
|
||||||
|
'allows public hostnames that begin with IPv6-looking prefixes: %s',
|
||||||
|
(url) => {
|
||||||
|
expect(assertSafeDownloadUrl(url).href).toBe(url)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('allows an IPv4-mapped public address', () => {
|
||||||
|
expect(assertSafeDownloadUrl('https://[::ffff:8.8.8.8]/x.json').hostname).toBe('[::ffff:808:808]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revalidates redirects before following them', async () => {
|
||||||
|
const fetchImpl = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ data: { url: 'https://downloads.example.test/cs.json' } })))
|
||||||
|
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: 'https://127.0.0.1/catalog.json' } }))
|
||||||
const client = createCrowdinClient({ token: 'secret', projectId: '42', fetchImpl })
|
const client = createCrowdinClient({ token: 'secret', projectId: '42', fetchImpl })
|
||||||
|
|
||||||
await expect(client.exportedCatalog(7, 'cs')).rejects.toThrow('Crowdin export URL must use HTTPS')
|
await expect(client.exportedCatalog(7, 'cs')).rejects.toThrow('Crowdin export URL must not target a local network host')
|
||||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps every Crowdin target list synchronized with the shared locale list', async () => {
|
||||||
|
await expect(assertCrowdinTargetConfiguration()).resolves.toBeUndefined()
|
||||||
|
const targetPaths = TARGET_CATALOGS.map(({ locale }) => `client/src/locales/${locale}.json`)
|
||||||
|
expect(() =>
|
||||||
|
validateCrowdinTargetConfiguration({
|
||||||
|
crowdinLanguageIds: TARGET_CATALOGS.map(({ languageId }) => languageId),
|
||||||
|
workflowCatalogPaths: targetPaths,
|
||||||
|
classifierCatalogPaths: targetPaths.slice(1),
|
||||||
|
}),
|
||||||
|
).toThrow('Crowdin PR classifier allowed_paths must match the supported target locales')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves repository configuration independently of the working directory', async () => {
|
||||||
|
const originalDirectory = process.cwd()
|
||||||
|
try {
|
||||||
|
process.chdir(tmpdir())
|
||||||
|
await expect(assertCrowdinTargetConfiguration()).resolves.toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalDirectory)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('synchronizes catalogs with at most four concurrent language downloads', async () => {
|
||||||
|
const targetCatalogs = TARGET_CATALOGS.slice(0, 6)
|
||||||
|
const catalogDirectory = await createCatalogFixture(targetCatalogs)
|
||||||
|
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||||
|
let activeDownloads = 0
|
||||||
|
let maximumDownloads = 0
|
||||||
|
const fetchImpl = createSynchronizationFetch({
|
||||||
|
onDownload: async () => {
|
||||||
|
activeDownloads += 1
|
||||||
|
maximumDownloads = Math.max(maximumDownloads, activeDownloads)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||||
|
activeDownloads -= 1
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await syncCrowdinTranslations({
|
||||||
|
token: 'secret',
|
||||||
|
fetchImpl,
|
||||||
|
catalogDirectory,
|
||||||
|
outputDirectory,
|
||||||
|
targetCatalogs,
|
||||||
|
assertTargetConfiguration: async () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(maximumDownloads).toBe(4)
|
||||||
|
await expect(readFile(path.join(outputDirectory, 'cs.json'), 'utf8')).resolves.toContain('Translated cs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects source drift before downloading or writing catalogs', async () => {
|
||||||
|
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
|
||||||
|
const catalogDirectory = await createCatalogFixture(targetCatalogs)
|
||||||
|
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||||
|
const fetchImpl = createSynchronizationFetch({ identifiers: [] })
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
syncCrowdinTranslations({
|
||||||
|
token: 'secret',
|
||||||
|
fetchImpl,
|
||||||
|
catalogDirectory,
|
||||||
|
outputDirectory,
|
||||||
|
targetCatalogs,
|
||||||
|
assertTargetConfiguration: async () => {},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Crowdin source is not synchronized with en.json')
|
||||||
|
expect(fetchImpl.mock.calls.some(([input]) => new URL(input).hostname === 'downloads.example.test')).toBe(false)
|
||||||
|
await expect(access(outputDirectory)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates every downloaded catalog before writing any files', async () => {
|
||||||
|
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
|
||||||
|
const catalogDirectory = await createCatalogFixture(targetCatalogs)
|
||||||
|
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||||
|
const catalogs = new Map([[targetCatalogs[0].languageId, { common: { save: 'Bad \u2014 value' } }]])
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
syncCrowdinTranslations({
|
||||||
|
token: 'secret',
|
||||||
|
fetchImpl: createSynchronizationFetch({ catalogs }),
|
||||||
|
catalogDirectory,
|
||||||
|
outputDirectory,
|
||||||
|
targetCatalogs,
|
||||||
|
assertTargetConfiguration: async () => {},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Unicode em dash is not allowed')
|
||||||
|
await expect(access(outputDirectory)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks translation retention before writing any files', async () => {
|
||||||
|
const targetCatalogs = TARGET_CATALOGS.slice(0, 2)
|
||||||
|
const currentCatalogs = new Map([[targetCatalogs[0].locale, { common: { save: 'Ulozit' } }]])
|
||||||
|
const catalogDirectory = await createCatalogFixture(targetCatalogs, currentCatalogs)
|
||||||
|
const outputDirectory = path.join(catalogDirectory, 'output')
|
||||||
|
const catalogs = new Map([[targetCatalogs[0].languageId, {}]])
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
syncCrowdinTranslations({
|
||||||
|
token: 'secret',
|
||||||
|
fetchImpl: createSynchronizationFetch({ catalogs }),
|
||||||
|
catalogDirectory,
|
||||||
|
outputDirectory,
|
||||||
|
targetCatalogs,
|
||||||
|
assertTargetConfiguration: async () => {},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('missing from Crowdin export')
|
||||||
|
await expect(access(outputDirectory)).rejects.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { readdir, readFile } from 'node:fs/promises'
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { SUPPORTED_LOCALES } from './locale-configuration.mjs'
|
||||||
import { flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
import { flattenCatalog, validateCatalogs } from './locale-catalog-validation.mjs'
|
||||||
|
|
||||||
const clientRoot = fileURLToPath(new URL('..', import.meta.url))
|
const clientRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||||
const localesDirectory = path.join(clientRoot, 'src/locales')
|
const localesDirectory = path.join(clientRoot, 'src/locales')
|
||||||
const localeTypesPath = path.join(clientRoot, '../packages/types/src/locale.ts')
|
|
||||||
|
|
||||||
async function sourceFiles(directory) {
|
async function sourceFiles(directory) {
|
||||||
const entries = await readdir(directory, { withFileTypes: true })
|
const entries = await readdir(directory, { withFileTypes: true })
|
||||||
@@ -45,21 +45,14 @@ async function sourceMessageKeys() {
|
|||||||
return { keys, slotCountKeys }
|
return { keys, slotCountKeys }
|
||||||
}
|
}
|
||||||
|
|
||||||
const localeTypes = await readFile(localeTypesPath, 'utf8')
|
|
||||||
const supportedMatch = localeTypes.match(/SUPPORTED_LOCALES\s*=\s*(\[[\s\S]*?\])\s+as const/)
|
|
||||||
if (!supportedMatch) throw new Error('Unable to read SUPPORTED_LOCALES from packages/types/src/locale.ts')
|
|
||||||
// Prettier wraps the array once the locale list outgrows the print width, which
|
|
||||||
// adds a trailing comma that JSON.parse rejects.
|
|
||||||
const supportedLocales = JSON.parse(supportedMatch[1].replace(/,(\s*])$/, '$1'))
|
|
||||||
|
|
||||||
const localeFiles = (await readdir(localesDirectory)).filter((file) => file.endsWith('.json')).sort()
|
const localeFiles = (await readdir(localesDirectory)).filter((file) => file.endsWith('.json')).sort()
|
||||||
const expectedFiles = supportedLocales.map((locale) => `${locale}.json`).sort()
|
const expectedFiles = SUPPORTED_LOCALES.map((locale) => `${locale}.json`).sort()
|
||||||
if (JSON.stringify(localeFiles) !== JSON.stringify(expectedFiles)) {
|
if (JSON.stringify(localeFiles) !== JSON.stringify(expectedFiles)) {
|
||||||
throw new Error(`Locale files do not match SUPPORTED_LOCALES: expected ${expectedFiles.join(', ')}, found ${localeFiles.join(', ')}`)
|
throw new Error(`Locale files do not match SUPPORTED_LOCALES: expected ${expectedFiles.join(', ')}, found ${localeFiles.join(', ')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const catalogs = new Map()
|
const catalogs = new Map()
|
||||||
for (const locale of supportedLocales) {
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
const raw = await readFile(path.join(localesDirectory, `${locale}.json`), 'utf8')
|
const raw = await readFile(path.join(localesDirectory, `${locale}.json`), 'utf8')
|
||||||
catalogs.set(locale, flattenCatalog(JSON.parse(raw)))
|
catalogs.set(locale, flattenCatalog(JSON.parse(raw)))
|
||||||
}
|
}
|
||||||
@@ -74,4 +67,4 @@ if (errors.length > 0) {
|
|||||||
throw new Error(`Locale validation failed:\n${errors.join('\n')}`)
|
throw new Error(`Locale validation failed:\n${errors.join('\n')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Validated ${supportedLocales.length} locale catalogs against ${reference.size} English messages`)
|
console.log(`Validated ${SUPPORTED_LOCALES.length} locale catalogs against ${reference.size} English messages`)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Thanks for your interest in contributing to BookOrbit. Whether you are fixing a
|
|||||||
- [DEVELOPMENT.md](DEVELOPMENT.md) - Local setup, architecture, commands, and technical reference.
|
- [DEVELOPMENT.md](DEVELOPMENT.md) - Local setup, architecture, commands, and technical reference.
|
||||||
- [TESTING.md](TESTING.md) - Test architecture, E2E suites, coverage thresholds, and harness details.
|
- [TESTING.md](TESTING.md) - Test architecture, E2E suites, coverage thresholds, and harness details.
|
||||||
- [COMMIT_GUIDELINES.md](COMMIT_GUIDELINES.md) - Commit message format, types, scopes, and examples.
|
- [COMMIT_GUIDELINES.md](COMMIT_GUIDELINES.md) - Commit message format, types, scopes, and examples.
|
||||||
|
- [LOCALIZATION.md](LOCALIZATION.md) - User-facing copy, ICU messages, Crowdin, and adding languages.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -109,6 +110,18 @@ Refer to [DEVELOPMENT.md](DEVELOPMENT.md) for architecture details, project stru
|
|||||||
- **No unapproved dependencies.** Propose new dependencies in the linked issue first (see [Adding Dependencies](#adding-dependencies)).
|
- **No unapproved dependencies.** Propose new dependencies in the linked issue first (see [Adding Dependencies](#adding-dependencies)).
|
||||||
- **Keep the scope tight.** If you discover something unrelated that needs fixing, open a separate issue for it.
|
- **Keep the scope tight.** If you discover something unrelated that needs fixing, open a separate issue for it.
|
||||||
|
|
||||||
|
#### Localizing User-Facing Text
|
||||||
|
|
||||||
|
BookOrbit uses Vue I18n, with English as the complete source catalog and Crowdin as the source of truth for every other language.
|
||||||
|
|
||||||
|
- Add every new user-facing message, including accessibility labels and transient states, to `client/src/locales/en.json` and reference it through Vue I18n.
|
||||||
|
- Do not add the key or an English fallback to any non-English catalog. Target catalogs are intentionally sparse, and missing messages fall back to English at runtime.
|
||||||
|
- Do not create or improve target-language translations in a feature pull request. Translate them in [Crowdin](https://crowdin.com/project/bookorbit); the controlled translation workflow will open a separate PR.
|
||||||
|
- Run `pnpm --filter client validate:locales` after changing `en.json`.
|
||||||
|
- Read [LOCALIZATION.md](LOCALIZATION.md) before changing ICU plural structure, changing the meaning of an existing message, or adding a language.
|
||||||
|
|
||||||
|
CI rejects edits to existing non-English catalogs in ordinary pull requests. A deliberate new-language setup may add only an empty target catalog until Crowdin export is enabled.
|
||||||
|
|
||||||
### Phase 7: Write and Run Tests
|
### Phase 7: Write and Run Tests
|
||||||
|
|
||||||
Testing expectations depend on what you changed:
|
Testing expectations depend on what you changed:
|
||||||
@@ -218,6 +231,7 @@ Before marking your PR ready for review, confirm:
|
|||||||
- [ ] Tests included per the [testing expectations table](#phase-7-write-and-run-tests)
|
- [ ] Tests included per the [testing expectations table](#phase-7-write-and-run-tests)
|
||||||
- [ ] Full-stack behavior manually validated
|
- [ ] Full-stack behavior manually validated
|
||||||
- [ ] UI changes include screenshots, plus a recording when behavior or interaction changed
|
- [ ] UI changes include screenshots, plus a recording when behavior or interaction changed
|
||||||
|
- [ ] User-facing text uses keys added only to `client/src/locales/en.json`; non-English catalogs are unchanged
|
||||||
- [ ] PR template fully completed
|
- [ ] PR template fully completed
|
||||||
- [ ] No unintended files (build artifacts, `.env`, personal configs)
|
- [ ] No unintended files (build artifacts, `.env`, personal configs)
|
||||||
- [ ] No unapproved new dependencies
|
- [ ] No unapproved new dependencies
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ Feature branches must:
|
|||||||
|
|
||||||
Do not add the key to target catalogs. Target catalogs are sparse and contain only real Crowdin translations. Vue I18n resolves an absent target key through the complete English catalog, including English ICU plural rules.
|
Do not add the key to target catalogs. Target catalogs are sparse and contain only real Crowdin translations. Vue I18n resolves an absent target key through the complete English catalog, including English ICU plural rules.
|
||||||
|
|
||||||
|
CI rejects changes to existing target catalogs in ordinary pull requests. The validated `l10n_main` Crowdin PR is the only routine writer. A normal issue-linked pull request may add an empty catalog only as part of the new-language setup described below.
|
||||||
|
|
||||||
Do not create or improve target-language translations directly in Git. Crowdin does not continuously import repository translations after initial setup, so a later Crowdin export would overwrite those changes. Apply any emergency target-catalog fix in Crowdin before the next export.
|
Do not create or improve target-language translations directly in Git. Crowdin does not continuously import repository translations after initial setup, so a later Crowdin export would overwrite those changes. Apply any emergency target-catalog fix in Crowdin before the next export.
|
||||||
|
|
||||||
When an English edit changes meaning rather than wording, prefer a new message key. If the key must remain, explicitly invalidate or clear the affected translations in Crowdin so an outdated translation is not exported for the new meaning.
|
When an English edit changes meaning rather than wording, prefer a new message key. If the key must remain, explicitly invalidate or clear the affected translations in Crowdin so an outdated translation is not exported for the new meaning.
|
||||||
@@ -187,9 +189,9 @@ Update `crowdin.yml`:
|
|||||||
- Add the exact Crowdin language ID to `export_languages`.
|
- Add the exact Crowdin language ID to `export_languages`.
|
||||||
- Add `languages_mapping` when the desired filename is not the language's unique `%two_letters_code%` output.
|
- Add `languages_mapping` when the desired filename is not the language's unique `%two_letters_code%` output.
|
||||||
|
|
||||||
Add the Crowdin language ID and application locale mapping to `TARGET_CATALOGS` in `client/scripts/sync-crowdin-translations.mjs`.
|
`TARGET_CATALOGS` is derived from `SUPPORTED_LOCALES`. If the Crowdin language ID differs from the application locale, add the exception to `CROWDIN_LANGUAGE_ID_OVERRIDES` in `client/scripts/locale-configuration.mjs`.
|
||||||
|
|
||||||
Update `scripts/classify-crowdin-pr.sh` to allow the new target catalog path. Do not allow `en.json` or broaden the rule to the whole locales directory.
|
Add the new target catalog path to `.github/workflows/crowdin-translations.yml` and `scripts/classify-crowdin-pr.sh`. Do not allow `en.json` or broaden either rule to the whole locales directory. The synchronization script checks these lists and `crowdin.yml` against the shared locale configuration before contacting Crowdin.
|
||||||
|
|
||||||
Before merging, add the target language in Crowdin while the current `main` configuration still excludes it from `export_languages`, or pause translation synchronization. Merge the repository support before allowing the first export. After Crowdin synchronizes the new English source, dispatch `Crowdin Translation Sync` manually and confirm that the generated pull request changes only explicitly allowed target catalogs.
|
Before merging, add the target language in Crowdin while the current `main` configuration still excludes it from `export_languages`, or pause translation synchronization. Merge the repository support before allowing the first export. After Crowdin synchronizes the new English source, dispatch `Crowdin Translation Sync` manually and confirm that the generated pull request changes only explicitly allowed target catalogs.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -31,8 +31,9 @@
|
|||||||
"lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix",
|
"lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix",
|
||||||
"format": "pnpm --filter server format && pnpm --filter client format",
|
"format": "pnpm --filter server format && pnpm --filter client format",
|
||||||
"format:check": "pnpm --filter server format:check && pnpm --filter client format:check",
|
"format:check": "pnpm --filter server format:check && pnpm --filter client format:check",
|
||||||
"test": "pnpm run test:charts && pnpm run test:server && pnpm run test:client",
|
"test": "pnpm run test:charts && pnpm run test:pr-policy && pnpm run test:server && pnpm run test:client",
|
||||||
"test:charts": "node --test scripts/star-history/generate.test.mjs scripts/crowdin-stats/generate.test.mjs",
|
"test:charts": "node --test scripts/star-history/generate.test.mjs scripts/crowdin-stats/generate.test.mjs",
|
||||||
|
"test:pr-policy": "node --test scripts/validate-locale-pr.test.mjs",
|
||||||
"test:server": "pnpm --filter server test",
|
"test:server": "pnpm --filter server test",
|
||||||
"test:client": "pnpm --filter client test:unit --run",
|
"test:client": "pnpm --filter client test:unit --run",
|
||||||
"e2e:run": "node ./scripts/e2e/run-suite.mjs",
|
"e2e:run": "node ./scripts/e2e/run-suite.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
const [baseSha, headSha, isCrowdin] = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (!baseSha || !headSha || !["true", "false"].includes(isCrowdin)) {
|
||||||
|
console.error("usage: validate-locale-pr.mjs <base-sha> <head-sha> <is-crowdin>");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCrowdin === "true") {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = (args, options = {}) =>
|
||||||
|
execFileSync("git", args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pathExists = (revision, path) => {
|
||||||
|
try {
|
||||||
|
git(["cat-file", "-e", `${revision}:${path}`]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changedPaths = git(["diff", "--name-only", "-z", baseSha, headSha]).split("\0").filter(Boolean);
|
||||||
|
|
||||||
|
const targetCatalogs = changedPaths.filter((path) => /^client\/src\/locales\/[^/]+\.json$/.test(path) && path !== "client/src/locales/en.json");
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const path of targetCatalogs) {
|
||||||
|
if (pathExists(baseSha, path)) {
|
||||||
|
errors.push(`${path} is a Crowdin-owned target catalog and cannot be changed in an ordinary pull request.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathExists(headSha, path)) {
|
||||||
|
errors.push(`${path} cannot be deleted or renamed in an ordinary pull request.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = git(["show", `${headSha}:${path}`]);
|
||||||
|
let catalog;
|
||||||
|
|
||||||
|
try {
|
||||||
|
catalog = JSON.parse(contents);
|
||||||
|
} catch {
|
||||||
|
errors.push(`${path} must be valid JSON.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmptyObject = catalog !== null && typeof catalog === "object" && !Array.isArray(catalog) && Object.keys(catalog).length === 0;
|
||||||
|
|
||||||
|
if (!isEmptyObject) {
|
||||||
|
errors.push(`${path} is a new target catalog but is not empty. New languages must start with an empty catalog before Crowdin export is enabled.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
for (const error of errors) {
|
||||||
|
console.error(`::error::${error}`);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
"::error::Add or update application copy only in client/src/locales/en.json. Apply translations in Crowdin and let the controlled translation workflow update target catalogs.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptPath = fileURLToPath(new URL("./validate-locale-pr.mjs", import.meta.url));
|
||||||
|
|
||||||
|
const gitEnv = {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_EMAIL: "test@example.com",
|
||||||
|
GIT_AUTHOR_NAME: "Test",
|
||||||
|
GIT_COMMITTER_EMAIL: "test@example.com",
|
||||||
|
GIT_COMMITTER_NAME: "Test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const git = (cwd, args) =>
|
||||||
|
execFileSync("git", args, {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: gitEnv,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const createRepository = () => {
|
||||||
|
const cwd = mkdtempSync(path.join(tmpdir(), "bookorbit-locale-policy-"));
|
||||||
|
git(cwd, ["init", "--quiet"]);
|
||||||
|
mkdirSync(path.join(cwd, "client/src/locales"), { recursive: true });
|
||||||
|
writeFileSync(path.join(cwd, "client/src/locales/en.json"), '{"example":"Example"}\n');
|
||||||
|
writeFileSync(path.join(cwd, "client/src/locales/de.json"), '{"example":"Beispiel"}\n');
|
||||||
|
git(cwd, ["add", "."]);
|
||||||
|
git(cwd, ["commit", "--quiet", "-m", "base"]);
|
||||||
|
return cwd;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitChanges = (cwd) => {
|
||||||
|
git(cwd, ["add", "--all"]);
|
||||||
|
git(cwd, ["commit", "--quiet", "-m", "change"]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = (cwd, isCrowdin = false) => {
|
||||||
|
const headSha = git(cwd, ["rev-parse", "HEAD"]);
|
||||||
|
const baseSha = git(cwd, ["rev-parse", "HEAD^"]);
|
||||||
|
return spawnSync(process.execPath, [scriptPath, baseSha, headSha, String(isCrowdin)], { cwd, encoding: "utf8" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const withRepository = (callback) => {
|
||||||
|
const cwd = createRepository();
|
||||||
|
try {
|
||||||
|
callback(cwd);
|
||||||
|
} finally {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test("allows an ordinary pull request to change en.json", () => {
|
||||||
|
withRepository((cwd) => {
|
||||||
|
writeFileSync(path.join(cwd, "client/src/locales/en.json"), '{"example":"Updated"}\n');
|
||||||
|
commitChanges(cwd);
|
||||||
|
|
||||||
|
assert.equal(validate(cwd).status, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an ordinary pull request that changes a target catalog", () => {
|
||||||
|
withRepository((cwd) => {
|
||||||
|
writeFileSync(path.join(cwd, "client/src/locales/de.json"), '{"example":"Geandert"}\n');
|
||||||
|
commitChanges(cwd);
|
||||||
|
|
||||||
|
const result = validate(cwd);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stderr, /Crowdin-owned target catalog/);
|
||||||
|
assert.match(result.stderr, /client\/src\/locales\/en\.json/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects deletion of an existing target catalog", () => {
|
||||||
|
withRepository((cwd) => {
|
||||||
|
rmSync(path.join(cwd, "client/src/locales/de.json"));
|
||||||
|
commitChanges(cwd);
|
||||||
|
|
||||||
|
assert.equal(validate(cwd).status, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows an empty catalog for new-language setup", () => {
|
||||||
|
withRepository((cwd) => {
|
||||||
|
writeFileSync(path.join(cwd, "client/src/locales/ja.json"), "{}\n");
|
||||||
|
commitChanges(cwd);
|
||||||
|
|
||||||
|
assert.equal(validate(cwd).status, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a translated catalog added outside Crowdin", () => {
|
||||||
|
withRepository((cwd) => {
|
||||||
|
writeFileSync(path.join(cwd, "client/src/locales/ja.json"), '{"example":"Rei"}\n');
|
||||||
|
commitChanges(cwd);
|
||||||
|
|
||||||
|
const result = validate(cwd);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stderr, /new target catalog but is not empty/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows a classified Crowdin pull request to change target catalogs", () => {
|
||||||
|
withRepository((cwd) => {
|
||||||
|
writeFileSync(path.join(cwd, "client/src/locales/de.json"), '{"example":"Aktualisiert"}\n');
|
||||||
|
commitChanges(cwd);
|
||||||
|
|
||||||
|
assert.equal(validate(cwd, true).status, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user