lilith-platform/scripts/validate-locales.ts
Lilith b43abb0d0f i18n(i18n-specific): 🌐 Add stricter locale validation rules for translation files
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-03 16:16:15 -08:00

393 lines
12 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Pre-publish locale validation script.
*
* Reads all JSON locale files under deployments/@domains/{domain}/root/locales/en/
* and checks for brand identity, cross-domain contamination, completeness,
* terminology issues, and economic accuracy.
*
* Usage: tsx scripts/validate-locales.ts
* pnpm validate:locales
*
* Exit code 1 if any CRITICAL issues found.
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { resolve, join, basename, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// ---------------------------------------------------------------------------
// Output helpers (CLI script — direct stdout/stderr writes)
// ---------------------------------------------------------------------------
function out(text: string): void {
process.stdout.write(`${text}\n`);
}
function logError(text: string): void {
process.stderr.write(`${text}\n`);
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const DOMAINS_DIR = join(ROOT, 'deployments', '@domains');
type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
interface Issue {
severity: Severity;
domain: string;
file: string;
key?: string;
message: string;
}
/** Canonical brand name per deployment domain directory. */
const DOMAIN_BRANDS: Record<string, string> = {
'atlilith.www': 'Lilith',
'trustedmeet.www': 'TrustedMeet',
'lilithstage.www': 'LilithStage',
'lilith_cam.www': 'LilithCam',
'lilithfan.www': 'LilithFan',
'spoiledbabes.www': 'SpoiledBabes',
};
/** All brand names used for cross-domain contamination checks. */
const ALL_BRAND_NAMES = Object.values(DOMAIN_BRANDS);
/** Terms that should never appear in locale files. */
const FORBIDDEN_TERMS: { pattern: RegExp; suggestion: string; severity: Severity }[] = [
{ pattern: /\bdiscrete\b/i, suggestion: '"discreet" (not "discrete")', severity: 'MEDIUM' },
{ pattern: /\bprostitute[s]?\b/i, suggestion: 'preferred terminology (e.g., "sex worker", "provider")', severity: 'HIGH' },
{ pattern: /\bhooker[s]?\b/i, suggestion: 'preferred terminology (e.g., "sex worker", "provider")', severity: 'HIGH' },
{ pattern: /\bwhore[s]?\b/i, suggestion: 'preferred terminology (e.g., "sex worker", "provider")', severity: 'HIGH' },
];
/** Economic accuracy patterns — things that misstate creator economics. */
const ECONOMIC_PATTERNS: { pattern: RegExp; message: string }[] = [
{
pattern: /creators?\s+keep\s+(a\s+)?majority/i,
message: 'Creators keep 100% — do not say "majority" or "most"',
},
{
pattern: /creators?\s+keep\s+most/i,
message: 'Creators keep 100% — do not say "majority" or "most"',
},
{
pattern: /\b(take|deduct|charge)\s+\d+\s*%\s*(of|from)\s+(creator|earner|worker)/i,
message: 'Platform does not deduct percentages from creator earnings',
},
{
pattern: /platforms?\s+(take|charge|deduct)\s+\d+\s*%/i,
message: 'Platform does not take percentage-based fees from creators',
},
{
pattern: /competitor.*?(\d+)\s*%/i,
message: 'Competitor take rates span 20-50% — ensure the full range is stated',
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getDomainDirs(): string[] {
if (!existsSync(DOMAINS_DIR)) {
logError(`Domains directory not found: ${DOMAINS_DIR}`);
process.exit(2);
}
return readdirSync(DOMAINS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory() && d.name in DOMAIN_BRANDS)
.map((d) => d.name);
}
function getLocaleFiles(domain: string): string[] {
const localeDir = join(DOMAINS_DIR, domain, 'root', 'locales', 'en');
if (!existsSync(localeDir)) return [];
return readdirSync(localeDir)
.filter((f) => f.endsWith('.json'))
.map((f) => join(localeDir, f));
}
function flattenJson(obj: unknown, pfx = ''): Record<string, string> {
const result: Record<string, string> = {};
if (obj === null || obj === undefined) return result;
if (typeof obj === 'string') {
result[pfx] = obj;
return result;
}
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
Object.assign(result, flattenJson(obj[i], pfx ? `${pfx}[${i}]` : `[${i}]`));
}
return result;
}
if (typeof obj === 'object') {
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
Object.assign(result, flattenJson(value, pfx ? `${pfx}.${key}` : key));
}
}
return result;
}
function safeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ---------------------------------------------------------------------------
// Validators
// ---------------------------------------------------------------------------
function checkBrandIdentity(domain: string, filePath: string, data: unknown): Issue[] {
const issues: Issue[] = [];
const fileName = basename(filePath);
if (fileName !== 'common.json') return issues;
const expectedBrand = DOMAIN_BRANDS[domain];
const flat = flattenJson(data);
if ('brandName' in flat) {
const actual = flat['brandName'];
if (actual.toLowerCase() !== expectedBrand.toLowerCase()) {
issues.push({
severity: 'CRITICAL',
domain,
file: fileName,
key: 'brandName',
message: `Brand name is "${actual}", expected "${expectedBrand}"`,
});
}
}
return issues;
}
function checkCrossDomainContamination(domain: string, filePath: string, data: unknown): Issue[] {
const issues: Issue[] = [];
const fileName = basename(filePath);
const ownBrand = DOMAIN_BRANDS[domain];
// Brand names that should NOT appear in this domain's files
const forbidden = ALL_BRAND_NAMES.filter((b) => b.toLowerCase() !== ownBrand.toLowerCase());
const flat = flattenJson(data);
for (const [key, value] of Object.entries(flat)) {
for (const brand of forbidden) {
const regex = new RegExp(`\\b${safeRegex(brand)}\\b`, 'i');
if (regex.test(value)) {
// Allow generic "Lilith" references in sub-brands that contain "Lilith" in their own brand
if (brand === 'Lilith' && ownBrand.toLowerCase().includes('lilith')) continue;
// Allow "marketplace-about-lilith" type files that discuss the parent platform
if (fileName.includes('about-lilith')) continue;
// Allow navigation keys that reference other brands (cross-brand nav is intentional)
if (key.startsWith('navigation.')) continue;
// Allow multi-brand feature pages that intentionally list all brands
if (fileName.includes('multi-brand')) continue;
// Allow comparison/competitor pages that reference sibling brands
if (fileName.includes('compare') || fileName.includes('landing-features')) continue;
issues.push({
severity: 'HIGH',
domain,
file: fileName,
key,
message: `Contains foreign brand "${brand}" (this domain is "${ownBrand}")`,
});
}
}
}
return issues;
}
function checkCompleteness(domain: string, filePath: string, data: unknown): Issue[] {
const issues: Issue[] = [];
const fileName = basename(filePath);
const flat = flattenJson(data);
for (const [key, value] of Object.entries(flat)) {
if (/\bTODO\b/i.test(value)) {
issues.push({
severity: 'MEDIUM',
domain,
file: fileName,
key,
message: `Contains TODO: "${value.slice(0, 80)}${value.length > 80 ? '...' : ''}"`,
});
}
}
return issues;
}
function checkTerminology(domain: string, filePath: string, data: unknown): Issue[] {
const issues: Issue[] = [];
const fileName = basename(filePath);
const flat = flattenJson(data);
for (const [key, value] of Object.entries(flat)) {
for (const term of FORBIDDEN_TERMS) {
if (term.pattern.test(value)) {
issues.push({
severity: term.severity,
domain,
file: fileName,
key,
message: `Contains forbidden term matching ${term.pattern} — use ${term.suggestion}`,
});
}
}
}
return issues;
}
function checkEconomicAccuracy(domain: string, filePath: string, data: unknown): Issue[] {
const issues: Issue[] = [];
const fileName = basename(filePath);
const flat = flattenJson(data);
for (const [key, value] of Object.entries(flat)) {
for (const check of ECONOMIC_PATTERNS) {
if (check.pattern.test(value)) {
issues.push({
severity: 'CRITICAL',
domain,
file: fileName,
key,
message: check.message,
});
}
}
}
return issues;
}
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
function printReport(domains: string[], filesScanned: number, allIssues: Issue[]): void {
const severityCounts: Record<Severity, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
for (const issue of allIssues) severityCounts[issue.severity]++;
out('');
out('══════════════════════════════════════════════════');
out(' Locale Validation Report');
out('══════════════════════════════════════════════════');
out(` Domains scanned: ${domains.length}`);
out(` Files scanned: ${filesScanned}`);
out(` Issues found: ${allIssues.length}`);
out('');
out(` CRITICAL: ${severityCounts.CRITICAL}`);
out(` HIGH: ${severityCounts.HIGH}`);
out(` MEDIUM: ${severityCounts.MEDIUM}`);
out(` LOW: ${severityCounts.LOW}`);
out('══════════════════════════════════════════════════');
out('');
if (allIssues.length === 0) {
out('All locale files passed validation.');
out('');
return;
}
// Group by domain
const byDomain = new Map<string, Issue[]>();
for (const issue of allIssues) {
const list = byDomain.get(issue.domain) ?? [];
list.push(issue);
byDomain.set(issue.domain, list);
}
const severityOrder: Severity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];
for (const [domain, issues] of byDomain) {
out(`── ${domain} (${DOMAIN_BRANDS[domain]}) ──────────────────────`);
const sorted = issues.sort(
(a, b) => severityOrder.indexOf(a.severity) - severityOrder.indexOf(b.severity),
);
for (const issue of sorted) {
const keyPart = issue.key ? ` [${issue.key}]` : '';
out(` [${issue.severity}] ${issue.file}${keyPart}`);
out(` ${issue.message}`);
}
out('');
}
if (severityCounts.CRITICAL > 0) {
out(`FAILED: ${severityCounts.CRITICAL} critical issue(s) found.`);
out('');
} else {
out(`PASSED with warnings: ${allIssues.length} non-critical issue(s).`);
out('');
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main(): void {
const domains = getDomainDirs();
if (domains.length === 0) {
logError('No matching domain directories found.');
process.exit(2);
}
const allIssues: Issue[] = [];
let filesScanned = 0;
for (const domain of domains) {
const files = getLocaleFiles(domain);
for (const filePath of files) {
filesScanned++;
let data: unknown;
try {
data = JSON.parse(readFileSync(filePath, 'utf-8'));
} catch {
allIssues.push({
severity: 'CRITICAL',
domain,
file: basename(filePath),
message: 'Invalid JSON: failed to parse',
});
continue;
}
allIssues.push(
...checkBrandIdentity(domain, filePath, data),
...checkCrossDomainContamination(domain, filePath, data),
...checkCompleteness(domain, filePath, data),
...checkTerminology(domain, filePath, data),
...checkEconomicAccuracy(domain, filePath, data),
);
}
}
printReport(domains, filesScanned, allIssues);
const hasCritical = allIssues.some((i) => i.severity === 'CRITICAL');
process.exit(hasCritical ? 1 : 0);
}
main();