@rtl-first/audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @rtl-first/audit
2
+
3
+ Scan any project for RTL readiness across the [five layers of RTL support](../../docs/for-contributors/methodology.md).
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx @rtl-first/audit ./my-project
9
+ ```
10
+
11
+ ## What it checks
12
+
13
+ | Layer | What it scans | How |
14
+ |-------|--------------|-----|
15
+ | **1 — Text engine** | Rich-text editors (ProseMirror, Slate, etc.) | Checks package.json dependencies |
16
+ | **2 — Direction logic** | `dir="rtl"`, DirectionProvider | Scans .ts/.tsx/.html files |
17
+ | **3 — CSS layout** | Physical CSS properties | Counts margin-left, padding-right, etc. |
18
+ | **4 — Translations** | ar.json existence and completeness | Compares with en.json |
19
+ | **5 — Hardcoded text** | English strings in JSX | Scans .tsx/.jsx files |
20
+
21
+ ## Output
22
+
23
+ ```
24
+ RTL Audit Report — my-project
25
+ ═══════════════════════════════════════
26
+
27
+ Layer 1 — Text engine ✅ No rich-text editor detected
28
+ Layer 2 — Direction logic ❌ No dir="rtl" on document root
29
+ Layer 3 — CSS layout ⚠️ 423 files use physical properties
30
+ Layer 4 — Translations ⚠️ ar.json not found (en.json: 1,660 keys)
31
+ Layer 5 — Hardcoded text ❌ 89 hardcoded English strings in JSX
32
+
33
+ RTL Readiness Score: 12/100
34
+ ```
35
+
36
+ ## Output formats
37
+
38
+ ```bash
39
+ npx @rtl-first/audit ./project # Terminal (default)
40
+ npx @rtl-first/audit ./project --format json # JSON (for CI/CD)
41
+ npx @rtl-first/audit ./project --format markdown # Markdown (for issues)
42
+ ```
43
+
44
+ ## Zero dependencies
45
+
46
+ This tool has no external dependencies. It uses only Node.js built-in modules.
47
+
48
+ ## License
49
+
50
+ MIT
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@rtl-first/audit",
3
+ "version": "0.1.0",
4
+ "description": "Scan any project for RTL readiness across all 5 layers",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "rtl-audit": "src/cli.js"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "test": "node --test src/**/*.test.js",
12
+ "start": "node src/cli.js"
13
+ },
14
+ "keywords": [
15
+ "rtl",
16
+ "right-to-left",
17
+ "arabic",
18
+ "hebrew",
19
+ "i18n",
20
+ "internationalization",
21
+ "audit",
22
+ "accessibility",
23
+ "css-logical-properties",
24
+ "bidi"
25
+ ],
26
+ "author": "Mohammad AlShammari <imohad>",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/imohad/rtl-first",
31
+ "directory": "packages/rtl-audit"
32
+ },
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "files": [
37
+ "src/"
38
+ ]
39
+ }
package/src/cli.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve } from 'path';
4
+ import { existsSync } from 'fs';
5
+ import { runAudit } from './index.js';
6
+ import { formatTerminal } from './formatters/terminal.js';
7
+ import { formatJSON } from './formatters/json.js';
8
+ import { formatMarkdown } from './formatters/markdown.js';
9
+
10
+ const args = process.argv.slice(2);
11
+
12
+ // Parse arguments
13
+ let targetPath = '.';
14
+ let format = 'terminal';
15
+
16
+ for (let i = 0; i < args.length; i++) {
17
+ if (args[i] === '--format' && args[i + 1]) {
18
+ format = args[i + 1];
19
+ i++;
20
+ } else if (args[i] === '--help' || args[i] === '-h') {
21
+ printHelp();
22
+ process.exit(0);
23
+ } else if (args[i] === '--version' || args[i] === '-v') {
24
+ console.log('0.1.0');
25
+ process.exit(0);
26
+ } else if (!args[i].startsWith('-')) {
27
+ targetPath = args[i];
28
+ }
29
+ }
30
+
31
+ const resolvedPath = resolve(targetPath);
32
+
33
+ if (!existsSync(resolvedPath)) {
34
+ console.error(`Error: path "${resolvedPath}" does not exist.`);
35
+ process.exit(1);
36
+ }
37
+
38
+ // Run audit
39
+ const report = runAudit(resolvedPath);
40
+
41
+ // Format output
42
+ const formatters = {
43
+ terminal: formatTerminal,
44
+ json: formatJSON,
45
+ markdown: formatMarkdown,
46
+ };
47
+
48
+ const formatter = formatters[format];
49
+ if (!formatter) {
50
+ console.error(`Unknown format: "${format}". Use: terminal, json, markdown`);
51
+ process.exit(1);
52
+ }
53
+
54
+ console.log(formatter(report));
55
+
56
+ function printHelp() {
57
+ console.log(`
58
+ @rtl-first/audit — Scan any project for RTL readiness
59
+
60
+ Usage:
61
+ rtl-audit [path] [options]
62
+
63
+ Arguments:
64
+ path Path to project directory (default: current dir)
65
+
66
+ Options:
67
+ --format <type> Output format: terminal, json, markdown (default: terminal)
68
+ -h, --help Show this help
69
+ -v, --version Show version
70
+
71
+ Examples:
72
+ rtl-audit ./my-project
73
+ rtl-audit ./my-project --format json
74
+ rtl-audit ./my-project --format markdown > rtl-report.md
75
+ `);
76
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Format audit report as JSON.
3
+ */
4
+ export function formatJSON(report) {
5
+ return JSON.stringify(report, null, 2);
6
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Format audit report as Markdown — ready to paste into a GitHub issue.
3
+ */
4
+ export function formatMarkdown(report) {
5
+ const lines = [];
6
+
7
+ lines.push(`## RTL Audit Report — ${report.projectName}`);
8
+ lines.push('');
9
+ lines.push(`*Generated by [rtl-first](https://github.com/imohad/rtl-first) v${report.version}*`);
10
+ lines.push('');
11
+ lines.push(`**RTL Readiness Score: ${report.score.total}/100 (${report.score.grade})**`);
12
+ lines.push('');
13
+
14
+ // Summary table
15
+ lines.push('| Layer | Status | Summary |');
16
+ lines.push('|-------|--------|---------|');
17
+
18
+ const layers = [
19
+ { key: 'layer1', name: 'Text engine', data: report.layers.layer1 },
20
+ { key: 'layer2', name: 'Direction logic', data: report.layers.layer2 },
21
+ { key: 'layer3', name: 'CSS layout', data: report.layers.layer3 },
22
+ { key: 'layer4', name: 'Translations', data: report.layers.layer4 },
23
+ { key: 'layer5', name: 'Hardcoded text', data: report.layers.layer5 },
24
+ ];
25
+
26
+ for (const { name, data } of layers) {
27
+ const icon = data.status === 'pass' ? '✅' : data.status === 'warn' ? '⚠️' : '❌';
28
+ lines.push(`| ${name} | ${icon} | ${data.summary} |`);
29
+ }
30
+
31
+ lines.push('');
32
+
33
+ // Details per layer
34
+ for (const { key, name, data } of layers) {
35
+ if (data.status === 'pass') continue;
36
+
37
+ lines.push(`### ${name}`);
38
+ lines.push('');
39
+ lines.push(data.detail);
40
+ lines.push('');
41
+
42
+ if (key === 'layer3' && data.breakdown) {
43
+ const entries = Object.entries(data.breakdown).slice(0, 8);
44
+ if (entries.length > 0) {
45
+ lines.push('| Property | Occurrences |');
46
+ lines.push('|----------|------------|');
47
+ for (const [prop, count] of entries) {
48
+ lines.push(`| \`${prop}\` | ${count} |`);
49
+ }
50
+ lines.push('');
51
+ }
52
+ }
53
+
54
+ if (key === 'layer4' && data.gaps) {
55
+ for (const gap of data.gaps.slice(0, 3)) {
56
+ if (gap.targetPath) {
57
+ lines.push(`- \`en.json\`: ${gap.sourceKeyCount} keys`);
58
+ lines.push(`- \`ar.json\`: ${gap.targetKeyCount} keys (${gap.missingKeys} missing)`);
59
+ } else {
60
+ lines.push(`- \`en.json\`: ${gap.sourceKeyCount} keys`);
61
+ lines.push(`- \`ar.json\`: **not found**`);
62
+ }
63
+ }
64
+ lines.push('');
65
+ }
66
+
67
+ if (key === 'layer5' && data.findings) {
68
+ lines.push('<details>');
69
+ lines.push(`<summary>Hardcoded strings (${data.count} found)</summary>`);
70
+ lines.push('');
71
+ for (const f of data.findings.slice(0, 20)) {
72
+ lines.push(`- \`${f.file}:${f.line}\` — "${f.text}"`);
73
+ }
74
+ if (data.count > 20) {
75
+ lines.push(`- ... and ${data.count - 20} more`);
76
+ }
77
+ lines.push('');
78
+ lines.push('</details>');
79
+ lines.push('');
80
+ }
81
+
82
+ if (data.fix) {
83
+ lines.push(`**Fix:** \`${data.fix}\``);
84
+ lines.push('');
85
+ }
86
+ }
87
+
88
+ // Priority
89
+ if (report.priority.length > 0) {
90
+ lines.push('### Recommended priority');
91
+ lines.push('');
92
+ for (let i = 0; i < report.priority.length; i++) {
93
+ lines.push(`${i + 1}. ${report.priority[i]}`);
94
+ }
95
+ lines.push('');
96
+ }
97
+
98
+ lines.push('---');
99
+ lines.push('');
100
+ lines.push('*Methodology: [The Five Layers of RTL Readiness](https://github.com/imohad/rtl-first/blob/main/docs/for-contributors/methodology.md)*');
101
+
102
+ return lines.join('\n');
103
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Format audit report for terminal output with colors and box drawing.
3
+ */
4
+ export function formatTerminal(report) {
5
+ const lines = [];
6
+ const W = 56;
7
+
8
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
9
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
10
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
11
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
12
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
13
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
14
+
15
+ const statusIcon = (status) => {
16
+ if (status === 'pass') return green('✅');
17
+ if (status === 'warn') return yellow('⚠️ ');
18
+ return red('❌');
19
+ };
20
+
21
+ const statusLabel = (status) => {
22
+ if (status === 'pass') return green('PASS');
23
+ if (status === 'warn') return yellow('WARN');
24
+ return red('FAIL');
25
+ };
26
+
27
+ // Header
28
+ lines.push('');
29
+ lines.push(bold(` RTL Audit Report — ${report.projectName}`));
30
+ lines.push(dim(` Generated by rtl-first v${report.version}`));
31
+ lines.push(dim(' ═'.repeat(28)));
32
+ lines.push('');
33
+
34
+ // Layer 1
35
+ const l1 = report.layers.layer1;
36
+ lines.push(` ${statusIcon(l1.status)} ${bold('Layer 1 — Text engine')} ${statusLabel(l1.status)}`);
37
+ lines.push(` ${l1.summary}`);
38
+ if (l1.engines && l1.engines.length > 0) {
39
+ for (const e of l1.engines) {
40
+ lines.push(dim(` → ${e.name}: ${e.bidiNote}`));
41
+ }
42
+ }
43
+ lines.push('');
44
+
45
+ // Layer 2
46
+ const l2 = report.layers.layer2;
47
+ lines.push(` ${statusIcon(l2.status)} ${bold('Layer 2 — Direction logic')} ${statusLabel(l2.status)}`);
48
+ lines.push(` ${l2.summary}`);
49
+ if (l2.fix) lines.push(dim(` → ${l2.fix}`));
50
+ if (l2.uiLibraries && l2.uiLibraries.length > 0) {
51
+ lines.push(dim(` UI libraries: ${l2.uiLibraries.join(', ')}`));
52
+ }
53
+ lines.push('');
54
+
55
+ // Layer 3
56
+ const l3 = report.layers.layer3;
57
+ lines.push(` ${statusIcon(l3.status)} ${bold('Layer 3 — CSS layout')} ${statusLabel(l3.status)}`);
58
+ lines.push(` ${l3.summary}`);
59
+ if (l3.breakdown && Object.keys(l3.breakdown).length > 0) {
60
+ const top5 = Object.entries(l3.breakdown).slice(0, 5);
61
+ for (const [prop, count] of top5) {
62
+ lines.push(dim(` ${prop}: ${count} occurrences`));
63
+ }
64
+ }
65
+ if (l3.fix) lines.push(dim(` → Fix: ${l3.fix}`));
66
+ lines.push('');
67
+
68
+ // Layer 4
69
+ const l4 = report.layers.layer4;
70
+ lines.push(` ${statusIcon(l4.status)} ${bold('Layer 4 — Translations')} ${statusLabel(l4.status)}`);
71
+ lines.push(` ${l4.summary}`);
72
+ if (l4.gaps && l4.gaps.length > 0) {
73
+ for (const gap of l4.gaps.slice(0, 3)) {
74
+ if (gap.targetPath) {
75
+ lines.push(dim(` en: ${gap.sourceKeyCount} keys → ar: ${gap.targetKeyCount} keys (${gap.missingKeys} missing)`));
76
+ } else {
77
+ lines.push(dim(` en: ${gap.sourceKeyCount} keys → ar.json: not found`));
78
+ }
79
+ }
80
+ }
81
+ if (l4.fix) lines.push(dim(` → Fix: ${l4.fix}`));
82
+ lines.push('');
83
+
84
+ // Layer 5
85
+ const l5 = report.layers.layer5;
86
+ lines.push(` ${statusIcon(l5.status)} ${bold('Layer 5 — Hardcoded text')} ${statusLabel(l5.status)}`);
87
+ lines.push(` ${l5.summary}`);
88
+ if (l5.findings && l5.findings.length > 0) {
89
+ for (const f of l5.findings.slice(0, 5)) {
90
+ lines.push(dim(` ${f.file}:${f.line} → "${f.text}"`));
91
+ }
92
+ if (l5.count > 5) {
93
+ lines.push(dim(` ... and ${l5.count - 5} more`));
94
+ }
95
+ }
96
+ lines.push('');
97
+
98
+ // Score
99
+ lines.push(dim(' ─'.repeat(28)));
100
+ lines.push('');
101
+
102
+ const grade = report.score.grade;
103
+ const gradeColor = grade === 'A' ? green : grade === 'B' ? green : grade === 'C' ? yellow : red;
104
+ lines.push(` ${bold('RTL Readiness Score:')} ${gradeColor(bold(`${report.score.total}/100`))} ${gradeColor(`(${grade})`)}`);
105
+ lines.push('');
106
+
107
+ // Score breakdown
108
+ lines.push(dim(` Layer 1: ${report.score.breakdown.layer1}/30 Layer 2: ${report.score.breakdown.layer2}/25 Layer 3: ${Math.round(report.score.breakdown.layer3)}/20`));
109
+ lines.push(dim(` Layer 4: ${Math.round(report.score.breakdown.layer4)}/15 Layer 5: ${Math.round(report.score.breakdown.layer5)}/10`));
110
+ lines.push('');
111
+
112
+ // Priority
113
+ if (report.priority.length > 0) {
114
+ lines.push(` ${bold('Priority order:')}`);
115
+ for (let i = 0; i < report.priority.length; i++) {
116
+ lines.push(cyan(` ${i + 1}. ${report.priority[i]}`));
117
+ }
118
+ lines.push('');
119
+ lines.push(dim(' Tip: Open an issue before submitting any PR.'));
120
+ lines.push(dim(' Guide: https://github.com/imohad/rtl-first'));
121
+ }
122
+
123
+ lines.push('');
124
+
125
+ return lines.join('\n');
126
+ }
package/src/index.js ADDED
@@ -0,0 +1,61 @@
1
+ import { basename } from 'path';
2
+ import { scanLayer1 } from './scanners/layer1-text-engine.js';
3
+ import { scanLayer2 } from './scanners/layer2-direction.js';
4
+ import { scanLayer3 } from './scanners/layer3-css.js';
5
+ import { scanLayer4 } from './scanners/layer4-translations.js';
6
+ import { scanLayer5 } from './scanners/layer5-hardcoded.js';
7
+ import { computeScore } from './score.js';
8
+
9
+ /**
10
+ * Run a full RTL audit on a project directory.
11
+ * @param {string} projectPath - Absolute path to the project root
12
+ * @returns {object} Complete audit report
13
+ */
14
+ export function runAudit(projectPath) {
15
+ const projectName = basename(projectPath);
16
+
17
+ const layer1 = scanLayer1(projectPath);
18
+ const layer2 = scanLayer2(projectPath);
19
+ const layer3 = scanLayer3(projectPath);
20
+ const layer4 = scanLayer4(projectPath);
21
+ const layer5 = scanLayer5(projectPath);
22
+
23
+ const layers = { layer1, layer2, layer3, layer4, layer5 };
24
+ const score = computeScore(layers);
25
+
26
+ return {
27
+ version: '0.1.0',
28
+ projectName,
29
+ projectPath,
30
+ timestamp: new Date().toISOString(),
31
+ score,
32
+ layers,
33
+ priority: computePriority(layers),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Determine which layers to fix first based on current state.
39
+ */
40
+ function computePriority(layers) {
41
+ const order = [];
42
+
43
+ // Layer 2 is almost always the first thing to fix
44
+ if (layers.layer2.status !== 'pass') order.push('Layer 2 — Direction logic');
45
+
46
+ // Layer 4 next — translations are safe and welcome
47
+ if (layers.layer4.status !== 'pass') order.push('Layer 4 — Translations');
48
+
49
+ // Layer 5 — hardcoded strings
50
+ if (layers.layer5.status !== 'pass') order.push('Layer 5 — Hardcoded text');
51
+
52
+ // Layer 3 — CSS, best done with codemod
53
+ if (layers.layer3.status !== 'pass') order.push('Layer 3 — CSS layout');
54
+
55
+ // Layer 1 — text engine, only if detected
56
+ if (layers.layer1.status === 'warn' || layers.layer1.status === 'fail') {
57
+ order.push('Layer 1 — Text engine (requires proposal)');
58
+ }
59
+
60
+ return order;
61
+ }
@@ -0,0 +1,82 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { findFiles } from '../utils/walker.js';
4
+
5
+ const TEXT_ENGINES = {
6
+ 'prosemirror-model': { name: 'ProseMirror', bidiNote: 'Requires explicit BiDi configuration' },
7
+ 'prosemirror-view': { name: 'ProseMirror', bidiNote: 'Requires explicit BiDi configuration' },
8
+ '@tiptap/core': { name: 'TipTap (ProseMirror)', bidiNote: 'Requires explicit BiDi configuration' },
9
+ 'slate': { name: 'Slate', bidiNote: 'BiDi support depends on implementation' },
10
+ 'slate-react': { name: 'Slate', bidiNote: 'BiDi support depends on implementation' },
11
+ '@codemirror/state': { name: 'CodeMirror 6', bidiNote: 'Has built-in BiDi support' },
12
+ 'codemirror': { name: 'CodeMirror', bidiNote: 'Check version — v6 has better BiDi' },
13
+ 'quill': { name: 'Quill', bidiNote: 'Limited BiDi support' },
14
+ 'monaco-editor': { name: 'Monaco', bidiNote: 'Good BiDi support (VS Code editor)' },
15
+ '@blocksuite/block-std': { name: 'BlockSuite', bidiNote: 'ProseMirror-based — BiDi not configured by default' },
16
+ '@blocksuite/blocks': { name: 'BlockSuite', bidiNote: 'ProseMirror-based — BiDi not configured by default' },
17
+ };
18
+
19
+ /**
20
+ * Scan for rich-text editor engines in the project.
21
+ */
22
+ export function scanLayer1(projectPath) {
23
+ const packageFiles = findFiles(projectPath, 'package.json', 5);
24
+ const detected = new Map(); // name → { packages, bidiNote }
25
+
26
+ for (const pkgPath of packageFiles) {
27
+ try {
28
+ const content = JSON.parse(readFileSync(pkgPath, 'utf-8'));
29
+ const allDeps = {
30
+ ...content.dependencies,
31
+ ...content.devDependencies,
32
+ ...content.peerDependencies,
33
+ };
34
+
35
+ for (const [pkg, info] of Object.entries(TEXT_ENGINES)) {
36
+ if (allDeps[pkg]) {
37
+ if (!detected.has(info.name)) {
38
+ detected.set(info.name, { packages: [], bidiNote: info.bidiNote });
39
+ }
40
+ detected.get(info.name).packages.push(pkg);
41
+ }
42
+ }
43
+ } catch {
44
+ // Skip invalid package.json files
45
+ }
46
+ }
47
+
48
+ if (detected.size === 0) {
49
+ return {
50
+ status: 'pass',
51
+ summary: 'No rich-text editor detected',
52
+ detail: 'This project does not appear to use a complex text editor. RTL support can start from Layer 2.',
53
+ engines: [],
54
+ };
55
+ }
56
+
57
+ const engines = [];
58
+ for (const [name, info] of detected) {
59
+ engines.push({
60
+ name,
61
+ packages: [...new Set(info.packages)],
62
+ bidiNote: info.bidiNote,
63
+ });
64
+ }
65
+
66
+ // Check if any detected engines have known BiDi issues
67
+ const hasRiskyEngine = engines.some(e =>
68
+ e.name.includes('ProseMirror') ||
69
+ e.name.includes('BlockSuite') ||
70
+ e.name.includes('Slate') ||
71
+ e.name.includes('Quill')
72
+ );
73
+
74
+ return {
75
+ status: hasRiskyEngine ? 'warn' : 'pass',
76
+ summary: `Detected: ${engines.map(e => e.name).join(', ')}`,
77
+ detail: hasRiskyEngine
78
+ ? 'This project uses a text editor that may need BiDi configuration. Check Layer 1 before proceeding with surface-level RTL changes.'
79
+ : 'Detected text editors appear to have reasonable BiDi support.',
80
+ engines,
81
+ };
82
+ }
@@ -0,0 +1,108 @@
1
+ import { readFileSync } from 'fs';
2
+ import { walkFiles, findFiles } from '../utils/walker.js';
3
+
4
+ const DIRECTION_PATTERNS = [
5
+ { pattern: /dir\s*=\s*["']rtl["']/i, label: 'dir="rtl" attribute' },
6
+ { pattern: /\.dir\s*=\s*["']rtl["']/i, label: 'JavaScript dir assignment' },
7
+ { pattern: /documentElement\.dir/i, label: 'document.documentElement.dir' },
8
+ { pattern: /applyDocumentLanguage/i, label: 'applyDocumentLanguage()' },
9
+ { pattern: /DirectionProvider/i, label: 'DirectionProvider' },
10
+ { pattern: /useDirection/i, label: 'useDirection hook' },
11
+ { pattern: /direction:\s*['"]rtl['"]/, label: 'CSS direction: rtl' },
12
+ { pattern: /i18n.*dir/i, label: 'i18n direction config' },
13
+ ];
14
+
15
+ // UI libraries that need DirectionProvider
16
+ const UI_LIBS = {
17
+ '@radix-ui/react-direction': 'Radix UI (has DirectionProvider)',
18
+ '@radix-ui/themes': 'Radix UI Themes',
19
+ '@chakra-ui/react': 'Chakra UI (has direction support)',
20
+ '@mantine/core': 'Mantine (has direction support)',
21
+ 'antd': 'Ant Design (has ConfigProvider direction)',
22
+ };
23
+
24
+ /**
25
+ * Scan for RTL direction logic in the project.
26
+ */
27
+ export function scanLayer2(projectPath) {
28
+ const codeFiles = walkFiles(projectPath, ['.ts', '.tsx', '.js', '.jsx', '.html', '.vue', '.svelte']);
29
+ const found = [];
30
+ const missing = [];
31
+
32
+ // Check for direction patterns in code
33
+ for (const filePath of codeFiles) {
34
+ try {
35
+ const content = readFileSync(filePath, 'utf-8');
36
+ for (const { pattern, label } of DIRECTION_PATTERNS) {
37
+ if (pattern.test(content)) {
38
+ found.push({ file: filePath, pattern: label });
39
+ }
40
+ }
41
+ } catch {
42
+ // Skip unreadable files
43
+ }
44
+ }
45
+
46
+ // Check for UI libraries that need direction configuration
47
+ const detectedLibs = [];
48
+ const packageFiles = findFiles(projectPath, 'package.json', 5);
49
+
50
+ for (const pkgPath of packageFiles) {
51
+ try {
52
+ const content = JSON.parse(readFileSync(pkgPath, 'utf-8'));
53
+ const allDeps = { ...content.dependencies, ...content.devDependencies };
54
+
55
+ for (const [pkg, label] of Object.entries(UI_LIBS)) {
56
+ if (allDeps[pkg]) detectedLibs.push(label);
57
+ }
58
+ } catch {
59
+ // Skip invalid files
60
+ }
61
+ }
62
+
63
+ // Determine status
64
+ const hasDirectionLogic = found.length > 0;
65
+ const hasDirectionProvider = found.some(f =>
66
+ f.pattern.includes('DirectionProvider') || f.pattern.includes('direction config')
67
+ );
68
+ const needsDirectionProvider = detectedLibs.length > 0 && !hasDirectionProvider;
69
+
70
+ if (hasDirectionLogic && !needsDirectionProvider) {
71
+ return {
72
+ status: 'pass',
73
+ summary: `Direction logic found (${found.length} patterns)`,
74
+ detail: 'This project has direction detection and configuration.',
75
+ found: dedup(found),
76
+ uiLibraries: detectedLibs,
77
+ };
78
+ }
79
+
80
+ if (hasDirectionLogic && needsDirectionProvider) {
81
+ return {
82
+ status: 'warn',
83
+ summary: 'Direction logic exists but may be incomplete',
84
+ detail: `Found direction patterns but ${detectedLibs.join(', ')} may need DirectionProvider wrapping.`,
85
+ found: dedup(found),
86
+ uiLibraries: detectedLibs,
87
+ };
88
+ }
89
+
90
+ return {
91
+ status: 'fail',
92
+ summary: 'No direction logic found',
93
+ detail: 'No dir="rtl" detection, no DirectionProvider, no language-aware direction switching.',
94
+ found: [],
95
+ uiLibraries: detectedLibs,
96
+ fix: 'Add document.documentElement.dir = "rtl" when RTL locale is active. If using Radix UI, wrap app in DirectionProvider.',
97
+ };
98
+ }
99
+
100
+ function dedup(items) {
101
+ const seen = new Set();
102
+ return items.filter(item => {
103
+ const key = item.pattern;
104
+ if (seen.has(key)) return false;
105
+ seen.add(key);
106
+ return true;
107
+ });
108
+ }
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from 'fs';
2
+ import { walkFiles } from '../utils/walker.js';
3
+
4
+ const PHYSICAL_PROPERTIES = [
5
+ { pattern: /margin-left\s*:/g, replacement: 'margin-inline-start', label: 'margin-left' },
6
+ { pattern: /margin-right\s*:/g, replacement: 'margin-inline-end', label: 'margin-right' },
7
+ { pattern: /padding-left\s*:/g, replacement: 'padding-inline-start', label: 'padding-left' },
8
+ { pattern: /padding-right\s*:/g, replacement: 'padding-inline-end', label: 'padding-right' },
9
+ { pattern: /border-left\s*:/g, replacement: 'border-inline-start', label: 'border-left' },
10
+ { pattern: /border-right\s*:/g, replacement: 'border-inline-end', label: 'border-right' },
11
+ { pattern: /border-left-width\s*:/g, replacement: 'border-inline-start-width', label: 'border-left-width' },
12
+ { pattern: /border-right-width\s*:/g, replacement: 'border-inline-end-width', label: 'border-right-width' },
13
+ { pattern: /(?<![a-zA-Z-])left\s*:\s*(?!.*(?:calc|var|env))/g, replacement: 'inset-inline-start', label: 'left (positional)' },
14
+ { pattern: /(?<![a-zA-Z-])right\s*:\s*(?!.*(?:calc|var|env))/g, replacement: 'inset-inline-end', label: 'right (positional)' },
15
+ { pattern: /text-align\s*:\s*left/g, replacement: 'text-align: start', label: 'text-align: left' },
16
+ { pattern: /text-align\s*:\s*right/g, replacement: 'text-align: end', label: 'text-align: right' },
17
+ ];
18
+
19
+ // camelCase variants for CSS-in-JS
20
+ const CAMELCASE_PROPERTIES = [
21
+ { pattern: /marginLeft\s*:/g, label: 'marginLeft (JS)' },
22
+ { pattern: /marginRight\s*:/g, label: 'marginRight (JS)' },
23
+ { pattern: /paddingLeft\s*:/g, label: 'paddingLeft (JS)' },
24
+ { pattern: /paddingRight\s*:/g, label: 'paddingRight (JS)' },
25
+ { pattern: /borderLeft\s*:/g, label: 'borderLeft (JS)' },
26
+ { pattern: /borderRight\s*:/g, label: 'borderRight (JS)' },
27
+ ];
28
+
29
+ /**
30
+ * Scan for physical CSS properties that should be logical.
31
+ */
32
+ export function scanLayer3(projectPath) {
33
+ const cssFiles = walkFiles(projectPath, ['.css', '.scss', '.less', '.sass']);
34
+ const codeFiles = walkFiles(projectPath, ['.ts', '.tsx', '.js', '.jsx', '.vue', '.svelte']);
35
+ const allFiles = [...cssFiles, ...codeFiles];
36
+
37
+ const counts = {};
38
+ let totalOccurrences = 0;
39
+ let filesWithIssues = 0;
40
+ const allProperties = [...PHYSICAL_PROPERTIES, ...CAMELCASE_PROPERTIES];
41
+
42
+ for (const filePath of allFiles) {
43
+ let fileHasIssues = false;
44
+
45
+ try {
46
+ const content = readFileSync(filePath, 'utf-8');
47
+
48
+ for (const { pattern, label } of allProperties) {
49
+ // Reset regex lastIndex
50
+ pattern.lastIndex = 0;
51
+ const matches = content.match(pattern);
52
+ if (matches && matches.length > 0) {
53
+ counts[label] = (counts[label] || 0) + matches.length;
54
+ totalOccurrences += matches.length;
55
+ fileHasIssues = true;
56
+ }
57
+ }
58
+ } catch {
59
+ // Skip unreadable files
60
+ }
61
+
62
+ if (fileHasIssues) filesWithIssues++;
63
+ }
64
+
65
+ if (totalOccurrences === 0) {
66
+ return {
67
+ status: 'pass',
68
+ summary: 'No physical CSS properties found',
69
+ detail: 'This project uses logical CSS properties or has no directional styling.',
70
+ filesScanned: allFiles.length,
71
+ filesWithIssues: 0,
72
+ totalOccurrences: 0,
73
+ breakdown: {},
74
+ };
75
+ }
76
+
77
+ // Sort by count descending
78
+ const sorted = Object.entries(counts)
79
+ .sort((a, b) => b[1] - a[1])
80
+ .reduce((obj, [k, v]) => ({ ...obj, [k]: v }), {});
81
+
82
+ return {
83
+ status: totalOccurrences > 50 ? 'fail' : 'warn',
84
+ summary: `${filesWithIssues} files use physical CSS properties (${totalOccurrences} occurrences)`,
85
+ detail: `Run @rtl-first/codemod to convert physical properties to logical properties.`,
86
+ filesScanned: allFiles.length,
87
+ filesWithIssues,
88
+ totalOccurrences,
89
+ breakdown: sorted,
90
+ fix: 'npx @rtl-first/codemod --dry-run ./src',
91
+ };
92
+ }
@@ -0,0 +1,276 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'fs';
2
+ import { join, basename } from 'path';
3
+ import { findFiles } from '../utils/walker.js';
4
+
5
+ const RTL_LOCALES = ['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi'];
6
+ const I18N_DIR_NAMES = ['i18n', 'locales', 'lang', 'languages', 'translations', 'resources', 'messages'];
7
+
8
+ /**
9
+ * Scan for translation file completeness.
10
+ * Supports two patterns:
11
+ * 1. Flat files: locales/en.json, locales/ar.json
12
+ * 2. Locale folders: i18n/en-US/*.json, i18n/ar-TN/*.json (Dify, Next.js style)
13
+ */
14
+ export function scanLayer4(projectPath) {
15
+ // Strategy 1: Look for locale folder pattern (en-US/, ar-TN/, etc.)
16
+ const folderResult = scanLocaleFolders(projectPath);
17
+ if (folderResult) return folderResult;
18
+
19
+ // Strategy 2: Look for flat file pattern (en.json, ar.json)
20
+ const flatResult = scanFlatFiles(projectPath);
21
+ if (flatResult) return flatResult;
22
+
23
+ return {
24
+ status: 'warn',
25
+ summary: 'No i18n files detected',
26
+ detail: 'Could not find locale files. The project may not use i18n, or may use a non-standard file structure.',
27
+ sourceFiles: [],
28
+ targetFiles: [],
29
+ gaps: [],
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Strategy 1: Locale folders like i18n/en-US/*.json, i18n/ar-TN/*.json
35
+ */
36
+ function scanLocaleFolders(projectPath) {
37
+ // Find directories that contain locale subdirectories
38
+ for (const dirName of I18N_DIR_NAMES) {
39
+ const candidates = findI18nDirs(projectPath, dirName, 5);
40
+
41
+ for (const i18nDir of candidates) {
42
+ let entries;
43
+ try { entries = readdirSync(i18nDir, { withFileTypes: true }); } catch { continue; }
44
+
45
+ const subdirs = entries.filter(e => e.isDirectory()).map(e => e.name);
46
+
47
+ // Find English source directory
48
+ const enDir = subdirs.find(d => d.startsWith('en'));
49
+ if (!enDir) continue;
50
+
51
+ // Find Arabic/RTL target directory
52
+ const arDir = subdirs.find(d => RTL_LOCALES.some(loc => d.startsWith(loc)));
53
+
54
+ const enPath = join(i18nDir, enDir);
55
+ const arPath = arDir ? join(i18nDir, arDir) : null;
56
+
57
+ // Count keys across all JSON files in the locale folder
58
+ const enKeys = countKeysInFolder(enPath);
59
+ const arKeys = arPath ? countKeysInFolder(arPath) : { total: 0, files: [] };
60
+
61
+ if (enKeys.total === 0) continue;
62
+
63
+ // Build per-file gap report
64
+ const gaps = [];
65
+ for (const fileInfo of enKeys.files) {
66
+ const arFile = arKeys.files.find(f => f.name === fileInfo.name);
67
+ const arCount = arFile ? arFile.count : 0;
68
+ gaps.push({
69
+ sourcePath: fileInfo.path,
70
+ targetPath: arFile ? arFile.path : null,
71
+ sourceKeyCount: fileInfo.count,
72
+ targetKeyCount: arCount,
73
+ missingKeys: fileInfo.count - arCount,
74
+ });
75
+ }
76
+
77
+ const totalMissing = enKeys.total - arKeys.total;
78
+ const coverage = ((arKeys.total / enKeys.total) * 100).toFixed(1);
79
+
80
+ if (arPath && totalMissing === 0) {
81
+ return {
82
+ status: 'pass',
83
+ summary: `Arabic translations complete — ${arDir} (${arKeys.total} keys)`,
84
+ detail: `${enDir} and ${arDir} have matching key counts across ${enKeys.files.length} files.`,
85
+ sourceLocale: enDir,
86
+ targetLocale: arDir,
87
+ sourceFiles: enKeys.files.map(f => f.path),
88
+ targetFiles: arKeys.files.map(f => f.path),
89
+ gaps,
90
+ };
91
+ }
92
+
93
+ if (arPath && totalMissing > 0) {
94
+ return {
95
+ status: 'warn',
96
+ summary: `Arabic ${coverage}% complete — ${totalMissing} keys missing (${arDir})`,
97
+ detail: `${enDir}: ${enKeys.total} keys → ${arDir}: ${arKeys.total} keys across ${enKeys.files.length} files.`,
98
+ sourceLocale: enDir,
99
+ targetLocale: arDir,
100
+ sourceFiles: enKeys.files.map(f => f.path),
101
+ targetFiles: arKeys.files.map(f => f.path),
102
+ gaps: gaps.filter(g => g.missingKeys > 0),
103
+ };
104
+ }
105
+
106
+ return {
107
+ status: 'fail',
108
+ summary: `No Arabic translation folder (${enDir} has ${enKeys.total} keys)`,
109
+ detail: `Found ${enDir} with ${enKeys.files.length} locale files but no Arabic locale folder.`,
110
+ sourceLocale: enDir,
111
+ targetLocale: null,
112
+ sourceFiles: enKeys.files.map(f => f.path),
113
+ targetFiles: [],
114
+ gaps,
115
+ fix: `Create ${i18nDir}/ar/ directory with translated JSON files matching ${enDir}/.`,
116
+ };
117
+ }
118
+ }
119
+
120
+ return null; // No locale folders found
121
+ }
122
+
123
+ /**
124
+ * Strategy 2: Flat files like locales/en.json, locales/ar.json
125
+ */
126
+ function scanFlatFiles(projectPath) {
127
+ const enFiles = findFiles(projectPath, 'en.json', 8);
128
+ if (enFiles.length === 0) return null;
129
+
130
+ const results = [];
131
+
132
+ for (const enPath of enFiles) {
133
+ try {
134
+ const enContent = JSON.parse(readFileSync(enPath, 'utf-8'));
135
+ const enKeys = flattenKeys(enContent);
136
+
137
+ // Look for ar.json in the same directory
138
+ const arPath = enPath.replace(/\/en\.json$/, '/ar.json');
139
+ let arKeys = [];
140
+ let arExists = false;
141
+
142
+ try {
143
+ const arContent = JSON.parse(readFileSync(arPath, 'utf-8'));
144
+ arKeys = flattenKeys(arContent);
145
+ arExists = true;
146
+ } catch {
147
+ // ar.json doesn't exist
148
+ }
149
+
150
+ results.push({
151
+ sourcePath: enPath,
152
+ targetPath: arExists ? arPath : null,
153
+ sourceKeyCount: enKeys.length,
154
+ targetKeyCount: arKeys.length,
155
+ missingKeys: enKeys.length - arKeys.length,
156
+ });
157
+ } catch {
158
+ // Skip unparseable JSON
159
+ }
160
+ }
161
+
162
+ if (results.length === 0) return null;
163
+
164
+ const totalSource = results.reduce((s, r) => s + r.sourceKeyCount, 0);
165
+ const totalTarget = results.reduce((s, r) => s + r.targetKeyCount, 0);
166
+ const totalMissing = totalSource - totalTarget;
167
+ const hasArabic = results.some(r => r.targetPath !== null);
168
+
169
+ if (hasArabic && totalMissing === 0) {
170
+ return {
171
+ status: 'pass',
172
+ summary: `Arabic translations complete (${totalTarget} keys)`,
173
+ detail: 'ar.json exists and covers all keys from en.json.',
174
+ gaps: results,
175
+ };
176
+ }
177
+
178
+ if (hasArabic && totalMissing > 0) {
179
+ const coverage = ((totalTarget / totalSource) * 100).toFixed(1);
180
+ return {
181
+ status: 'warn',
182
+ summary: `Arabic ${coverage}% complete — ${totalMissing} keys missing`,
183
+ detail: `en.json: ${totalSource} keys → ar.json: ${totalTarget} keys.`,
184
+ gaps: results,
185
+ };
186
+ }
187
+
188
+ return {
189
+ status: 'fail',
190
+ summary: `No Arabic translation found (en.json has ${totalSource} keys)`,
191
+ detail: 'ar.json does not exist.',
192
+ gaps: results,
193
+ fix: 'Create ar.json in the same directory as en.json with translated strings.',
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Find i18n directories by name within the project.
199
+ */
200
+ function findI18nDirs(dir, targetName, maxDepth, depth = 0) {
201
+ const results = [];
202
+ if (depth > maxDepth) return results;
203
+
204
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'vendor', 'coverage']);
205
+
206
+ try {
207
+ const entries = readdirSync(dir, { withFileTypes: true });
208
+ for (const entry of entries) {
209
+ if (!entry.isDirectory()) continue;
210
+ if (SKIP.has(entry.name)) continue;
211
+ if (entry.name === targetName) {
212
+ results.push(join(dir, entry.name));
213
+ }
214
+ results.push(...findI18nDirs(join(dir, entry.name), targetName, maxDepth, depth + 1));
215
+ }
216
+ } catch {
217
+ // Skip unreadable dirs
218
+ }
219
+
220
+ return results;
221
+ }
222
+
223
+ /**
224
+ * Count translation keys across all JSON files in a locale folder.
225
+ */
226
+ function countKeysInFolder(folderPath) {
227
+ const files = [];
228
+ let total = 0;
229
+
230
+ try {
231
+ for (const entry of readdirSync(folderPath)) {
232
+ if (!entry.endsWith('.json')) continue;
233
+ const filePath = join(folderPath, entry);
234
+ try {
235
+ const content = JSON.parse(readFileSync(filePath, 'utf-8'));
236
+ const count = countKeysDeep(content);
237
+ files.push({ name: entry, path: filePath, count });
238
+ total += count;
239
+ } catch {
240
+ // Skip invalid JSON
241
+ }
242
+ }
243
+ } catch {
244
+ // Folder unreadable
245
+ }
246
+
247
+ return { total, files };
248
+ }
249
+
250
+ function countKeysDeep(obj) {
251
+ let count = 0;
252
+ for (const value of Object.values(obj)) {
253
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
254
+ count += countKeysDeep(value);
255
+ } else {
256
+ count++;
257
+ }
258
+ }
259
+ return count;
260
+ }
261
+
262
+ /**
263
+ * Flatten a nested JSON object into dot-notation keys.
264
+ */
265
+ function flattenKeys(obj, prefix = '') {
266
+ const keys = [];
267
+ for (const [key, value] of Object.entries(obj)) {
268
+ const fullKey = prefix ? `${prefix}.${key}` : key;
269
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
270
+ keys.push(...flattenKeys(value, fullKey));
271
+ } else {
272
+ keys.push(fullKey);
273
+ }
274
+ }
275
+ return keys;
276
+ }
@@ -0,0 +1,162 @@
1
+ import { readFileSync } from 'fs';
2
+ import { relative } from 'path';
3
+ import { walkFiles } from '../utils/walker.js';
4
+
5
+ // Strings that are NOT hardcoded user-facing text
6
+ const IGNORE_PATTERNS = [
7
+ /^[a-z][a-zA-Z0-9]*$/, // camelCase identifiers
8
+ /^[A-Z][A-Z0-9_]*$/, // CONSTANT_CASE
9
+ /^[a-z-]+$/, // kebab-case (CSS classes, data attributes)
10
+ /^(https?:\/\/|mailto:|tel:)/, // URLs
11
+ /^[./#]/, // Paths
12
+ /^\d+/, // Numbers
13
+ /^(true|false|null|undefined)$/, // Literals
14
+ /^(div|span|button|input|form|label|img|svg|path|a|p|h[1-6])$/, // HTML tags
15
+ /^(GET|POST|PUT|DELETE|PATCH)$/, // HTTP methods
16
+ /^(string|number|boolean|object|function)$/, // Types
17
+ /^(px|rem|em|vh|vw|%|auto|none|inherit|flex|grid|block|inline)$/, // CSS values
18
+ /^(onClick|onChange|onSubmit|className|style|key|ref|id|type|name|value|placeholder)$/, // React props
19
+ /^data-/, // Data attributes
20
+ /^aria-/, // ARIA attributes
21
+ /^[{}<>()\[\]|&=+\-*\/\\;:,.'"`~!@#$%^?]+$/, // Symbols only
22
+ ];
23
+
24
+ // Minimum word count to consider a string as user-facing text
25
+ const MIN_WORDS = 2;
26
+ const MAX_RESULTS = 50;
27
+
28
+ /**
29
+ * Scan JSX/TSX files for hardcoded English strings.
30
+ */
31
+ export function scanLayer5(projectPath) {
32
+ const files = walkFiles(projectPath, ['.tsx', '.jsx']);
33
+ const findings = [];
34
+
35
+ for (const filePath of files) {
36
+ try {
37
+ const content = readFileSync(filePath, 'utf-8');
38
+ const lines = content.split('\n');
39
+
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const line = lines[i];
42
+
43
+ // Skip imports, comments, and type definitions
44
+ if (isSkippableLine(line)) continue;
45
+
46
+ // Find string literals in JSX context
47
+ const strings = extractJSXStrings(line);
48
+
49
+ for (const str of strings) {
50
+ if (isLikelyUserFacing(str)) {
51
+ findings.push({
52
+ file: relative(projectPath, filePath),
53
+ line: i + 1,
54
+ text: str.length > 60 ? str.substring(0, 60) + '...' : str,
55
+ });
56
+ }
57
+ }
58
+ }
59
+ } catch {
60
+ // Skip unreadable files
61
+ }
62
+
63
+ // Cap results for performance
64
+ if (findings.length >= MAX_RESULTS * 2) break;
65
+ }
66
+
67
+ const limited = findings.slice(0, MAX_RESULTS);
68
+
69
+ if (findings.length === 0) {
70
+ return {
71
+ status: 'pass',
72
+ summary: 'No hardcoded English strings detected in JSX',
73
+ detail: 'All user-facing text appears to use i18n keys.',
74
+ count: 0,
75
+ findings: [],
76
+ };
77
+ }
78
+
79
+ return {
80
+ status: findings.length > 20 ? 'fail' : 'warn',
81
+ summary: `${findings.length} hardcoded English strings found in JSX`,
82
+ detail: 'These strings should be replaced with i18n translation keys.',
83
+ count: findings.length,
84
+ findings: limited,
85
+ fix: 'Replace hardcoded strings with t() or equivalent i18n function calls.',
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Extract string literals that appear in JSX context (between > and <, or in attributes).
91
+ */
92
+ function extractJSXStrings(line) {
93
+ const strings = [];
94
+
95
+ // Match strings between JSX tags: >Some text<
96
+ const jsxTextPattern = />\s*([A-Z][^<>{]*?)\s*</g;
97
+ let match;
98
+ while ((match = jsxTextPattern.exec(line)) !== null) {
99
+ const text = match[1].trim();
100
+ if (text.length > 0) strings.push(text);
101
+ }
102
+
103
+ // Match string literals in JSX: {"Some text"} or {'Some text'}
104
+ const jsxExprPattern = /\{["']([A-Z][^"']*?)["']\}/g;
105
+ while ((match = jsxExprPattern.exec(line)) !== null) {
106
+ strings.push(match[1].trim());
107
+ }
108
+
109
+ // Match title="Some text", placeholder="Some text", alt="Some text" etc.
110
+ const attrPattern = /(?:title|placeholder|alt|label|aria-label|description)\s*=\s*["']([A-Z][^"']*?)["']/g;
111
+ while ((match = attrPattern.exec(line)) !== null) {
112
+ strings.push(match[1].trim());
113
+ }
114
+
115
+ return strings;
116
+ }
117
+
118
+ /**
119
+ * Check if a string looks like user-facing text that should be translated.
120
+ */
121
+ function isLikelyUserFacing(str) {
122
+ if (!str || str.length < 3) return false;
123
+
124
+ // Check against ignore patterns
125
+ for (const pattern of IGNORE_PATTERNS) {
126
+ if (pattern.test(str)) return false;
127
+ }
128
+
129
+ // Count words
130
+ const words = str.split(/\s+/).filter(w => w.length > 0);
131
+ if (words.length < MIN_WORDS) return false;
132
+
133
+ // Must contain at least one ASCII letter
134
+ if (!/[a-zA-Z]/.test(str)) return false;
135
+
136
+ // Must start with uppercase (user-facing text convention)
137
+ if (!/^[A-Z]/.test(str)) return false;
138
+
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Check if a line should be skipped entirely.
144
+ */
145
+ function isSkippableLine(line) {
146
+ const trimmed = line.trim();
147
+ return (
148
+ trimmed.startsWith('import ') ||
149
+ trimmed.startsWith('export type') ||
150
+ trimmed.startsWith('export interface') ||
151
+ trimmed.startsWith('//') ||
152
+ trimmed.startsWith('*') ||
153
+ trimmed.startsWith('/*') ||
154
+ trimmed.startsWith('console.') ||
155
+ trimmed.startsWith('throw ') ||
156
+ trimmed.includes('.test(') ||
157
+ trimmed.includes('.spec(') ||
158
+ trimmed.includes('describe(') ||
159
+ trimmed.includes('it(') ||
160
+ trimmed.includes('expect(')
161
+ );
162
+ }
package/src/score.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Compute RTL Readiness Score (0-100) from layer results.
3
+ *
4
+ * Weight distribution:
5
+ * Layer 1 — Text engine: 30 points
6
+ * Layer 2 — Direction logic: 25 points
7
+ * Layer 3 — CSS layout: 20 points
8
+ * Layer 4 — Translations: 15 points
9
+ * Layer 5 — Hardcoded text: 10 points
10
+ *
11
+ * Total: 100 points
12
+ */
13
+ export function computeScore(layers) {
14
+ const scores = {
15
+ layer1: scoreLayer1(layers.layer1),
16
+ layer2: scoreLayer2(layers.layer2),
17
+ layer3: scoreLayer3(layers.layer3),
18
+ layer4: scoreLayer4(layers.layer4),
19
+ layer5: scoreLayer5(layers.layer5),
20
+ };
21
+
22
+ const total = scores.layer1 + scores.layer2 + scores.layer3 + scores.layer4 + scores.layer5;
23
+
24
+ return {
25
+ total: Math.round(total),
26
+ breakdown: scores,
27
+ grade: getGrade(total),
28
+ };
29
+ }
30
+
31
+ function scoreLayer1(result) {
32
+ const MAX = 30;
33
+ if (result.status === 'pass' && result.engines.length === 0) return MAX; // No editor = no issue
34
+ if (result.status === 'pass') return MAX; // Editor with good BiDi
35
+ if (result.status === 'warn') return MAX * 0.4; // Editor with unknown BiDi
36
+ return 0; // Editor with known BiDi issues
37
+ }
38
+
39
+ function scoreLayer2(result) {
40
+ const MAX = 25;
41
+ if (result.status === 'pass') return MAX;
42
+ if (result.status === 'warn') return MAX * 0.5;
43
+ return 0;
44
+ }
45
+
46
+ function scoreLayer3(result) {
47
+ const MAX = 20;
48
+ if (result.status === 'pass') return MAX;
49
+ if (result.totalOccurrences === 0) return MAX;
50
+
51
+ // Gradual degradation based on number of physical properties
52
+ if (result.totalOccurrences < 10) return MAX * 0.8;
53
+ if (result.totalOccurrences < 50) return MAX * 0.5;
54
+ if (result.totalOccurrences < 200) return MAX * 0.2;
55
+ return 0;
56
+ }
57
+
58
+ function scoreLayer4(result) {
59
+ const MAX = 15;
60
+ if (result.status === 'pass') return MAX;
61
+
62
+ // If translations exist but incomplete, score proportionally
63
+ if (result.gaps && result.gaps.length > 0) {
64
+ const totalSource = result.gaps.reduce((s, g) => s + g.sourceKeyCount, 0);
65
+ const totalTarget = result.gaps.reduce((s, g) => s + g.targetKeyCount, 0);
66
+ if (totalSource > 0) {
67
+ return MAX * (totalTarget / totalSource);
68
+ }
69
+ }
70
+
71
+ if (result.status === 'warn') return MAX * 0.3;
72
+ return 0;
73
+ }
74
+
75
+ function scoreLayer5(result) {
76
+ const MAX = 10;
77
+ if (result.status === 'pass') return MAX;
78
+ if (result.count < 5) return MAX * 0.7;
79
+ if (result.count < 20) return MAX * 0.4;
80
+ return 0;
81
+ }
82
+
83
+ function getGrade(score) {
84
+ if (score >= 90) return 'A';
85
+ if (score >= 75) return 'B';
86
+ if (score >= 55) return 'C';
87
+ if (score >= 35) return 'D';
88
+ return 'F';
89
+ }
@@ -0,0 +1,81 @@
1
+ import { readdirSync, statSync } from 'fs';
2
+ import { join, extname } from 'path';
3
+
4
+ const IGNORE_DIRS = new Set([
5
+ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',
6
+ 'coverage', '.nyc_output', '.turbo', '.cache', 'vendor',
7
+ '__pycache__', '.svelte-kit', '.output', 'out',
8
+ ]);
9
+
10
+ /**
11
+ * Walk a directory tree and yield file paths matching given extensions.
12
+ * @param {string} dir - Root directory
13
+ * @param {string[]} extensions - File extensions to include (e.g. ['.ts', '.tsx'])
14
+ * @param {number} maxDepth - Maximum directory depth (default: 15)
15
+ * @returns {string[]} Array of matching file paths
16
+ */
17
+ export function walkFiles(dir, extensions, maxDepth = 15) {
18
+ const results = [];
19
+ walk(dir, extensions, results, 0, maxDepth);
20
+ return results;
21
+ }
22
+
23
+ function walk(dir, extensions, results, depth, maxDepth) {
24
+ if (depth > maxDepth) return;
25
+
26
+ let entries;
27
+ try {
28
+ entries = readdirSync(dir, { withFileTypes: true });
29
+ } catch {
30
+ return; // Skip directories we can't read
31
+ }
32
+
33
+ for (const entry of entries) {
34
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
35
+
36
+ if (entry.isDirectory()) {
37
+ if (IGNORE_DIRS.has(entry.name)) continue;
38
+ walk(join(dir, entry.name), extensions, results, depth + 1, maxDepth);
39
+ } else if (entry.isFile()) {
40
+ const ext = extname(entry.name).toLowerCase();
41
+ if (extensions.includes(ext)) {
42
+ results.push(join(dir, entry.name));
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Find a file by name in a directory (non-recursive or shallow recursive).
50
+ * @param {string} dir - Directory to search
51
+ * @param {string} fileName - Exact filename to find
52
+ * @param {number} maxDepth - How deep to search (default: 3)
53
+ * @returns {string[]} Array of matching paths
54
+ */
55
+ export function findFiles(dir, fileName, maxDepth = 3) {
56
+ const results = [];
57
+ findRecursive(dir, fileName, results, 0, maxDepth);
58
+ return results;
59
+ }
60
+
61
+ function findRecursive(dir, fileName, results, depth, maxDepth) {
62
+ if (depth > maxDepth) return;
63
+
64
+ let entries;
65
+ try {
66
+ entries = readdirSync(dir, { withFileTypes: true });
67
+ } catch {
68
+ return;
69
+ }
70
+
71
+ for (const entry of entries) {
72
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
73
+
74
+ if (entry.isDirectory()) {
75
+ if (IGNORE_DIRS.has(entry.name)) continue;
76
+ findRecursive(join(dir, entry.name), fileName, results, depth + 1, maxDepth);
77
+ } else if (entry.isFile() && entry.name === fileName) {
78
+ results.push(join(dir, entry.name));
79
+ }
80
+ }
81
+ }