hanlint 0.0.7 → 0.0.8
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 +23 -5
- package/data/adjectiveStems.txt +118 -0
- package/data/baselineAnchors.txt +3 -0
- package/data/blindEvaluation.schema.json +26 -0
- package/data/bridgeOpeners.txt +10 -0
- package/data/candidateActionNouns.txt +39 -0
- package/data/candidateSplitEndings.txt +13 -0
- package/data/cliches.json +1 -1
- package/data/confusable.json +2 -2
- package/data/deixis.txt +10 -3
- package/data/draftHistory.txt +10 -0
- package/data/easyWords.json +0 -42
- package/data/enoughMarkers.txt +6 -0
- package/data/entailmentPredictions.schema.json +52 -0
- package/data/euiNouns.txt +3 -4
- package/data/evidenceEntailmentBenchmark.schema.json +126 -0
- package/data/exemplars.json +347 -0
- package/data/holeKinds.json +1 -2
- package/data/iVerbStems.txt +26 -0
- package/data/inNouns.txt +147 -0
- package/data/irregularStems.txt +64 -0
- package/data/nonNouns.txt +153 -0
- package/data/panelAssignmentReview.schema.json +117 -0
- package/data/panelJudgePredictions.schema.json +78 -0
- package/data/panelReviewBatch.schema.json +79 -0
- package/data/panelTrialSet.schema.json +44 -0
- package/data/patterns.json +114 -0
- package/data/profiles.json +1 -0
- package/data/readerCalls.txt +12 -7
- package/data/redundantPair.json +1 -1
- package/data/rieulStems.txt +30 -0
- package/data/ruleCategories.json +56 -0
- package/data/ruleDocs.json +29 -17
- package/data/ruleMechanisms.json +56 -0
- package/data/spacing.json +0 -12
- package/data/spelling.json +6 -24
- package/data/translationese.json +10 -0
- package/data/verbTails.txt +17 -0
- package/data/version.json +1 -1
- package/data/writingBrief.schema.json +64 -0
- package/data/writingBriefV2.schema.json +116 -0
- package/data/writingTrial.schema.json +36 -0
- package/package.json +13 -5
- package/src/analysis/grammar/ending.js +336 -0
- package/src/analysis/grammar/hangul.js +94 -0
- package/src/analysis/grammar/index.js +5 -0
- package/src/analysis/grammar/josa.js +73 -0
- package/src/analysis/grammar/register.js +115 -0
- package/src/analysis/grammar/voice.js +43 -0
- package/src/analysis/index.js +4 -31
- package/src/analysis/josa.js +2 -0
- package/src/analysis/{surface/splitSentences.js → splitSentences.js} +1 -1
- package/src/analysis/{surface/tokenize.js → tokenize.js} +70 -13
- package/src/baseline/store.js +114 -0
- package/src/cli/main.js +506 -58
- package/src/cli/walk.js +30 -0
- package/src/cli/welcome.js +52 -0
- package/src/config/settings.js +103 -8
- package/src/data/exemplars.js +177 -0
- package/src/data/load.js +10 -0
- package/src/data/operations.js +278 -0
- package/src/data/patches.js +125 -0
- package/src/data/patterns.js +48 -0
- package/src/data/profiles.js +102 -0
- package/src/document/model.js +2 -0
- package/src/document/parseMarkdown.js +84 -11
- package/src/edit/applyFixes.js +0 -0
- package/src/fingerprint/build.js +50 -32
- package/src/{rules/shared → fingerprint}/codeBlocks.js +11 -11
- package/src/fingerprint/codeMarkers.js +73 -0
- package/src/fingerprint/markers.js +9 -0
- package/src/fingerprint/readerState.js +126 -0
- package/src/fingerprint/sourceSentence.js +37 -0
- package/src/fingerprint/topics.js +1 -1
- package/src/index.js +2 -16
- package/src/report/compactReport.js +6 -1
- package/src/report/fingerprintJson.js +8 -3
- package/src/report/jsonReport.js +50 -3
- package/src/report/operationMatch.js +31 -0
- package/src/report/patchMatch.js +44 -0
- package/src/report/registerMatch.js +40 -0
- package/src/report/textReport.js +41 -5
- package/src/rules/code/duplicateBlock.js +4 -3
- package/src/rules/code/firstResultDistance.js +1 -0
- package/src/rules/code/inputFileSource.js +23 -68
- package/src/rules/code/installImport.js +4 -4
- package/src/rules/code/platformApi.js +7 -6
- package/src/rules/document/countMismatch.js +22 -6
- package/src/rules/document/enoughOnce.js +25 -0
- package/src/rules/document/fieldEcho.js +38 -0
- package/src/rules/document/keywordHeading.js +27 -0
- package/src/rules/document/keywordMissing.js +1 -0
- package/src/rules/document/noQuestion.js +5 -1
- package/src/rules/document/promiseRecall.js +4 -2
- package/src/rules/document/tableOddCell.js +60 -0
- package/src/rules/finding.js +24 -2
- package/src/rules/orthography/confusable.js +1 -0
- package/src/rules/orthography/spacing.js +1 -0
- package/src/rules/orthography/spelling.js +1 -0
- package/src/rules/paragraph/factListParagraph.js +1 -0
- package/src/rules/paragraph/paraFragment.js +21 -11
- package/src/rules/paragraph/topicBreak.js +1 -0
- package/src/rules/registry.js +72 -3
- package/src/rules/sentence/cliche.js +1 -0
- package/src/rules/sentence/connectorRepeat.js +8 -7
- package/src/rules/sentence/danglingDeixis.js +8 -3
- package/src/rules/sentence/dash.js +2 -1
- package/src/rules/sentence/deixis.js +7 -3
- package/src/rules/sentence/doubleNegative.js +4 -1
- package/src/rules/sentence/doublePassive.js +38 -1
- package/src/rules/sentence/draftHistory.js +24 -0
- package/src/rules/sentence/ellipsis.js +17 -3
- package/src/rules/sentence/endingRepeat.js +6 -2
- package/src/rules/sentence/euiChain.js +3 -2
- package/src/rules/sentence/fillerOpener.js +1 -0
- package/src/rules/sentence/hardWord.js +1 -0
- package/src/rules/sentence/imperativePeriod.js +2 -1
- package/src/rules/sentence/japaneseLoan.js +1 -0
- package/src/rules/sentence/longSentence.js +3 -1
- package/src/rules/sentence/negationRedefine.js +10 -1
- package/src/rules/sentence/nounPile.js +1 -0
- package/src/rules/sentence/numberOrphan.js +45 -0
- package/src/rules/sentence/outsideProfile.js +68 -0
- package/src/rules/sentence/redundantPair.js +1 -0
- package/src/rules/sentence/translationese.js +1 -0
- package/src/rules/shared/candidates.js +129 -0
- package/src/rules/shared/dictionaryRule.js +12 -2
- package/src/rules/shared/localAntecedent.js +12 -0
- package/src/rules/shared/repeat.js +48 -0
- package/src/rules/structure/blockUnread.js +33 -0
- package/src/rules/structure/bridgeRepeat.js +40 -0
- package/src/rules/structure/emojiBullet.js +1 -0
- package/src/rules/structure/headingQuestion.js +19 -0
- package/src/rules/structure/headingSentence.js +9 -4
- package/src/rules/structure/headingSkip.js +1 -0
- package/src/rules/structure/headingUniform.js +9 -17
- package/src/rules/structure/introImage.js +19 -0
- package/src/rules/structure/introLong.js +1 -0
- package/src/rules/structure/loneSubheading.js +42 -0
- package/src/rules/structure/moreLater.js +48 -0
- package/src/rules/structure/sectionNoProse.js +1 -0
- package/src/rules/structure/sectionResult.js +3 -2
- package/src/text.js +19 -0
- package/src/rules/document/readerAbsent.js +0 -11
- package/src/rules/shared/runs.js +0 -22
package/src/cli/walk.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 폴더에서 검사할 마크다운을 찾는 규칙. 첫 화면과 검사가 **같은 함수**를 쓴다.
|
|
4
|
+
* 무엇을 건너뛰고 왜 그러는지는 파이썬 `cli/commands/shared.py` 의 SKIPPED_FOLDERS 가 소유한다.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** 폴더를 주면 이 확장자만 찾는다. */
|
|
10
|
+
export const MARKDOWN = [".md", ".markdown"];
|
|
11
|
+
/** 폴더를 훑을 때 안 들어가는 이름. 점으로 시작하는 폴더도 함께 건너뛴다. */
|
|
12
|
+
export const SKIPPED_FOLDERS = ["node_modules"];
|
|
13
|
+
|
|
14
|
+
/** @param {string} name */
|
|
15
|
+
export function isSkipped(name) {
|
|
16
|
+
return name.startsWith(".") || SKIPPED_FOLDERS.includes(name);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 폴더 아래 마크다운. 건너뛸 폴더에는 안 들어간다. 경로 문자열 순이라 두 판이 같은 차례를 낸다. @param {string} folder */
|
|
20
|
+
export function markdownUnder(folder) {
|
|
21
|
+
/** @type {string[]} */
|
|
22
|
+
const found = [];
|
|
23
|
+
for (const name of [...readdirSync(folder)].sort()) {
|
|
24
|
+
const path = join(folder, name);
|
|
25
|
+
if (statSync(path).isDirectory()) {
|
|
26
|
+
if (!isSkipped(name)) found.push(...markdownUnder(path));
|
|
27
|
+
} else if (MARKDOWN.includes(name.slice(name.lastIndexOf(".")).toLowerCase())) found.push(path);
|
|
28
|
+
}
|
|
29
|
+
return found.sort();
|
|
30
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* `hanlint` 를 인자 없이 쳤을 때의 첫 화면. 파이썬 cli/welcome.py 와 같은 글자다.
|
|
4
|
+
* 같은 폴더에서 두 판을 돌리면 결과가 같다. 파일을 찾는 것은 검사와 같은 함수 (walk.markdownUnder) 다.
|
|
5
|
+
*/
|
|
6
|
+
import { relative, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { markdownUnder } from "./walk.js";
|
|
9
|
+
|
|
10
|
+
/** 현재 폴더에서 예시로 보일 파일 수. 넷을 넘으면 목록이 안내를 밀어낸다. */
|
|
11
|
+
const SAMPLE_LIMIT = 3;
|
|
12
|
+
|
|
13
|
+
/** 이 폴더에서 검사가 실제로 볼 마크다운. 폴더 기준 상대 경로라 그대로 복사해 칠 수 있다. @param {string} folder */
|
|
14
|
+
export function nearbyMarkdown(folder) {
|
|
15
|
+
try {
|
|
16
|
+
return markdownUnder(folder)
|
|
17
|
+
.slice(0, SAMPLE_LIMIT)
|
|
18
|
+
.map((path) => relative(folder, path).split(sep).join("/"));
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {string} version @param {string} [folder] */
|
|
25
|
+
export function welcome(version, folder) {
|
|
26
|
+
const where = folder ?? process.cwd();
|
|
27
|
+
const nearby = nearbyMarkdown(where);
|
|
28
|
+
const example = nearby.length ? nearby[0] : "글.md";
|
|
29
|
+
const shown = [
|
|
30
|
+
[`hanlint ${example}`, "검사한다. 자리와 이유와 고칠 말이 나온다"],
|
|
31
|
+
[`hanlint fix ${example}`, "기계가 확실히 고칠 수 있는 자리를 원문에 적용한다"],
|
|
32
|
+
[`hanlint audit ${example}`, "글의 모양을 지도와 분포로 본다"],
|
|
33
|
+
];
|
|
34
|
+
const width = Math.max(...shown.map(([command]) => command.length));
|
|
35
|
+
const lines = [
|
|
36
|
+
`hanlint ${version} 한국어 글에서 세면 확정되는 결함을 집는다. 좋은 글인지는 판정하지 않는다`,
|
|
37
|
+
"",
|
|
38
|
+
...shown.map(([command, why]) => ` ${command.padEnd(width)} ${why}`),
|
|
39
|
+
];
|
|
40
|
+
if (nearby.length) {
|
|
41
|
+
lines.push("", `이 폴더의 마크다운: ${nearby.join(", ")}. 폴더를 통째로 줘도 된다 (hanlint .)`);
|
|
42
|
+
} else {
|
|
43
|
+
lines.push("", "이 폴더에는 검사할 마크다운이 없다. 파일 하나나 폴더 하나를 인자로 준다");
|
|
44
|
+
}
|
|
45
|
+
lines.push(
|
|
46
|
+
"",
|
|
47
|
+
"처음이면 hanlint init 으로 설정을 만든다. 글의 종류가 블로그가 아니면 --preset report 나 --preset docs",
|
|
48
|
+
"규칙 목록은 hanlint rules, 규칙 하나가 왜 있는지는 hanlint explain <규칙>, 지금 상태는 hanlint doctor",
|
|
49
|
+
"전체 사용법은 hanlint --help",
|
|
50
|
+
);
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
package/src/config/settings.js
CHANGED
|
@@ -4,19 +4,71 @@
|
|
|
4
4
|
* 기본값을 바꾸면 양쪽을 같은 작업에서 바꾼다. tests/parity 가 두 구현의 결과를 견준다.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { projectExemplars } from "../data/exemplars.js";
|
|
8
|
+
import { projectOperations } from "../data/operations.js";
|
|
9
|
+
import { projectPatches } from "../data/patches.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 글의 종류마다 처음부터 끄고 시작할 규칙. 정본은 파이썬 config/settings.py 의 PRESETS 다.
|
|
13
|
+
* @type {Record<string, string[]>}
|
|
14
|
+
*/
|
|
15
|
+
export const PRESETS = {
|
|
16
|
+
blog: [],
|
|
17
|
+
report: ["noQuestion", "sectionResult", "firstResultDistance", "introImage", "moreLater", "numberOrphan"],
|
|
18
|
+
docs: [
|
|
19
|
+
"noQuestion",
|
|
20
|
+
"sectionResult",
|
|
21
|
+
"firstResultDistance",
|
|
22
|
+
"introImage",
|
|
23
|
+
"moreLater",
|
|
24
|
+
"draftHistory",
|
|
25
|
+
"blockUnread",
|
|
26
|
+
"numberOrphan",
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
PRESETS.guide = PRESETS.blog;
|
|
30
|
+
PRESETS.essay = PRESETS.report;
|
|
31
|
+
PRESETS.fiction = PRESETS.report;
|
|
32
|
+
PRESETS.encyclopedia = PRESETS.docs;
|
|
33
|
+
|
|
34
|
+
/** 프리셋 → 견줄 프로파일의 종류. 정본은 파이썬 config/settings.py 의 PROFILE_OF 다. @type {Record<string, string>} */
|
|
35
|
+
export const PROFILE_OF = {
|
|
36
|
+
blog: "blog",
|
|
37
|
+
report: "report",
|
|
38
|
+
docs: "technicalDocs",
|
|
39
|
+
guide: "guide",
|
|
40
|
+
essay: "essay",
|
|
41
|
+
fiction: "fiction",
|
|
42
|
+
encyclopedia: "encyclopedia",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const PRESET_NAMES = Object.keys(PRESETS);
|
|
46
|
+
/** 설정도 옵션도 없을 때의 종류. 이 이름일 때는 출력에 프리셋을 적지 않는다. */
|
|
47
|
+
export const DEFAULT_PRESET = PRESET_NAMES[0];
|
|
8
48
|
|
|
9
49
|
/**
|
|
10
50
|
* @typedef {object} Config
|
|
51
|
+
* @property {string} preset 글의 종류. PRESETS 가 정한 규칙을 처음부터 끈다
|
|
11
52
|
* @property {Set<string>} disable 끌 규칙 이름
|
|
12
|
-
* @property {string} analyzer
|
|
13
53
|
* @property {string | null} keywordField
|
|
14
|
-
* @property {string
|
|
54
|
+
* @property {string[]} introFields
|
|
55
|
+
* @property {string[]} endingFields
|
|
56
|
+
* @property {string | null} profile 사용자 프로파일 파일 경로
|
|
57
|
+
* @property {number} profilePercentile outsideProfile 이 짚는 백분위
|
|
58
|
+
* @property {number} countMismatchSpan 절이 없는 글이 이 줄 수 안일 때만 글 전체를 견준다
|
|
59
|
+
* @property {string | null} baseline
|
|
15
60
|
* @property {Record<string, unknown[]>} dictionary
|
|
61
|
+
* @property {import("../data/exemplars.js").Exemplar[]} exemplars 사람이 승인한 프로젝트 본보기
|
|
62
|
+
* @property {import("../data/patches.js").Patch[]} patches 사람이 승인한 국소 고침
|
|
63
|
+
* @property {import("../data/operations.js").SurfaceOperation[]} operations 사람이 승인한 표면 치환
|
|
64
|
+
* @property {string[]} protectedTerms 표면 치환이 바꾸면 안 되는 한국어 고유명사와 프로젝트 용어
|
|
65
|
+
* @property {string[]} ignoreFences 지문에서 뺄 펜스의 언어 표기. 장면 계약과 도표 원문은 코드 블록이 아니다
|
|
16
66
|
* @property {string | null} source 설정을 읽은 파일. 기본값이면 null. loadConfig 가 채운다
|
|
17
67
|
* @property {number} fragmentRun
|
|
18
68
|
* @property {number} introMaxParagraphs
|
|
19
69
|
* @property {number} headingUniformRatio
|
|
70
|
+
* @property {number} headingSentenceMaxLevel
|
|
71
|
+
* @property {number} bridgeRepeatMin
|
|
20
72
|
* @property {number} nounPileMin
|
|
21
73
|
* @property {number} endingRun
|
|
22
74
|
* @property {number} factListMinSentences
|
|
@@ -26,20 +78,37 @@ export const ANALYZERS = ["surface", "kiwi"];
|
|
|
26
78
|
* @property {number} duplicateBlockRatio
|
|
27
79
|
* @property {number} firstResultMaxParagraphs
|
|
28
80
|
* @property {number} sectionResultMinParagraphs
|
|
81
|
+
* @property {number} introMaxImages
|
|
82
|
+
* @property {number} headingQuestionRatio
|
|
83
|
+
* @property {number} moreLaterMaxChars
|
|
84
|
+
* @property {number} tableOddCellMinRows
|
|
85
|
+
* @property {number} registerMinShare
|
|
29
86
|
*/
|
|
30
87
|
|
|
31
88
|
/** @returns {Config} */
|
|
32
89
|
export function defaultConfig() {
|
|
33
90
|
return {
|
|
91
|
+
preset: DEFAULT_PRESET,
|
|
34
92
|
disable: new Set(),
|
|
35
|
-
analyzer: "surface",
|
|
36
93
|
keywordField: null,
|
|
94
|
+
introFields: [],
|
|
95
|
+
endingFields: [],
|
|
37
96
|
profile: null,
|
|
97
|
+
profilePercentile: 99,
|
|
98
|
+
countMismatchSpan: 60,
|
|
99
|
+
baseline: null,
|
|
38
100
|
dictionary: {},
|
|
101
|
+
exemplars: [],
|
|
102
|
+
patches: [],
|
|
103
|
+
operations: [],
|
|
104
|
+
protectedTerms: [],
|
|
105
|
+
ignoreFences: [],
|
|
39
106
|
source: null,
|
|
40
107
|
fragmentRun: 3,
|
|
41
108
|
introMaxParagraphs: 4,
|
|
42
109
|
headingUniformRatio: 0.75,
|
|
110
|
+
headingSentenceMaxLevel: 6,
|
|
111
|
+
bridgeRepeatMin: 3,
|
|
43
112
|
nounPileMin: 5,
|
|
44
113
|
endingRun: 4,
|
|
45
114
|
factListMinSentences: 3,
|
|
@@ -49,12 +118,23 @@ export function defaultConfig() {
|
|
|
49
118
|
duplicateBlockRatio: 0.9,
|
|
50
119
|
firstResultMaxParagraphs: 4,
|
|
51
120
|
sectionResultMinParagraphs: 3,
|
|
121
|
+
introMaxImages: 1,
|
|
122
|
+
headingQuestionRatio: 0.5,
|
|
123
|
+
moreLaterMaxChars: 150,
|
|
124
|
+
tableOddCellMinRows: 4,
|
|
125
|
+
// 기준 말뭉치에서 일관된 에세이 하위 5%는 0.7576, 실제 혼합 기사는 0.625였다.
|
|
126
|
+
registerMinShare: 0.7,
|
|
52
127
|
};
|
|
53
128
|
}
|
|
54
129
|
|
|
55
130
|
/** @param {Config} config @param {string} ruleName */
|
|
56
131
|
export function enabled(config, ruleName) {
|
|
57
|
-
return !config.disable.has(ruleName);
|
|
132
|
+
return !config.disable.has(ruleName) && !PRESETS[config.preset].includes(ruleName);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 지금 꺼져 있는 규칙 이름. 프리셋이 끈 것과 disable 이 끈 것을 합친다. @param {Config} config */
|
|
136
|
+
export function offRules(config) {
|
|
137
|
+
return [...new Set([...PRESETS[config.preset], ...config.disable])].sort();
|
|
58
138
|
}
|
|
59
139
|
|
|
60
140
|
/** @param {Record<string, unknown>} data @returns {Config} */
|
|
@@ -64,12 +144,27 @@ export function configFromMapping(data) {
|
|
|
64
144
|
if (key === "disable") {
|
|
65
145
|
config.disable = new Set(/** @type {string[]} */ (value));
|
|
66
146
|
} else if (key === "analyzer") {
|
|
67
|
-
|
|
68
|
-
|
|
147
|
+
// 0.0.7 까지의 키. hanlint init 이 surface 를 써 넣었으므로 그 값은 조용히 넘기고 다른 값은 빠졌다고 알린다.
|
|
148
|
+
if (value !== "surface") throw new Error(`analyzer 설정은 빠졌다. 분석기는 표층 하나라 키를 지운다: ${JSON.stringify(value)}`);
|
|
149
|
+
} else if (key === "preset") {
|
|
150
|
+
if (!(/** @type {string} */ (value) in PRESETS)) {
|
|
151
|
+
throw new Error(`preset 은 ${PRESET_NAMES.join(", ")} 가운데 하나다: ${JSON.stringify(value)}`);
|
|
69
152
|
}
|
|
70
|
-
config.
|
|
153
|
+
config.preset = /** @type {string} */ (value);
|
|
71
154
|
} else if (key === "dictionary") {
|
|
72
155
|
config.dictionary = { .../** @type {Record<string, unknown[]>} */ (value) };
|
|
156
|
+
} else if (key === "exemplars") {
|
|
157
|
+
config.exemplars = projectExemplars(value, PRESET_NAMES);
|
|
158
|
+
} else if (key === "patches") {
|
|
159
|
+
config.patches = projectPatches(value, PRESET_NAMES);
|
|
160
|
+
} else if (key === "operations") {
|
|
161
|
+
config.operations = projectOperations(value, PRESET_NAMES);
|
|
162
|
+
} else if (key === "ignoreFences" || key === "introFields" || key === "endingFields" || key === "protectedTerms") {
|
|
163
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim())) {
|
|
164
|
+
throw new Error(`${key} 는 문자열 배열이다: ${JSON.stringify(value)}`);
|
|
165
|
+
}
|
|
166
|
+
const names = value.map((item) => item.trim());
|
|
167
|
+
config[key] = key === "ignoreFences" ? names.map((name) => name.toLowerCase()) : names;
|
|
73
168
|
} else if (key !== "source" && key in config) {
|
|
74
169
|
config[key] = value;
|
|
75
170
|
} else {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 본보기. 규칙마다 고치기 전과 후의 짝. 파이썬 data/exemplars.py 와 같다.
|
|
4
|
+
*
|
|
5
|
+
* 지적은 무엇이 틀렸는지 말하고 본보기는 무엇이 맞는지 보인다. 자리표시자 규약은 fixture 와 같다.
|
|
6
|
+
* `{em}` 은 긴 줄표, `{dot}` 은 마침표다.
|
|
7
|
+
*/
|
|
8
|
+
import { loadEntries } from "./load.js";
|
|
9
|
+
|
|
10
|
+
const EM_DASH = String.fromCharCode(0x2014);
|
|
11
|
+
const EN_DASH = String.fromCharCode(0x2013);
|
|
12
|
+
const ELLIPSIS_CHAR = String.fromCharCode(0x2026);
|
|
13
|
+
/** 한 줄 본보기의 표시 폭 상한. 글자 수가 아니라 폭이다. 뜻은 파이썬 data/exemplars.py 가 소유한다. */
|
|
14
|
+
const ONE_LINE_LIMIT = 96;
|
|
15
|
+
/** 칸을 둘 먹는 글자. 한중일 W 와 F 다. 파이썬 unicodedata.east_asian_width 와 같은 범위를 든다. */
|
|
16
|
+
const WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} Exemplar
|
|
20
|
+
* @property {string} rule
|
|
21
|
+
* @property {string} before 그 규칙에 실제로 잡히는 글
|
|
22
|
+
* @property {string} after 같은 뜻이면서 잡히지 않는 글
|
|
23
|
+
* @property {string} moved 무엇이 달라졌는지 한 마디
|
|
24
|
+
* @property {string[]} presets 비어 있으면 기본, 값이 있으면 그 프리셋의 문맥 본보기
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @param {string} text */
|
|
28
|
+
export function expand(text) {
|
|
29
|
+
return text.split("{em}").join(EM_DASH).split("{en}").join(EN_DASH).split("{dot}").join(".");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 터미널이 먹는 칸 수. 한중일 글자는 둘, 나머지는 하나다. @param {string} text */
|
|
33
|
+
export function displayWidth(text) {
|
|
34
|
+
let width = 0;
|
|
35
|
+
for (const ch of text) width += WIDE.test(ch) ? 2 : 1;
|
|
36
|
+
return width;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 한 줄로 보일 때의 꼴. 줄바꿈은 공백으로 눕히고 폭이 넘치면 자른다. @param {string} text @param {number} [limit] */
|
|
40
|
+
export function shorten(text, limit = ONE_LINE_LIMIT) {
|
|
41
|
+
const flat = text.split(/\s+/).filter(Boolean).join(" ");
|
|
42
|
+
if (displayWidth(flat) <= limit) return flat;
|
|
43
|
+
let kept = "";
|
|
44
|
+
let used = 0;
|
|
45
|
+
for (const ch of flat) {
|
|
46
|
+
const step = WIDE.test(ch) ? 2 : 1;
|
|
47
|
+
if (used + step > limit - 1) break;
|
|
48
|
+
kept += ch;
|
|
49
|
+
used += step;
|
|
50
|
+
}
|
|
51
|
+
return kept + ELLIPSIS_CHAR;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {string} text */
|
|
55
|
+
export function isShortened(text) {
|
|
56
|
+
return displayWidth(text.split(/\s+/).filter(Boolean).join(" ")) > ONE_LINE_LIMIT;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @type {Exemplar[] | null} */
|
|
60
|
+
let allCache = null;
|
|
61
|
+
/** @type {Map<string, Exemplar> | null} */
|
|
62
|
+
let defaultCache = null;
|
|
63
|
+
|
|
64
|
+
/** 기본과 문맥 본보기를 모두 읽는다. @returns {Exemplar[]} */
|
|
65
|
+
export function allExemplars() {
|
|
66
|
+
if (!allCache) {
|
|
67
|
+
allCache = [];
|
|
68
|
+
const defaults = new Set();
|
|
69
|
+
/** @type {Map<string, Set<string>>} */
|
|
70
|
+
const contexts = new Map();
|
|
71
|
+
for (const entry of loadEntries("exemplars.json")) {
|
|
72
|
+
const rule = /** @type {string} */ (entry.rule);
|
|
73
|
+
const presets = /** @type {string[]} */ (entry.presets ?? []);
|
|
74
|
+
if (!presets.length) {
|
|
75
|
+
if (defaults.has(rule)) throw new Error(`기본 본보기가 겹친다: ${rule}`);
|
|
76
|
+
defaults.add(rule);
|
|
77
|
+
} else {
|
|
78
|
+
const used = contexts.get(rule) ?? new Set();
|
|
79
|
+
const overlap = presets.filter((preset) => used.has(preset)).sort();
|
|
80
|
+
if (overlap.length) throw new Error(`문맥 본보기의 프리셋이 겹친다: ${rule} ${overlap.join(", ")}`);
|
|
81
|
+
for (const preset of presets) used.add(preset);
|
|
82
|
+
contexts.set(rule, used);
|
|
83
|
+
}
|
|
84
|
+
allCache.push({
|
|
85
|
+
rule,
|
|
86
|
+
before: expand(/** @type {string} */ (entry.before)),
|
|
87
|
+
after: expand(/** @type {string} */ (entry.after)),
|
|
88
|
+
moved: /** @type {string} */ (entry.moved),
|
|
89
|
+
presets,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return allCache;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 규칙 이름 → 본보기. @returns {Map<string, Exemplar>} */
|
|
97
|
+
export function exemplars() {
|
|
98
|
+
if (!defaultCache) {
|
|
99
|
+
defaultCache = new Map(
|
|
100
|
+
allExemplars().filter((exemplar) => !exemplar.presets.length).map((exemplar) => [exemplar.rule, exemplar]),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return defaultCache;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 설정의 `[[exemplars]]` 를 검증한다. @param {unknown} entries @param {string[]} presetNames @returns {Exemplar[]} */
|
|
107
|
+
export function projectExemplars(entries, presetNames) {
|
|
108
|
+
if (!Array.isArray(entries)) throw new Error("exemplars 는 [[exemplars]] 배열이다");
|
|
109
|
+
const allowedKeys = new Set(["rule", "before", "after", "moved", "presets"]);
|
|
110
|
+
const knownRules = new Set(exemplars().keys());
|
|
111
|
+
const knownPresets = new Set(presetNames);
|
|
112
|
+
const defaults = new Set();
|
|
113
|
+
/** @type {Map<string, Set<string>>} */
|
|
114
|
+
const contexts = new Map();
|
|
115
|
+
return entries.map((raw, offset) => {
|
|
116
|
+
const index = offset + 1;
|
|
117
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`exemplars ${index}번째 항목은 표다`);
|
|
118
|
+
const entry = /** @type {Record<string, unknown>} */ (raw);
|
|
119
|
+
const unknown = Object.keys(entry).filter((key) => !allowedKeys.has(key)).sort();
|
|
120
|
+
if (unknown.length) throw new Error(`exemplars ${index}번째 항목의 모르는 키: ${unknown.join(", ")}`);
|
|
121
|
+
/** @type {Record<string, string>} */
|
|
122
|
+
const values = {};
|
|
123
|
+
for (const key of ["rule", "before", "after", "moved"]) {
|
|
124
|
+
const value = entry[key];
|
|
125
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
126
|
+
throw new Error(`exemplars ${index}번째 항목의 ${key} 는 비지 않은 문자열이다`);
|
|
127
|
+
}
|
|
128
|
+
values[key] = value;
|
|
129
|
+
}
|
|
130
|
+
const rule = values.rule;
|
|
131
|
+
if (!knownRules.has(rule)) throw new Error(`exemplars ${index}번째 항목의 모르는 규칙: ${rule}`);
|
|
132
|
+
const rawPresets = entry.presets ?? [];
|
|
133
|
+
if (!Array.isArray(rawPresets) || !rawPresets.every((item) => typeof item === "string")) {
|
|
134
|
+
throw new Error(`exemplars ${index}번째 항목의 presets 는 문자열 배열이다`);
|
|
135
|
+
}
|
|
136
|
+
const presets = /** @type {string[]} */ (rawPresets);
|
|
137
|
+
const unknownPresets = [...new Set(presets.filter((preset) => !knownPresets.has(preset)))].sort();
|
|
138
|
+
if (unknownPresets.length) {
|
|
139
|
+
throw new Error(`exemplars ${index}번째 항목의 모르는 프리셋: ${unknownPresets.join(", ")}`);
|
|
140
|
+
}
|
|
141
|
+
if (new Set(presets).size !== presets.length) throw new Error(`프로젝트 본보기의 프리셋이 겹친다: ${rule}`);
|
|
142
|
+
if (!presets.length) {
|
|
143
|
+
if (defaults.has(rule)) throw new Error(`프로젝트 기본 본보기가 겹친다: ${rule}`);
|
|
144
|
+
defaults.add(rule);
|
|
145
|
+
} else {
|
|
146
|
+
const used = contexts.get(rule) ?? new Set();
|
|
147
|
+
const overlap = presets.filter((preset) => used.has(preset)).sort();
|
|
148
|
+
if (overlap.length) throw new Error(`프로젝트 본보기의 프리셋이 겹친다: ${rule} ${overlap.join(", ")}`);
|
|
149
|
+
for (const preset of presets) used.add(preset);
|
|
150
|
+
contexts.set(rule, used);
|
|
151
|
+
}
|
|
152
|
+
return { rule, before: expand(values.before), after: expand(values.after), moved: values.moved, presets };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @param {string} rule @param {string | null | undefined} [preset] @param {Exemplar[]} [customExemplars] @returns {Exemplar | undefined} */
|
|
157
|
+
export function exemplarFor(rule, preset = null, customExemplars = []) {
|
|
158
|
+
if (preset) {
|
|
159
|
+
const contextual = customExemplars.find((exemplar) => exemplar.rule === rule && exemplar.presets.includes(preset));
|
|
160
|
+
if (contextual) return contextual;
|
|
161
|
+
const builtInContextual = allExemplars().find((exemplar) => exemplar.rule === rule && exemplar.presets.includes(preset));
|
|
162
|
+
if (builtInContextual) return builtInContextual;
|
|
163
|
+
}
|
|
164
|
+
const customDefault = customExemplars.find((exemplar) => exemplar.rule === rule && !exemplar.presets.length);
|
|
165
|
+
if (customDefault) return customDefault;
|
|
166
|
+
return exemplars().get(rule);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 전과 후를 제 줄씩. @param {Exemplar} exemplar @returns {[string, string]} */
|
|
170
|
+
export function twoLines(exemplar) {
|
|
171
|
+
return [shorten(exemplar.before), shorten(exemplar.after)];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 어느 한쪽이라도 잘렸나. @param {Exemplar} exemplar */
|
|
175
|
+
export function shortened(exemplar) {
|
|
176
|
+
return isShortened(exemplar.before) || isShortened(exemplar.after);
|
|
177
|
+
}
|
package/src/data/load.js
CHANGED
|
@@ -57,6 +57,16 @@ export function loadRuleDocs() {
|
|
|
57
57
|
return cached("ruleDocs", () => JSON.parse(readText("ruleDocs.json")));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** @returns {Record<string, string>} 규칙 이름 → 부류 (규칙 파일이 사는 폴더의 투영) */
|
|
61
|
+
export function loadRuleCategories() {
|
|
62
|
+
return cached("ruleCategories", () => JSON.parse(readText("ruleCategories.json")));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @returns {Record<string, string>} 규칙 이름 → 기제 (파이썬 등록부의 투영) */
|
|
66
|
+
export function loadRuleMechanisms() {
|
|
67
|
+
return cached("ruleMechanisms", () => JSON.parse(readText("ruleMechanisms.json")));
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/** @returns {string} */
|
|
61
71
|
export function loadVersion() {
|
|
62
72
|
return cached("version", () => JSON.parse(readText("version.json")).version);
|