@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 +50 -0
- package/package.json +39 -0
- package/src/cli.js +76 -0
- package/src/formatters/json.js +6 -0
- package/src/formatters/markdown.js +103 -0
- package/src/formatters/terminal.js +126 -0
- package/src/index.js +61 -0
- package/src/scanners/layer1-text-engine.js +82 -0
- package/src/scanners/layer2-direction.js +108 -0
- package/src/scanners/layer3-css.js +92 -0
- package/src/scanners/layer4-translations.js +276 -0
- package/src/scanners/layer5-hardcoded.js +162 -0
- package/src/score.js +89 -0
- package/src/utils/walker.js +81 -0
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,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
|
+
}
|