@digitalby/zh-lint 0.1.1
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/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/cli.js +639 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 digitalby
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# zh-lint
|
|
2
|
+
|
|
3
|
+
> Compiler-error-grade lint for Chinese localizations. Catches Simplified characters that leaked into a Traditional locale (and vice versa) before they ship.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/digitalby/zh-lint/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/@digitalby/zh-lint)
|
|
7
|
+
|
|
8
|
+
## The problem
|
|
9
|
+
|
|
10
|
+
Chinese localizations get this wrong all the time:
|
|
11
|
+
|
|
12
|
+
- A translator copy-pastes from a mainland source into `zh-Hant.lproj`.
|
|
13
|
+
- The wrong IME is active when a single character is patched.
|
|
14
|
+
- A 简 sneaks into 繁 (or the reverse) and the compiler is silent. It looks like Chinese, so it ships.
|
|
15
|
+
|
|
16
|
+
`zh-lint` catches it. One CJK character at a time, with file/line/column, so you can fail the Xcode build or the GitHub Actions run.
|
|
17
|
+
|
|
18
|
+
## What it does
|
|
19
|
+
|
|
20
|
+
For every `.strings` file under a `zh-Hans*.lproj`, `zh-Hant*.lproj`, `zh-HK.lproj`, `zh-TW.lproj`, `zh-MO.lproj`, `zh-SG.lproj` or `zh-CN.lproj` directory:
|
|
21
|
+
|
|
22
|
+
1. Detect the expected script (Simplified for `zh-Hans*`/`zh-CN`/`zh-SG`, Traditional for the rest). Override per-glob in `.zh-lint.yml`.
|
|
23
|
+
2. For every value, run [OpenCC](https://github.com/BYVoid/OpenCC) (via `opencc-js`) to convert the string to the *expected* script.
|
|
24
|
+
3. Any CJK character that *changed* during conversion was script-exclusive in the wrong direction. Flag it as an error at its exact line and column.
|
|
25
|
+
|
|
26
|
+
Shared characters (most of the CJK Unified Ideographs block) don't change during OpenCC conversion and produce zero noise.
|
|
27
|
+
|
|
28
|
+
## Install / run
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
# One-off, no install:
|
|
32
|
+
npx --yes @digitalby/zh-lint /path/to/repo
|
|
33
|
+
|
|
34
|
+
# As a dev dependency:
|
|
35
|
+
npm install --save-dev @digitalby/zh-lint
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> The package ships as `@digitalby/zh-lint` on npm but the CLI binary is `zh-lint`. After install/`npx`, run it as `zh-lint <root>`.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
zh-lint <root> Scan <root> for Hans/Hant contamination.
|
|
44
|
+
zh-lint --init Write a default .zh-lint.yml.
|
|
45
|
+
zh-lint --config=<path> Use a specific config file.
|
|
46
|
+
zh-lint --no-config Ignore .zh-lint.yml entirely.
|
|
47
|
+
zh-lint --format=xcode|github|plain|json Output format.
|
|
48
|
+
zh-lint --help
|
|
49
|
+
zh-lint --version
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Exit codes:
|
|
53
|
+
|
|
54
|
+
| code | meaning |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `0` | No violations. |
|
|
57
|
+
| `1` | One or more violations. |
|
|
58
|
+
| `2` | Configuration or I/O error. |
|
|
59
|
+
|
|
60
|
+
### Output formats
|
|
61
|
+
|
|
62
|
+
- **`xcode`** — `file:line:col: error: zh-lint: ...`, written to stderr. Xcode picks these up automatically when emitted from a Run Script build phase.
|
|
63
|
+
- **`github`** — `::error file=...,line=...,col=...::...` workflow commands for GitHub Actions annotations.
|
|
64
|
+
- **`plain`** — `file:line:col: error: ...` for any CI. The default.
|
|
65
|
+
- **`json`** — A JSON array of `{file, line, col, severity, key, char, expectedScript, actualScriptHint, message}` for programmatic consumers.
|
|
66
|
+
|
|
67
|
+
## Configuration: `.zh-lint.yml`
|
|
68
|
+
|
|
69
|
+
All keys are optional. The defaults handle standard Apple/Android layouts.
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
# Override or extend the default directory→script mapping.
|
|
73
|
+
locales:
|
|
74
|
+
"**/zh-HK.lproj": traditional
|
|
75
|
+
"**/zh-SG.lproj": simplified
|
|
76
|
+
|
|
77
|
+
# Globs to skip during the walk.
|
|
78
|
+
ignore:
|
|
79
|
+
- "**/*.generated.strings"
|
|
80
|
+
|
|
81
|
+
# Exact whole-string values to permit (proper nouns, brand names).
|
|
82
|
+
allow_strings:
|
|
83
|
+
- "App Store"
|
|
84
|
+
|
|
85
|
+
# Individual characters to permit in any locale.
|
|
86
|
+
# Useful for brand names where Traditional 髮 is used even in Simplified copy.
|
|
87
|
+
allow_chars:
|
|
88
|
+
- "髮"
|
|
89
|
+
- "體"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Run `zh-lint --init` to drop a starter file in the current directory.
|
|
93
|
+
|
|
94
|
+
## Integrations
|
|
95
|
+
|
|
96
|
+
- [Xcode (Run Script build phase)](docs/integration-xcode.md)
|
|
97
|
+
- [GitHub Actions](docs/integration-github-actions.md)
|
|
98
|
+
- [Fastlane](docs/integration-fastlane.md)
|
|
99
|
+
|
|
100
|
+
### One-liner: GitHub Actions
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
- uses: digitalby/zh-lint@v0.1.1
|
|
104
|
+
with:
|
|
105
|
+
root: '.'
|
|
106
|
+
format: 'github'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### One-liner: Xcode build phase
|
|
110
|
+
|
|
111
|
+
Add a new "Run Script" build phase before "Compile Sources":
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
if ! command -v npx >/dev/null 2>&1; then
|
|
115
|
+
echo "warning: zh-lint skipped — install Node (brew install node)"
|
|
116
|
+
exit 0
|
|
117
|
+
fi
|
|
118
|
+
npx --yes @digitalby/zh-lint@latest "$SRCROOT" --format=xcode
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The `xcode` format writes errors to stderr in the form Xcode parses, so violations appear directly in the Issue Navigator.
|
|
122
|
+
|
|
123
|
+
## Scope (v0.1)
|
|
124
|
+
|
|
125
|
+
- Apple legacy `.strings` files (UTF-8, UTF-8-with-BOM, UTF-16 LE/BE — all handled).
|
|
126
|
+
- Hans-vs-Hant character-script detection only. CN-vs-HK-vs-TW vocabulary mismatches are tracked for v0.2.
|
|
127
|
+
- `.stringsdict`, `.xcstrings` (String Catalogs), and Android `strings.xml` are also v0.2 — file an issue if you need them sooner.
|
|
128
|
+
- No inline-comment overrides; everything goes through `.zh-lint.yml`.
|
|
129
|
+
|
|
130
|
+
## How it works (one paragraph)
|
|
131
|
+
|
|
132
|
+
For a Hans file, `zh-lint` converts every character with OpenCC's TW→CN mapping. If the conversion changed a character, the original was Traditional-exclusive — that's the violation. For Hant files, the reverse: CN→TW. Shared characters pass through unchanged in both directions, so they're never flagged.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT — see [LICENSE](LICENSE).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import * as fs4 from "fs";
|
|
5
|
+
import * as path3 from "path";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import { parse as parseYaml } from "yaml";
|
|
11
|
+
var DEFAULT_CONFIG_NAMES = [".zh-lint.yml", ".zh-lint.yaml"];
|
|
12
|
+
var DEFAULT_LOCALES = {
|
|
13
|
+
"**/zh-Hans*.lproj": "simplified",
|
|
14
|
+
"**/zh-Hant*.lproj": "traditional",
|
|
15
|
+
"**/zh-HK.lproj": "traditional",
|
|
16
|
+
"**/zh-MO.lproj": "traditional",
|
|
17
|
+
"**/zh-TW.lproj": "traditional",
|
|
18
|
+
"**/zh-SG.lproj": "simplified",
|
|
19
|
+
"**/zh-CN.lproj": "simplified"
|
|
20
|
+
};
|
|
21
|
+
var DEFAULT_IGNORE = ["**/node_modules/**", "**/Pods/**", "**/.git/**"];
|
|
22
|
+
var ConfigError = class extends Error {
|
|
23
|
+
};
|
|
24
|
+
function findConfig(startDir) {
|
|
25
|
+
let dir = path.resolve(startDir);
|
|
26
|
+
const root = path.parse(dir).root;
|
|
27
|
+
while (true) {
|
|
28
|
+
for (const name of DEFAULT_CONFIG_NAMES) {
|
|
29
|
+
const candidate = path.join(dir, name);
|
|
30
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (dir === root) return null;
|
|
35
|
+
dir = path.dirname(dir);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function loadConfig(configPath) {
|
|
39
|
+
if (configPath === null) {
|
|
40
|
+
return {
|
|
41
|
+
locales: { ...DEFAULT_LOCALES },
|
|
42
|
+
ignore: [...DEFAULT_IGNORE],
|
|
43
|
+
allowStrings: /* @__PURE__ */ new Set(),
|
|
44
|
+
allowChars: /* @__PURE__ */ new Set()
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = parseYaml(raw);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
throw new ConfigError(`Failed to parse ${configPath}: ${e.message}`);
|
|
53
|
+
}
|
|
54
|
+
if (parsed === null || parsed === void 0) {
|
|
55
|
+
return {
|
|
56
|
+
locales: { ...DEFAULT_LOCALES },
|
|
57
|
+
ignore: [...DEFAULT_IGNORE],
|
|
58
|
+
allowStrings: /* @__PURE__ */ new Set(),
|
|
59
|
+
allowChars: /* @__PURE__ */ new Set()
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
63
|
+
throw new ConfigError(`${configPath}: top-level must be a mapping`);
|
|
64
|
+
}
|
|
65
|
+
const obj = parsed;
|
|
66
|
+
const locales = { ...DEFAULT_LOCALES };
|
|
67
|
+
if (obj.locales !== void 0) {
|
|
68
|
+
if (typeof obj.locales !== "object" || obj.locales === null || Array.isArray(obj.locales)) {
|
|
69
|
+
throw new ConfigError(`${configPath}: 'locales' must be a mapping of glob \u2192 script`);
|
|
70
|
+
}
|
|
71
|
+
for (const [glob, script] of Object.entries(obj.locales)) {
|
|
72
|
+
if (script !== "simplified" && script !== "traditional") {
|
|
73
|
+
throw new ConfigError(
|
|
74
|
+
`${configPath}: locale '${glob}' must map to 'simplified' or 'traditional', got '${String(script)}'`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
locales[glob] = script;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const ignore = [...DEFAULT_IGNORE];
|
|
81
|
+
if (obj.ignore !== void 0) {
|
|
82
|
+
if (!Array.isArray(obj.ignore)) {
|
|
83
|
+
throw new ConfigError(`${configPath}: 'ignore' must be an array of globs`);
|
|
84
|
+
}
|
|
85
|
+
for (const g of obj.ignore) {
|
|
86
|
+
if (typeof g !== "string") {
|
|
87
|
+
throw new ConfigError(`${configPath}: 'ignore' entries must be strings`);
|
|
88
|
+
}
|
|
89
|
+
ignore.push(g);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const allowStrings = /* @__PURE__ */ new Set();
|
|
93
|
+
if (obj.allow_strings !== void 0) {
|
|
94
|
+
if (!Array.isArray(obj.allow_strings)) {
|
|
95
|
+
throw new ConfigError(`${configPath}: 'allow_strings' must be an array`);
|
|
96
|
+
}
|
|
97
|
+
for (const s of obj.allow_strings) {
|
|
98
|
+
if (typeof s !== "string") {
|
|
99
|
+
throw new ConfigError(`${configPath}: 'allow_strings' entries must be strings`);
|
|
100
|
+
}
|
|
101
|
+
allowStrings.add(s);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const allowChars = /* @__PURE__ */ new Set();
|
|
105
|
+
if (obj.allow_chars !== void 0) {
|
|
106
|
+
if (!Array.isArray(obj.allow_chars)) {
|
|
107
|
+
throw new ConfigError(`${configPath}: 'allow_chars' must be an array`);
|
|
108
|
+
}
|
|
109
|
+
for (const ch of obj.allow_chars) {
|
|
110
|
+
if (typeof ch !== "string") {
|
|
111
|
+
throw new ConfigError(`${configPath}: 'allow_chars' entries must be strings`);
|
|
112
|
+
}
|
|
113
|
+
for (const cp of ch) allowChars.add(cp);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { locales, ignore, allowStrings, allowChars };
|
|
117
|
+
}
|
|
118
|
+
var DEFAULT_INIT_CONFIG = `# zh-lint configuration.
|
|
119
|
+
# All keys are optional; defaults already cover standard iOS/Android layouts.
|
|
120
|
+
|
|
121
|
+
# locales:
|
|
122
|
+
# "**/zh-HK.lproj": traditional
|
|
123
|
+
# "**/zh-SG.lproj": simplified
|
|
124
|
+
|
|
125
|
+
# ignore:
|
|
126
|
+
# - "**/*.generated.strings"
|
|
127
|
+
|
|
128
|
+
# allow_strings:
|
|
129
|
+
# - "App Store"
|
|
130
|
+
|
|
131
|
+
# allow_chars:
|
|
132
|
+
# - "\u9AEE"
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
// src/core.ts
|
|
136
|
+
import * as fs3 from "fs";
|
|
137
|
+
|
|
138
|
+
// src/detect.ts
|
|
139
|
+
import { Converter } from "opencc-js";
|
|
140
|
+
var t2s = Converter({ from: "tw", to: "cn" });
|
|
141
|
+
var s2t = Converter({ from: "cn", to: "tw" });
|
|
142
|
+
function isCJK(cp) {
|
|
143
|
+
const code = cp.codePointAt(0);
|
|
144
|
+
if (code === void 0) return false;
|
|
145
|
+
return code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 131072 && code <= 173791 || code >= 173824 && code <= 191471 || code >= 196608 && code <= 201551 || code >= 63744 && code <= 64255;
|
|
146
|
+
}
|
|
147
|
+
function detectInEntry(filePath, entry, expectedScript, config) {
|
|
148
|
+
if (config.allowStrings.has(entry.value)) return [];
|
|
149
|
+
const violations = [];
|
|
150
|
+
const convert = expectedScript === "simplified" ? t2s : s2t;
|
|
151
|
+
const oppositeScript = expectedScript === "simplified" ? "traditional" : "simplified";
|
|
152
|
+
for (let i = 0; i < entry.valueChars.length; i++) {
|
|
153
|
+
const ch = entry.valueChars[i];
|
|
154
|
+
if (!isCJK(ch)) continue;
|
|
155
|
+
if (config.allowChars.has(ch)) continue;
|
|
156
|
+
const converted = convert(ch);
|
|
157
|
+
if (converted === ch) continue;
|
|
158
|
+
violations.push({
|
|
159
|
+
file: filePath,
|
|
160
|
+
line: entry.charLines[i] ?? entry.valueLine,
|
|
161
|
+
col: entry.charCols[i] ?? entry.valueCol,
|
|
162
|
+
key: entry.key,
|
|
163
|
+
char: ch,
|
|
164
|
+
expectedScript,
|
|
165
|
+
actualScriptHint: oppositeScript,
|
|
166
|
+
message: `${oppositeScript} character "${ch}" in ${scriptLabel(expectedScript)} file (key="${entry.key}")`
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return violations;
|
|
170
|
+
}
|
|
171
|
+
function scriptLabel(s) {
|
|
172
|
+
return s === "simplified" ? "zh-Hans" : "zh-Hant";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/parsers/strings.ts
|
|
176
|
+
var ParseError = class extends Error {
|
|
177
|
+
constructor(message, line, col) {
|
|
178
|
+
super(`${message} at line ${line}, col ${col}`);
|
|
179
|
+
this.line = line;
|
|
180
|
+
this.col = col;
|
|
181
|
+
}
|
|
182
|
+
line;
|
|
183
|
+
col;
|
|
184
|
+
};
|
|
185
|
+
function advance(c, n = 1) {
|
|
186
|
+
for (let i = 0; i < n; i++) {
|
|
187
|
+
if (c.pos >= c.source.length) return;
|
|
188
|
+
if (c.source[c.pos] === "\n") {
|
|
189
|
+
c.line++;
|
|
190
|
+
c.col = 1;
|
|
191
|
+
} else {
|
|
192
|
+
c.col++;
|
|
193
|
+
}
|
|
194
|
+
c.pos++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function peek(c, offset = 0) {
|
|
198
|
+
return c.source[c.pos + offset] ?? "";
|
|
199
|
+
}
|
|
200
|
+
function skipTrivia(c) {
|
|
201
|
+
while (c.pos < c.source.length) {
|
|
202
|
+
const ch = peek(c);
|
|
203
|
+
if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
|
|
204
|
+
advance(c);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (ch === "/" && peek(c, 1) === "/") {
|
|
208
|
+
while (c.pos < c.source.length && peek(c) !== "\n") advance(c);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (ch === "/" && peek(c, 1) === "*") {
|
|
212
|
+
advance(c, 2);
|
|
213
|
+
while (c.pos < c.source.length && !(peek(c) === "*" && peek(c, 1) === "/")) {
|
|
214
|
+
advance(c);
|
|
215
|
+
}
|
|
216
|
+
if (c.pos < c.source.length) advance(c, 2);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function expect(c, ch) {
|
|
223
|
+
if (peek(c) !== ch) {
|
|
224
|
+
throw new ParseError(`Expected '${ch}', got '${peek(c) || "<eof>"}'`, c.line, c.col);
|
|
225
|
+
}
|
|
226
|
+
advance(c);
|
|
227
|
+
}
|
|
228
|
+
function readStringLiteral(c) {
|
|
229
|
+
expect(c, '"');
|
|
230
|
+
const chars = [];
|
|
231
|
+
const charLines = [];
|
|
232
|
+
const charCols = [];
|
|
233
|
+
const startLine = c.line;
|
|
234
|
+
const startCol = c.col;
|
|
235
|
+
while (c.pos < c.source.length) {
|
|
236
|
+
const ch = peek(c);
|
|
237
|
+
if (ch === '"') {
|
|
238
|
+
advance(c);
|
|
239
|
+
return { value: chars.join(""), startLine, startCol, charLines, charCols };
|
|
240
|
+
}
|
|
241
|
+
if (ch === "\\") {
|
|
242
|
+
const escLine = c.line;
|
|
243
|
+
const escCol = c.col;
|
|
244
|
+
advance(c);
|
|
245
|
+
const esc = peek(c);
|
|
246
|
+
let decoded;
|
|
247
|
+
switch (esc) {
|
|
248
|
+
case "n":
|
|
249
|
+
decoded = "\n";
|
|
250
|
+
advance(c);
|
|
251
|
+
break;
|
|
252
|
+
case "t":
|
|
253
|
+
decoded = " ";
|
|
254
|
+
advance(c);
|
|
255
|
+
break;
|
|
256
|
+
case "r":
|
|
257
|
+
decoded = "\r";
|
|
258
|
+
advance(c);
|
|
259
|
+
break;
|
|
260
|
+
case '"':
|
|
261
|
+
decoded = '"';
|
|
262
|
+
advance(c);
|
|
263
|
+
break;
|
|
264
|
+
case "\\":
|
|
265
|
+
decoded = "\\";
|
|
266
|
+
advance(c);
|
|
267
|
+
break;
|
|
268
|
+
case "'":
|
|
269
|
+
decoded = "'";
|
|
270
|
+
advance(c);
|
|
271
|
+
break;
|
|
272
|
+
case "0":
|
|
273
|
+
decoded = "\0";
|
|
274
|
+
advance(c);
|
|
275
|
+
break;
|
|
276
|
+
case "U":
|
|
277
|
+
case "u": {
|
|
278
|
+
advance(c);
|
|
279
|
+
let hex = "";
|
|
280
|
+
while (hex.length < 4 && /[0-9a-fA-F]/.test(peek(c))) {
|
|
281
|
+
hex += peek(c);
|
|
282
|
+
advance(c);
|
|
283
|
+
}
|
|
284
|
+
if (hex.length === 0) {
|
|
285
|
+
throw new ParseError("Invalid \\u escape", escLine, escCol);
|
|
286
|
+
}
|
|
287
|
+
decoded = String.fromCodePoint(parseInt(hex, 16));
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
default:
|
|
291
|
+
decoded = esc;
|
|
292
|
+
advance(c);
|
|
293
|
+
}
|
|
294
|
+
for (const codePoint of decoded) {
|
|
295
|
+
chars.push(codePoint);
|
|
296
|
+
charLines.push(escLine);
|
|
297
|
+
charCols.push(escCol);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const charLine = c.line;
|
|
302
|
+
const charCol = c.col;
|
|
303
|
+
if (ch >= "\uD800" && ch <= "\uDBFF" && c.pos + 1 < c.source.length) {
|
|
304
|
+
const next = peek(c, 1);
|
|
305
|
+
if (next >= "\uDC00" && next <= "\uDFFF") {
|
|
306
|
+
chars.push(ch + next);
|
|
307
|
+
charLines.push(charLine);
|
|
308
|
+
charCols.push(charCol);
|
|
309
|
+
advance(c, 2);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
chars.push(ch);
|
|
314
|
+
charLines.push(charLine);
|
|
315
|
+
charCols.push(charCol);
|
|
316
|
+
advance(c);
|
|
317
|
+
}
|
|
318
|
+
throw new ParseError("Unterminated string literal", startLine, startCol);
|
|
319
|
+
}
|
|
320
|
+
function parseStrings(source) {
|
|
321
|
+
const c = { source, pos: 0, line: 1, col: 1 };
|
|
322
|
+
if (source.charCodeAt(0) === 65279) {
|
|
323
|
+
advance(c);
|
|
324
|
+
}
|
|
325
|
+
const entries = [];
|
|
326
|
+
while (true) {
|
|
327
|
+
skipTrivia(c);
|
|
328
|
+
if (c.pos >= c.source.length) break;
|
|
329
|
+
const key = readStringLiteral(c);
|
|
330
|
+
skipTrivia(c);
|
|
331
|
+
expect(c, "=");
|
|
332
|
+
skipTrivia(c);
|
|
333
|
+
const valueResult = readStringLiteral(c);
|
|
334
|
+
skipTrivia(c);
|
|
335
|
+
expect(c, ";");
|
|
336
|
+
const valueChars = [];
|
|
337
|
+
for (const cp of valueResult.value) valueChars.push(cp);
|
|
338
|
+
entries.push({
|
|
339
|
+
key: key.value,
|
|
340
|
+
value: valueResult.value,
|
|
341
|
+
valueLine: valueResult.startLine,
|
|
342
|
+
valueCol: valueResult.startCol,
|
|
343
|
+
valueChars,
|
|
344
|
+
charLines: valueResult.charLines,
|
|
345
|
+
charCols: valueResult.charCols
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return entries;
|
|
349
|
+
}
|
|
350
|
+
function decodeStringsBuffer(buf) {
|
|
351
|
+
if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) {
|
|
352
|
+
return buf.toString("utf16le", 2);
|
|
353
|
+
}
|
|
354
|
+
if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
|
|
355
|
+
const swapped = Buffer.alloc(buf.length - 2);
|
|
356
|
+
for (let i = 2; i + 1 < buf.length; i += 2) {
|
|
357
|
+
swapped[i - 2] = buf[i + 1];
|
|
358
|
+
swapped[i - 1] = buf[i];
|
|
359
|
+
}
|
|
360
|
+
return swapped.toString("utf16le");
|
|
361
|
+
}
|
|
362
|
+
if (buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) {
|
|
363
|
+
return buf.toString("utf8", 3);
|
|
364
|
+
}
|
|
365
|
+
return buf.toString("utf8");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/walker.ts
|
|
369
|
+
import * as fs2 from "fs";
|
|
370
|
+
import * as path2 from "path";
|
|
371
|
+
import picomatch from "picomatch";
|
|
372
|
+
var STRINGS_EXTENSIONS = /* @__PURE__ */ new Set([".strings"]);
|
|
373
|
+
function walk(root, config) {
|
|
374
|
+
const absRoot = path2.resolve(root);
|
|
375
|
+
const ignoreMatchers = config.ignore.map((g) => picomatch(g, { dot: true }));
|
|
376
|
+
const localeMatchers = Object.entries(
|
|
377
|
+
config.locales
|
|
378
|
+
).map(([glob, script]) => ({ match: picomatch(glob, { dot: true }), script }));
|
|
379
|
+
const out = [];
|
|
380
|
+
function visit(dir) {
|
|
381
|
+
let entries;
|
|
382
|
+
try {
|
|
383
|
+
entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
384
|
+
} catch {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
for (const e of entries) {
|
|
388
|
+
const full = path2.join(dir, e.name);
|
|
389
|
+
const rel = path2.relative(absRoot, full) || e.name;
|
|
390
|
+
if (ignoreMatchers.some((m) => m(rel) || m(full))) continue;
|
|
391
|
+
if (e.isDirectory()) {
|
|
392
|
+
visit(full);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (!e.isFile()) continue;
|
|
396
|
+
const ext = path2.extname(full);
|
|
397
|
+
if (!STRINGS_EXTENSIONS.has(ext)) continue;
|
|
398
|
+
const parentDir = path2.basename(path2.dirname(full));
|
|
399
|
+
if (!parentDir.endsWith(".lproj")) continue;
|
|
400
|
+
const dirRel = path2.relative(absRoot, path2.dirname(full));
|
|
401
|
+
const dirFull = path2.dirname(full);
|
|
402
|
+
const script = inferScript(localeMatchers, dirRel, dirFull);
|
|
403
|
+
if (script === null) continue;
|
|
404
|
+
out.push({ path: full, expectedScript: script });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
visit(absRoot);
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
function inferScript(matchers, dirRel, dirFull) {
|
|
411
|
+
for (const m of matchers) {
|
|
412
|
+
if (m.match(dirRel) || m.match(dirFull)) return m.script;
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/core.ts
|
|
418
|
+
function scan(root, config) {
|
|
419
|
+
const files = walk(root, config);
|
|
420
|
+
const violations = [];
|
|
421
|
+
const parseErrors = [];
|
|
422
|
+
for (const file of files) {
|
|
423
|
+
let source;
|
|
424
|
+
try {
|
|
425
|
+
const buf = fs3.readFileSync(file.path);
|
|
426
|
+
source = decodeStringsBuffer(buf);
|
|
427
|
+
} catch (e) {
|
|
428
|
+
parseErrors.push({ file: file.path, message: e.message });
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
const entries = parseStrings(source);
|
|
433
|
+
for (const entry of entries) {
|
|
434
|
+
violations.push(...detectInEntry(file.path, entry, file.expectedScript, config));
|
|
435
|
+
}
|
|
436
|
+
} catch (e) {
|
|
437
|
+
parseErrors.push({ file: file.path, message: e.message });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return { files: files.length, violations, parseErrors };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/report/github.ts
|
|
444
|
+
function escape(s) {
|
|
445
|
+
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
446
|
+
}
|
|
447
|
+
function formatGithub(violations) {
|
|
448
|
+
return violations.map(
|
|
449
|
+
(v) => `::error file=${escape(v.file)},line=${v.line},col=${v.col}::${escape(`zh-lint: ${v.message}`)}`
|
|
450
|
+
).join("\n");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/report/json.ts
|
|
454
|
+
function formatJson(violations) {
|
|
455
|
+
return JSON.stringify(
|
|
456
|
+
violations.map((v) => ({
|
|
457
|
+
file: v.file,
|
|
458
|
+
line: v.line,
|
|
459
|
+
col: v.col,
|
|
460
|
+
severity: "error",
|
|
461
|
+
key: v.key,
|
|
462
|
+
char: v.char,
|
|
463
|
+
expectedScript: v.expectedScript,
|
|
464
|
+
actualScriptHint: v.actualScriptHint,
|
|
465
|
+
message: v.message
|
|
466
|
+
})),
|
|
467
|
+
null,
|
|
468
|
+
2
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/report/plain.ts
|
|
473
|
+
function formatPlain(violations) {
|
|
474
|
+
return violations.map((v) => `${v.file}:${v.line}:${v.col}: error: ${v.message}`).join("\n");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/report/xcode.ts
|
|
478
|
+
function formatXcode(violations) {
|
|
479
|
+
return violations.map((v) => `${v.file}:${v.line}:${v.col}: error: zh-lint: ${v.message}`).join("\n");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/report/index.ts
|
|
483
|
+
function format(violations, outputFormat) {
|
|
484
|
+
switch (outputFormat) {
|
|
485
|
+
case "xcode":
|
|
486
|
+
return formatXcode(violations);
|
|
487
|
+
case "github":
|
|
488
|
+
return formatGithub(violations);
|
|
489
|
+
case "plain":
|
|
490
|
+
return formatPlain(violations);
|
|
491
|
+
case "json":
|
|
492
|
+
return formatJson(violations);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/cli.ts
|
|
497
|
+
var USAGE = `zh-lint \u2014 Chinese script-contamination linter
|
|
498
|
+
|
|
499
|
+
Usage:
|
|
500
|
+
zh-lint <root> Scan <root> for Hans/Hant contamination.
|
|
501
|
+
zh-lint --init Write a default .zh-lint.yml in the current directory.
|
|
502
|
+
|
|
503
|
+
Options:
|
|
504
|
+
--config=<path> Use a specific config file (default: .zh-lint.yml searched upward).
|
|
505
|
+
--no-config Ignore any .zh-lint.yml and use built-in defaults.
|
|
506
|
+
--format=<xcode|github|plain|json> Output format (default: plain).
|
|
507
|
+
--help, -h Show this message.
|
|
508
|
+
--version, -V Print version and exit.
|
|
509
|
+
|
|
510
|
+
Exit codes:
|
|
511
|
+
0 Clean.
|
|
512
|
+
1 One or more violations found.
|
|
513
|
+
2 Configuration or IO error.
|
|
514
|
+
`;
|
|
515
|
+
function parseArgs(argv) {
|
|
516
|
+
const result = {
|
|
517
|
+
root: ".",
|
|
518
|
+
configPath: void 0,
|
|
519
|
+
format: "plain",
|
|
520
|
+
init: false,
|
|
521
|
+
help: false,
|
|
522
|
+
version: false
|
|
523
|
+
};
|
|
524
|
+
let positional = null;
|
|
525
|
+
for (const arg of argv) {
|
|
526
|
+
if (arg === "--help" || arg === "-h") result.help = true;
|
|
527
|
+
else if (arg === "--version" || arg === "-V") result.version = true;
|
|
528
|
+
else if (arg === "--init") result.init = true;
|
|
529
|
+
else if (arg === "--no-config") result.configPath = null;
|
|
530
|
+
else if (arg.startsWith("--config=")) result.configPath = arg.slice("--config=".length);
|
|
531
|
+
else if (arg.startsWith("--format=")) {
|
|
532
|
+
const v = arg.slice("--format=".length);
|
|
533
|
+
if (v !== "xcode" && v !== "github" && v !== "plain" && v !== "json") {
|
|
534
|
+
throw new Error(`Unknown --format value: ${v}`);
|
|
535
|
+
}
|
|
536
|
+
result.format = v;
|
|
537
|
+
} else if (arg.startsWith("--")) {
|
|
538
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
539
|
+
} else if (positional === null) {
|
|
540
|
+
positional = arg;
|
|
541
|
+
} else {
|
|
542
|
+
throw new Error(`Unexpected extra argument: ${arg}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (positional !== null) result.root = positional;
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
function readVersion() {
|
|
549
|
+
try {
|
|
550
|
+
const here = path3.dirname(new URL(import.meta.url).pathname);
|
|
551
|
+
const candidates = [path3.join(here, "..", "package.json"), path3.join(here, "package.json")];
|
|
552
|
+
for (const c of candidates) {
|
|
553
|
+
if (fs4.existsSync(c)) {
|
|
554
|
+
const pkg = JSON.parse(fs4.readFileSync(c, "utf8"));
|
|
555
|
+
if (pkg.version) return pkg.version;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} catch {
|
|
559
|
+
}
|
|
560
|
+
return "unknown";
|
|
561
|
+
}
|
|
562
|
+
function main(argv) {
|
|
563
|
+
let args;
|
|
564
|
+
try {
|
|
565
|
+
args = parseArgs(argv);
|
|
566
|
+
} catch (e) {
|
|
567
|
+
process.stderr.write(`zh-lint: ${e.message}
|
|
568
|
+
|
|
569
|
+
${USAGE}`);
|
|
570
|
+
return 2;
|
|
571
|
+
}
|
|
572
|
+
if (args.help) {
|
|
573
|
+
process.stdout.write(USAGE);
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
if (args.version) {
|
|
577
|
+
process.stdout.write(`${readVersion()}
|
|
578
|
+
`);
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
if (args.init) {
|
|
582
|
+
const target = path3.resolve(".zh-lint.yml");
|
|
583
|
+
if (fs4.existsSync(target)) {
|
|
584
|
+
process.stderr.write(`zh-lint: ${target} already exists; refusing to overwrite
|
|
585
|
+
`);
|
|
586
|
+
return 2;
|
|
587
|
+
}
|
|
588
|
+
fs4.writeFileSync(target, DEFAULT_INIT_CONFIG, "utf8");
|
|
589
|
+
process.stdout.write(`Wrote ${target}
|
|
590
|
+
`);
|
|
591
|
+
return 0;
|
|
592
|
+
}
|
|
593
|
+
let configPath;
|
|
594
|
+
if (args.configPath === void 0) {
|
|
595
|
+
configPath = findConfig(args.root);
|
|
596
|
+
} else if (args.configPath === null) {
|
|
597
|
+
configPath = null;
|
|
598
|
+
} else {
|
|
599
|
+
configPath = path3.resolve(args.configPath);
|
|
600
|
+
if (!fs4.existsSync(configPath)) {
|
|
601
|
+
process.stderr.write(`zh-lint: config file not found: ${configPath}
|
|
602
|
+
`);
|
|
603
|
+
return 2;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
let config;
|
|
607
|
+
try {
|
|
608
|
+
config = loadConfig(configPath);
|
|
609
|
+
} catch (e) {
|
|
610
|
+
if (e instanceof ConfigError) {
|
|
611
|
+
process.stderr.write(`zh-lint: ${e.message}
|
|
612
|
+
`);
|
|
613
|
+
return 2;
|
|
614
|
+
}
|
|
615
|
+
throw e;
|
|
616
|
+
}
|
|
617
|
+
const result = scan(args.root, config);
|
|
618
|
+
if (result.parseErrors.length > 0) {
|
|
619
|
+
for (const pe of result.parseErrors) {
|
|
620
|
+
process.stderr.write(`zh-lint: parse error in ${pe.file}: ${pe.message}
|
|
621
|
+
`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (result.violations.length === 0) {
|
|
625
|
+
if (args.format === "json") process.stdout.write("[]\n");
|
|
626
|
+
return result.parseErrors.length > 0 ? 2 : 0;
|
|
627
|
+
}
|
|
628
|
+
const out = format(result.violations, args.format);
|
|
629
|
+
const stream = args.format === "xcode" ? process.stderr : process.stdout;
|
|
630
|
+
stream.write(out + "\n");
|
|
631
|
+
return 1;
|
|
632
|
+
}
|
|
633
|
+
var isDirectInvocation = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("cli.js") === true || process.argv[1]?.endsWith("zh-lint") === true;
|
|
634
|
+
if (isDirectInvocation) {
|
|
635
|
+
process.exit(main(process.argv.slice(2)));
|
|
636
|
+
}
|
|
637
|
+
export {
|
|
638
|
+
main
|
|
639
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@digitalby/zh-lint",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Lint Chinese localization files for Simplified/Traditional script contamination",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zh-lint": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"lint": "tsc --noEmit",
|
|
19
|
+
"prepublishOnly": "npm run lint && npm run test && npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"chinese",
|
|
23
|
+
"localization",
|
|
24
|
+
"l10n",
|
|
25
|
+
"i18n",
|
|
26
|
+
"lint",
|
|
27
|
+
"simplified",
|
|
28
|
+
"traditional",
|
|
29
|
+
"opencc",
|
|
30
|
+
"ios",
|
|
31
|
+
"xcode"
|
|
32
|
+
],
|
|
33
|
+
"author": "digitalby <yury@digitalby.me>",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/digitalby/zh-lint.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/digitalby/zh-lint/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/digitalby/zh-lint#readme",
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"opencc-js": "^1.0.5",
|
|
48
|
+
"picomatch": "^4.0.2",
|
|
49
|
+
"yaml": "^2.6.1"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^22.10.2",
|
|
53
|
+
"@types/picomatch": "^3.0.2",
|
|
54
|
+
"tsup": "^8.3.5",
|
|
55
|
+
"typescript": "^5.7.2",
|
|
56
|
+
"vitest": "^4.1.6"
|
|
57
|
+
}
|
|
58
|
+
}
|