393 lines
12 KiB
TypeScript
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();
|