prooflint 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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/cli.js +534 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +491 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +418 -0
- package/dist/index.d.ts +418 -0
- package/dist/index.js +446 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Higashiyama
|
|
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,147 @@
|
|
|
1
|
+
# prooflint
|
|
2
|
+
|
|
3
|
+
Declarative text linter for Japanese/Markdown. Define custom rules in YAML — no JavaScript required.
|
|
4
|
+
|
|
5
|
+
## textlint との棲み分け
|
|
6
|
+
|
|
7
|
+
[textlint](https://textlint.github.io/) は汎用的な日本語ルール(ですます混在検出、二重否定、助詞の重複など)を提供する。prooflint はそれを置き換えるものではなく、**プロジェクト固有のルールを YAML で宣言的に定義する**ことに特化している。
|
|
8
|
+
|
|
9
|
+
| | textlint | prooflint |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| 汎用日本語ルール | ✅ | — |
|
|
12
|
+
| カスタムルール定義 | JS プラグイン必要 | **YAML のみ** |
|
|
13
|
+
| 用語集管理 | 別途プラグイン | **組み込み** |
|
|
14
|
+
| プロジェクト固有設定 | `.textlintrc` | `.prooflint.yml` |
|
|
15
|
+
|
|
16
|
+
推奨: 汎用ルールは textlint、プロジェクト固有ルールは prooflint で併用する。
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install --save-dev prooflint
|
|
22
|
+
# または
|
|
23
|
+
npm install -g prooflint
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 設定ファイルを生成
|
|
30
|
+
prooflint init
|
|
31
|
+
|
|
32
|
+
# Markdown ファイルを lint
|
|
33
|
+
prooflint check "docs/**/*.md"
|
|
34
|
+
|
|
35
|
+
# JSON 出力 (CI 用)
|
|
36
|
+
prooflint check --format json "docs/**/*.md"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
`.prooflint.yml` に 3 種類のルールを定義できる。
|
|
42
|
+
|
|
43
|
+
### pattern — 正規表現マッチング
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
rules:
|
|
47
|
+
- id: no-desu-masu-mix
|
|
48
|
+
type: pattern
|
|
49
|
+
description: "である調とですます調の混在を検出"
|
|
50
|
+
severity: error
|
|
51
|
+
patterns:
|
|
52
|
+
- regex: "です。"
|
|
53
|
+
message: "である調で統一してください"
|
|
54
|
+
- regex: "ます。"
|
|
55
|
+
message: "である調で統一してください"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### dictionary — 用語の表記ゆれ統一
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
rules:
|
|
62
|
+
- id: term-consistency
|
|
63
|
+
type: dictionary
|
|
64
|
+
description: "用語の表記ゆれを統一"
|
|
65
|
+
severity: warning
|
|
66
|
+
terms:
|
|
67
|
+
- prefer: "サーバー"
|
|
68
|
+
avoid: ["サーバ"]
|
|
69
|
+
- prefer: "インターフェース"
|
|
70
|
+
avoid: ["インタフェース", "インターフェイス"]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### structure — 文構造のチェック
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
rules:
|
|
77
|
+
# 一文の長さを制限
|
|
78
|
+
- id: sentence-length
|
|
79
|
+
type: structure
|
|
80
|
+
severity: warning
|
|
81
|
+
target: sentence
|
|
82
|
+
max_chars: 120
|
|
83
|
+
|
|
84
|
+
# 見出しのレベルと末尾ピリオドを制限
|
|
85
|
+
- id: heading-style
|
|
86
|
+
type: structure
|
|
87
|
+
severity: error
|
|
88
|
+
target: heading
|
|
89
|
+
max_level: 3
|
|
90
|
+
no_period: true
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Severity
|
|
94
|
+
|
|
95
|
+
| 値 | 意味 | 終了コード |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `error` | 重大な問題。CI で失敗させる | 1 |
|
|
98
|
+
| `warning` | 推奨事項の違反 | 0 |
|
|
99
|
+
| `info` | 情報提供のみ | 0 |
|
|
100
|
+
|
|
101
|
+
## Output
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
docs/guide.md
|
|
105
|
+
3:15 error である調で統一してください no-desu-masu-mix
|
|
106
|
+
7:1 warn 「サーバ」→「サーバー」に統一してください term-consistency
|
|
107
|
+
12:5 error 見出しレベル4は上限(H3)を超えています heading-style
|
|
108
|
+
|
|
109
|
+
3 problems (2 errors, 1 warning)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## CLI Options
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
prooflint check [patterns...] [options]
|
|
116
|
+
|
|
117
|
+
Options:
|
|
118
|
+
-c, --config <path> 設定ファイルのパス (デフォルト: .prooflint.yml)
|
|
119
|
+
-f, --format <format> 出力形式: console または json (デフォルト: console)
|
|
120
|
+
|
|
121
|
+
prooflint init [options]
|
|
122
|
+
|
|
123
|
+
Options:
|
|
124
|
+
--force 既存の設定ファイルを上書き
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Node.js API
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import { lintText, loadConfig } from 'prooflint'
|
|
131
|
+
|
|
132
|
+
const config = loadConfig('./.prooflint.yml')
|
|
133
|
+
const result = lintText(markdownContent, 'path/to/file.md', config)
|
|
134
|
+
|
|
135
|
+
console.log(result.errorCount, result.warningCount)
|
|
136
|
+
result.messages.forEach((msg) => {
|
|
137
|
+
console.log(`${msg.line}:${msg.column} [${msg.severity}] ${msg.message}`)
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Requirements
|
|
142
|
+
|
|
143
|
+
- Node.js >= 18
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync3, writeFileSync, existsSync } from "fs";
|
|
5
|
+
import { resolve as resolve2, dirname as dirname2 } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { glob } from "fs/promises";
|
|
9
|
+
|
|
10
|
+
// src/config/loader.ts
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { resolve, dirname } from "path";
|
|
13
|
+
import yaml from "js-yaml";
|
|
14
|
+
|
|
15
|
+
// src/rules/types.ts
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
var SeveritySchema = z.enum(["error", "warning", "info"]);
|
|
18
|
+
var PatternRuleSchema = z.object({
|
|
19
|
+
id: z.string().min(1),
|
|
20
|
+
type: z.literal("pattern"),
|
|
21
|
+
description: z.string().optional(),
|
|
22
|
+
severity: SeveritySchema.default("warning"),
|
|
23
|
+
patterns: z.array(
|
|
24
|
+
z.object({
|
|
25
|
+
regex: z.string().min(1),
|
|
26
|
+
message: z.string().min(1),
|
|
27
|
+
flags: z.string().optional()
|
|
28
|
+
})
|
|
29
|
+
).min(1)
|
|
30
|
+
});
|
|
31
|
+
var DictionaryRuleSchema = z.object({
|
|
32
|
+
id: z.string().min(1),
|
|
33
|
+
type: z.literal("dictionary"),
|
|
34
|
+
description: z.string().optional(),
|
|
35
|
+
severity: SeveritySchema.default("warning"),
|
|
36
|
+
terms: z.array(
|
|
37
|
+
z.object({
|
|
38
|
+
prefer: z.string().min(1),
|
|
39
|
+
avoid: z.array(z.string().min(1)).min(1)
|
|
40
|
+
})
|
|
41
|
+
).min(1)
|
|
42
|
+
});
|
|
43
|
+
var StructureRuleSchema = z.object({
|
|
44
|
+
id: z.string().min(1),
|
|
45
|
+
type: z.literal("structure"),
|
|
46
|
+
description: z.string().optional(),
|
|
47
|
+
severity: SeveritySchema.default("warning"),
|
|
48
|
+
target: z.enum(["sentence", "heading"]).default("sentence"),
|
|
49
|
+
max_chars: z.number().int().positive().optional(),
|
|
50
|
+
max_level: z.number().int().min(1).max(6).optional(),
|
|
51
|
+
no_period: z.boolean().optional()
|
|
52
|
+
});
|
|
53
|
+
var RuleSchema = z.discriminatedUnion("type", [
|
|
54
|
+
PatternRuleSchema,
|
|
55
|
+
DictionaryRuleSchema,
|
|
56
|
+
StructureRuleSchema
|
|
57
|
+
]);
|
|
58
|
+
var ContextSchema = z.object({
|
|
59
|
+
glossary: z.string().optional(),
|
|
60
|
+
style_guide: z.string().optional()
|
|
61
|
+
}).optional();
|
|
62
|
+
var ConfigSchema = z.object({
|
|
63
|
+
rules: z.array(RuleSchema).default([]),
|
|
64
|
+
context: ContextSchema
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// src/config/loader.ts
|
|
68
|
+
var CONFIG_FILE_NAMES = [".prooflint.yml", ".prooflint.yaml", "prooflint.config.yml"];
|
|
69
|
+
function findConfigFile(cwd = process.cwd()) {
|
|
70
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
71
|
+
const candidate = resolve(cwd, name);
|
|
72
|
+
try {
|
|
73
|
+
readFileSync(candidate);
|
|
74
|
+
return candidate;
|
|
75
|
+
} catch {
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function loadConfig(configPath) {
|
|
81
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
82
|
+
const parsed = yaml.load(raw);
|
|
83
|
+
const result = ConfigSchema.safeParse(parsed);
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
const issues = result.error.issues.map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`).join("\n");
|
|
86
|
+
throw new Error(`Invalid config at ${configPath}:
|
|
87
|
+
${issues}`);
|
|
88
|
+
}
|
|
89
|
+
return result.data;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/rules/engine.ts
|
|
93
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
94
|
+
|
|
95
|
+
// src/parser/markdown.ts
|
|
96
|
+
import { remark } from "remark";
|
|
97
|
+
function parseMarkdown(content) {
|
|
98
|
+
const processor = remark();
|
|
99
|
+
const tree = processor.parse(content);
|
|
100
|
+
const textNodes = [];
|
|
101
|
+
const headings = [];
|
|
102
|
+
function visit(node) {
|
|
103
|
+
if (node.type === "heading") {
|
|
104
|
+
const text = extractText(node);
|
|
105
|
+
const position = node.position;
|
|
106
|
+
const textNode = {
|
|
107
|
+
text,
|
|
108
|
+
line: position?.start.line ?? 1,
|
|
109
|
+
column: position?.start.column ?? 1,
|
|
110
|
+
nodeType: "heading",
|
|
111
|
+
headingDepth: node.depth
|
|
112
|
+
};
|
|
113
|
+
headings.push(textNode);
|
|
114
|
+
textNodes.push(textNode);
|
|
115
|
+
} else if (node.type === "paragraph") {
|
|
116
|
+
const text = extractText(node);
|
|
117
|
+
const position = node.position;
|
|
118
|
+
textNodes.push({
|
|
119
|
+
text,
|
|
120
|
+
line: position?.start.line ?? 1,
|
|
121
|
+
column: position?.start.column ?? 1,
|
|
122
|
+
nodeType: "paragraph"
|
|
123
|
+
});
|
|
124
|
+
} else if (node.type === "listItem" || node.type === "blockquote") {
|
|
125
|
+
if ("children" in node) {
|
|
126
|
+
for (const child of node.children) {
|
|
127
|
+
visit(child);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if ("children" in node && node.type !== "heading" && node.type !== "paragraph") {
|
|
133
|
+
for (const child of node.children) {
|
|
134
|
+
visit(child);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const child of tree.children) {
|
|
139
|
+
visit(child);
|
|
140
|
+
}
|
|
141
|
+
return { textNodes, headings, raw: content };
|
|
142
|
+
}
|
|
143
|
+
function extractText(node) {
|
|
144
|
+
if ("value" in node && typeof node.value === "string") {
|
|
145
|
+
return node.value;
|
|
146
|
+
}
|
|
147
|
+
if ("children" in node) {
|
|
148
|
+
return node.children.map(extractText).join("");
|
|
149
|
+
}
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/rules/pattern.ts
|
|
154
|
+
function applyPatternRule(rule, nodes) {
|
|
155
|
+
const messages = [];
|
|
156
|
+
for (const node of nodes) {
|
|
157
|
+
for (const pattern of rule.patterns) {
|
|
158
|
+
const flags = pattern.flags ?? "g";
|
|
159
|
+
let regex;
|
|
160
|
+
try {
|
|
161
|
+
regex = new RegExp(pattern.regex, flags.includes("g") ? flags : flags + "g");
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error(`Rule "${rule.id}": invalid regex "${pattern.regex}"`);
|
|
164
|
+
}
|
|
165
|
+
let match;
|
|
166
|
+
while ((match = regex.exec(node.text)) !== null) {
|
|
167
|
+
const beforeMatch = node.text.slice(0, match.index);
|
|
168
|
+
const newlines = (beforeMatch.match(/\n/g) ?? []).length;
|
|
169
|
+
const lastNewline = beforeMatch.lastIndexOf("\n");
|
|
170
|
+
const col = lastNewline === -1 ? node.column + match.index : match.index - lastNewline;
|
|
171
|
+
messages.push({
|
|
172
|
+
ruleId: rule.id,
|
|
173
|
+
severity: rule.severity,
|
|
174
|
+
message: pattern.message,
|
|
175
|
+
line: node.line + newlines,
|
|
176
|
+
column: col,
|
|
177
|
+
source: match[0]
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return messages;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/rules/dictionary.ts
|
|
186
|
+
function isWordBoundary(text, start, end) {
|
|
187
|
+
const charBefore = start > 0 ? text[start - 1] : null;
|
|
188
|
+
const charAfter = end < text.length ? text[end] : null;
|
|
189
|
+
if (charAfter === "\u30FC" || charAfter === "\u301C") return false;
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
function applyDictionaryRule(rule, nodes) {
|
|
193
|
+
const messages = [];
|
|
194
|
+
for (const node of nodes) {
|
|
195
|
+
for (const term of rule.terms) {
|
|
196
|
+
for (const avoidWord of term.avoid) {
|
|
197
|
+
const preferStartsWithAvoid = term.prefer.startsWith(avoidWord);
|
|
198
|
+
const escaped = avoidWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
199
|
+
const regex = new RegExp(escaped, "g");
|
|
200
|
+
let match;
|
|
201
|
+
while ((match = regex.exec(node.text)) !== null) {
|
|
202
|
+
const matchStart = match.index;
|
|
203
|
+
const matchEnd = matchStart + avoidWord.length;
|
|
204
|
+
if (preferStartsWithAvoid && !isWordBoundary(node.text, matchStart, matchEnd)) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const beforeMatch = node.text.slice(0, matchStart);
|
|
208
|
+
const newlines = (beforeMatch.match(/\n/g) ?? []).length;
|
|
209
|
+
const lastNewline = beforeMatch.lastIndexOf("\n");
|
|
210
|
+
const col = lastNewline === -1 ? node.column + matchStart : matchStart - lastNewline;
|
|
211
|
+
messages.push({
|
|
212
|
+
ruleId: rule.id,
|
|
213
|
+
severity: rule.severity,
|
|
214
|
+
message: `\u300C${avoidWord}\u300D\u2192\u300C${term.prefer}\u300D\u306B\u7D71\u4E00\u3057\u3066\u304F\u3060\u3055\u3044`,
|
|
215
|
+
line: node.line + newlines,
|
|
216
|
+
column: col,
|
|
217
|
+
source: match[0]
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return messages;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/parser/sentence.ts
|
|
227
|
+
function splitSentences(text, startLine, startColumn) {
|
|
228
|
+
const sentences = [];
|
|
229
|
+
const lines = text.split("\n");
|
|
230
|
+
let currentLine = startLine;
|
|
231
|
+
let currentCol = startColumn;
|
|
232
|
+
let buffer = "";
|
|
233
|
+
let bufferLine = currentLine;
|
|
234
|
+
let bufferCol = currentCol;
|
|
235
|
+
for (let li = 0; li < lines.length; li++) {
|
|
236
|
+
const line = lines[li] ?? "";
|
|
237
|
+
let charPos = li === 0 ? startColumn - 1 : 0;
|
|
238
|
+
for (let ci = 0; ci < line.length; ci++) {
|
|
239
|
+
const ch = line[ci] ?? "";
|
|
240
|
+
buffer += ch;
|
|
241
|
+
charPos++;
|
|
242
|
+
if (isSentenceEnd(ch, line, ci)) {
|
|
243
|
+
const trimmed2 = buffer.trim();
|
|
244
|
+
if (trimmed2.length > 0) {
|
|
245
|
+
sentences.push({ text: trimmed2, line: bufferLine, column: bufferCol });
|
|
246
|
+
}
|
|
247
|
+
buffer = "";
|
|
248
|
+
bufferLine = currentLine;
|
|
249
|
+
bufferCol = charPos + 1;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (li < lines.length - 1) {
|
|
253
|
+
buffer += "\n";
|
|
254
|
+
currentLine++;
|
|
255
|
+
currentCol = 1;
|
|
256
|
+
if (buffer.trim() === "") {
|
|
257
|
+
bufferLine = currentLine;
|
|
258
|
+
bufferCol = 1;
|
|
259
|
+
buffer = "";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const trimmed = buffer.trim();
|
|
264
|
+
if (trimmed.length > 0) {
|
|
265
|
+
sentences.push({ text: trimmed, line: bufferLine, column: bufferCol });
|
|
266
|
+
}
|
|
267
|
+
return sentences;
|
|
268
|
+
}
|
|
269
|
+
function isSentenceEnd(ch, line, index) {
|
|
270
|
+
if ("\u3002\uFF01\uFF1F".includes(ch)) return true;
|
|
271
|
+
if (ch === "." || ch === "!" || ch === "?") {
|
|
272
|
+
const next = line[index + 1];
|
|
273
|
+
if (next === void 0 || next === " " || next === " ") return true;
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/rules/structure.ts
|
|
279
|
+
function applyStructureRule(rule, nodes) {
|
|
280
|
+
const messages = [];
|
|
281
|
+
const target = rule.target ?? "sentence";
|
|
282
|
+
if (target === "heading") {
|
|
283
|
+
return applyHeadingRules(rule, nodes);
|
|
284
|
+
}
|
|
285
|
+
for (const node of nodes) {
|
|
286
|
+
if (node.nodeType === "heading") continue;
|
|
287
|
+
if (rule.max_chars !== void 0) {
|
|
288
|
+
const sentences = splitSentences(node.text, node.line, node.column);
|
|
289
|
+
for (const sentence of sentences) {
|
|
290
|
+
if (sentence.text.length > rule.max_chars) {
|
|
291
|
+
messages.push({
|
|
292
|
+
ruleId: rule.id,
|
|
293
|
+
severity: rule.severity,
|
|
294
|
+
message: `\u4E00\u6587\u304C${sentence.text.length}\u6587\u5B57\u3067\u3059\uFF08\u4E0A\u9650: ${rule.max_chars}\u6587\u5B57\uFF09`,
|
|
295
|
+
line: sentence.line,
|
|
296
|
+
column: sentence.column,
|
|
297
|
+
source: sentence.text.slice(0, 40) + (sentence.text.length > 40 ? "..." : "")
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return messages;
|
|
304
|
+
}
|
|
305
|
+
function applyHeadingRules(rule, nodes) {
|
|
306
|
+
const messages = [];
|
|
307
|
+
for (const node of nodes) {
|
|
308
|
+
if (node.nodeType !== "heading") continue;
|
|
309
|
+
if (rule.max_level !== void 0 && node.headingDepth !== void 0) {
|
|
310
|
+
if (node.headingDepth > rule.max_level) {
|
|
311
|
+
messages.push({
|
|
312
|
+
ruleId: rule.id,
|
|
313
|
+
severity: rule.severity,
|
|
314
|
+
message: `\u898B\u51FA\u3057\u30EC\u30D9\u30EB${node.headingDepth}\u306F\u4E0A\u9650\uFF08H${rule.max_level}\uFF09\u3092\u8D85\u3048\u3066\u3044\u307E\u3059`,
|
|
315
|
+
line: node.line,
|
|
316
|
+
column: node.column,
|
|
317
|
+
source: node.text
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (rule.no_period === true) {
|
|
322
|
+
if (/[。..]$/.test(node.text.trim())) {
|
|
323
|
+
messages.push({
|
|
324
|
+
ruleId: rule.id,
|
|
325
|
+
severity: rule.severity,
|
|
326
|
+
message: "\u898B\u51FA\u3057\u306E\u672B\u5C3E\u306B\u53E5\u70B9\u3092\u4F7F\u308F\u306A\u3044\u3067\u304F\u3060\u3055\u3044",
|
|
327
|
+
line: node.line,
|
|
328
|
+
column: node.column,
|
|
329
|
+
source: node.text
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return messages;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/rules/engine.ts
|
|
338
|
+
function lintText(content, filePath, config) {
|
|
339
|
+
const doc = parseMarkdown(content);
|
|
340
|
+
const messages = [];
|
|
341
|
+
for (const rule of config.rules) {
|
|
342
|
+
switch (rule.type) {
|
|
343
|
+
case "pattern":
|
|
344
|
+
messages.push(...applyPatternRule(rule, doc.textNodes));
|
|
345
|
+
break;
|
|
346
|
+
case "dictionary":
|
|
347
|
+
messages.push(...applyDictionaryRule(rule, doc.textNodes));
|
|
348
|
+
break;
|
|
349
|
+
case "structure":
|
|
350
|
+
messages.push(...applyStructureRule(rule, doc.textNodes));
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
messages.sort((a, b) => a.line !== b.line ? a.line - b.line : a.column - b.column);
|
|
355
|
+
return {
|
|
356
|
+
filePath,
|
|
357
|
+
messages,
|
|
358
|
+
errorCount: messages.filter((m) => m.severity === "error").length,
|
|
359
|
+
warningCount: messages.filter((m) => m.severity === "warning").length,
|
|
360
|
+
infoCount: messages.filter((m) => m.severity === "info").length
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function lintFile(filePath, config) {
|
|
364
|
+
const content = readFileSync2(filePath, "utf-8");
|
|
365
|
+
return lintText(content, filePath, config);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/reporter/console.ts
|
|
369
|
+
import chalk from "chalk";
|
|
370
|
+
function severityLabel(severity) {
|
|
371
|
+
switch (severity) {
|
|
372
|
+
case "error":
|
|
373
|
+
return chalk.red("error");
|
|
374
|
+
case "warning":
|
|
375
|
+
return chalk.yellow("warn ");
|
|
376
|
+
case "info":
|
|
377
|
+
return chalk.blue("info ");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function formatMessage(msg) {
|
|
381
|
+
const location = chalk.dim(`${String(msg.line).padStart(4)}:${String(msg.column).padEnd(4)}`);
|
|
382
|
+
const sev = severityLabel(msg.severity);
|
|
383
|
+
const text = msg.message;
|
|
384
|
+
const rule = chalk.dim(msg.ruleId);
|
|
385
|
+
return ` ${location} ${sev} ${text} ${rule}`;
|
|
386
|
+
}
|
|
387
|
+
function formatConsole(results) {
|
|
388
|
+
const lines = [];
|
|
389
|
+
let totalErrors = 0;
|
|
390
|
+
let totalWarnings = 0;
|
|
391
|
+
let totalInfos = 0;
|
|
392
|
+
for (const result of results) {
|
|
393
|
+
if (result.messages.length === 0) continue;
|
|
394
|
+
lines.push("");
|
|
395
|
+
lines.push(chalk.underline(result.filePath));
|
|
396
|
+
for (const msg of result.messages) {
|
|
397
|
+
lines.push(formatMessage(msg));
|
|
398
|
+
}
|
|
399
|
+
totalErrors += result.errorCount;
|
|
400
|
+
totalWarnings += result.warningCount;
|
|
401
|
+
totalInfos += result.infoCount;
|
|
402
|
+
}
|
|
403
|
+
const total = totalErrors + totalWarnings + totalInfos;
|
|
404
|
+
if (total === 0) {
|
|
405
|
+
lines.push(chalk.green("\n0 problems"));
|
|
406
|
+
return lines.join("\n");
|
|
407
|
+
}
|
|
408
|
+
const parts = [];
|
|
409
|
+
if (totalErrors > 0) parts.push(chalk.red(`${totalErrors} error${totalErrors > 1 ? "s" : ""}`));
|
|
410
|
+
if (totalWarnings > 0) parts.push(chalk.yellow(`${totalWarnings} warning${totalWarnings > 1 ? "s" : ""}`));
|
|
411
|
+
if (totalInfos > 0) parts.push(chalk.blue(`${totalInfos} info`));
|
|
412
|
+
lines.push("");
|
|
413
|
+
lines.push(`${total} problem${total > 1 ? "s" : ""} (${parts.join(", ")})`);
|
|
414
|
+
return lines.join("\n");
|
|
415
|
+
}
|
|
416
|
+
function hasErrors(results) {
|
|
417
|
+
return results.some((r) => r.errorCount > 0);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/reporter/json.ts
|
|
421
|
+
function formatJson(results) {
|
|
422
|
+
const output = {
|
|
423
|
+
results,
|
|
424
|
+
summary: {
|
|
425
|
+
totalFiles: results.length,
|
|
426
|
+
filesWithProblems: results.filter((r) => r.messages.length > 0).length,
|
|
427
|
+
totalErrors: results.reduce((s, r) => s + r.errorCount, 0),
|
|
428
|
+
totalWarnings: results.reduce((s, r) => s + r.warningCount, 0),
|
|
429
|
+
totalInfos: results.reduce((s, r) => s + r.infoCount, 0)
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
return JSON.stringify(output, null, 2);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/cli.ts
|
|
436
|
+
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
437
|
+
var pkgPath = resolve2(__dirname, "../package.json");
|
|
438
|
+
var pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
|
|
439
|
+
var INIT_TEMPLATE = `rules:
|
|
440
|
+
# \u7528\u8A9E\u306E\u8868\u8A18\u3086\u308C\u3092\u7D71\u4E00
|
|
441
|
+
- id: term-consistency
|
|
442
|
+
type: dictionary
|
|
443
|
+
description: "\u7528\u8A9E\u306E\u8868\u8A18\u3086\u308C\u3092\u7D71\u4E00"
|
|
444
|
+
severity: warning
|
|
445
|
+
terms:
|
|
446
|
+
- prefer: "\u30B5\u30FC\u30D0\u30FC"
|
|
447
|
+
avoid: ["\u30B5\u30FC\u30D0"]
|
|
448
|
+
- prefer: "\u30E6\u30FC\u30B6\u30FC"
|
|
449
|
+
avoid: ["\u30E6\u30FC\u30B6"]
|
|
450
|
+
|
|
451
|
+
# \u4E00\u6587\u306E\u9577\u3055\u3092\u5236\u9650
|
|
452
|
+
- id: sentence-length
|
|
453
|
+
type: structure
|
|
454
|
+
description: "\u4E00\u6587\u306E\u9577\u3055\u3092\u5236\u9650"
|
|
455
|
+
severity: warning
|
|
456
|
+
target: sentence
|
|
457
|
+
max_chars: 120
|
|
458
|
+
|
|
459
|
+
# \u898B\u51FA\u3057\u306E\u30B9\u30BF\u30A4\u30EB\u3092\u7D71\u4E00
|
|
460
|
+
- id: heading-style
|
|
461
|
+
type: structure
|
|
462
|
+
description: "\u898B\u51FA\u3057\u306E\u30EC\u30D9\u30EB\u3068\u672B\u5C3E\u30D4\u30EA\u30AA\u30C9\u3092\u5236\u9650"
|
|
463
|
+
severity: error
|
|
464
|
+
target: heading
|
|
465
|
+
max_level: 4
|
|
466
|
+
no_period: true
|
|
467
|
+
`;
|
|
468
|
+
var program = new Command();
|
|
469
|
+
program.name("prooflint").description("Declarative text linter for Markdown \u2014 define rules in YAML").version(pkg.version);
|
|
470
|
+
program.command("init").description("Create a .prooflint.yml config file in the current directory").option("--force", "Overwrite existing config file").action((options) => {
|
|
471
|
+
const target = resolve2(process.cwd(), ".prooflint.yml");
|
|
472
|
+
if (existsSync(target) && !options.force) {
|
|
473
|
+
console.error(".prooflint.yml already exists. Use --force to overwrite.");
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
writeFileSync(target, INIT_TEMPLATE, "utf-8");
|
|
477
|
+
console.log(`Created ${target}`);
|
|
478
|
+
});
|
|
479
|
+
program.command("check [patterns...]").description("Lint Markdown files matching the given glob patterns").option("-c, --config <path>", "Path to config file").option("-f, --format <format>", "Output format: console or json", "console").action(async (patterns, options) => {
|
|
480
|
+
const cwd = process.cwd();
|
|
481
|
+
let config;
|
|
482
|
+
try {
|
|
483
|
+
if (options.config) {
|
|
484
|
+
config = loadConfig(resolve2(cwd, options.config));
|
|
485
|
+
} else {
|
|
486
|
+
const configPath = findConfigFile(cwd);
|
|
487
|
+
if (!configPath) {
|
|
488
|
+
console.error("No .prooflint.yml found. Run `prooflint init` to create one.");
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
config = loadConfig(configPath);
|
|
492
|
+
}
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
const targetPatterns = patterns.length > 0 ? patterns : ["**/*.md"];
|
|
498
|
+
const filePaths = [];
|
|
499
|
+
for (const pattern of targetPatterns) {
|
|
500
|
+
try {
|
|
501
|
+
for await (const entry of glob(pattern, { cwd })) {
|
|
502
|
+
const abs = resolve2(cwd, entry);
|
|
503
|
+
if (!filePaths.includes(abs)) filePaths.push(abs);
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
const abs = resolve2(cwd, pattern);
|
|
507
|
+
if (existsSync(abs) && !filePaths.includes(abs)) filePaths.push(abs);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (filePaths.length === 0) {
|
|
511
|
+
console.log("No files found.");
|
|
512
|
+
process.exit(0);
|
|
513
|
+
}
|
|
514
|
+
const results = filePaths.map((fp) => {
|
|
515
|
+
try {
|
|
516
|
+
return lintFile(fp, config);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
console.error(`Error processing ${fp}: ${err instanceof Error ? err.message : String(err)}`);
|
|
519
|
+
process.exit(1);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
const format = options.format ?? "console";
|
|
523
|
+
if (format === "json") {
|
|
524
|
+
console.log(formatJson(results));
|
|
525
|
+
} else {
|
|
526
|
+
const output = formatConsole(results);
|
|
527
|
+
console.log(output);
|
|
528
|
+
}
|
|
529
|
+
if (hasErrors(results)) {
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
program.parse();
|
|
534
|
+
//# sourceMappingURL=cli.js.map
|