hanlint 0.0.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.
Files changed (106) hide show
  1. package/README.md +27 -0
  2. package/bin/hanlint.js +4 -0
  3. package/data/causalMarkers.txt +25 -0
  4. package/data/cliches.json +99 -0
  5. package/data/confusable.json +76 -0
  6. package/data/connectors.txt +18 -0
  7. package/data/countUnits.txt +6 -0
  8. package/data/coverageTypes.txt +7 -0
  9. package/data/deixis.txt +8 -0
  10. package/data/easyWords.json +340 -0
  11. package/data/emphasisWords.txt +11 -0
  12. package/data/endings.txt +9 -0
  13. package/data/euiNouns.txt +40 -0
  14. package/data/hedges.txt +15 -0
  15. package/data/hiddenDeps.txt +11 -0
  16. package/data/holeKinds.json +145 -0
  17. package/data/japaneseLoan.json +82 -0
  18. package/data/josa.txt +54 -0
  19. package/data/koreanNumbers.txt +22 -0
  20. package/data/passiveStems.txt +53 -0
  21. package/data/platformApis.txt +12 -0
  22. package/data/promiseMarkers.txt +7 -0
  23. package/data/pythonPackages.txt +35 -0
  24. package/data/pythonStdlib.txt +193 -0
  25. package/data/readerCalls.txt +14 -0
  26. package/data/recallMarkers.txt +6 -0
  27. package/data/redundantPair.json +70 -0
  28. package/data/ruleDocs.json +44 -0
  29. package/data/spacing.json +76 -0
  30. package/data/spelling.json +166 -0
  31. package/data/stopwords.txt +33 -0
  32. package/data/translationese.json +104 -0
  33. package/data/verbTails.txt +56 -0
  34. package/data/version.json +1 -0
  35. package/package.json +39 -0
  36. package/src/analysis/index.js +34 -0
  37. package/src/analysis/surface/splitSentences.js +36 -0
  38. package/src/analysis/surface/tokenize.js +162 -0
  39. package/src/cli/main.js +376 -0
  40. package/src/config/loadConfig.js +51 -0
  41. package/src/config/settings.js +80 -0
  42. package/src/config/toml.js +239 -0
  43. package/src/data/load.js +63 -0
  44. package/src/document/model.js +55 -0
  45. package/src/document/parseMarkdown.js +183 -0
  46. package/src/document/plainText.js +43 -0
  47. package/src/edit/applyFixes.js +0 -0
  48. package/src/fingerprint/build.js +284 -0
  49. package/src/fingerprint/dictionaries.js +118 -0
  50. package/src/fingerprint/markers.js +175 -0
  51. package/src/fingerprint/topics.js +42 -0
  52. package/src/index.js +68 -0
  53. package/src/regex.js +47 -0
  54. package/src/report/compactReport.js +9 -0
  55. package/src/report/fingerprintJson.js +126 -0
  56. package/src/report/githubReport.js +13 -0
  57. package/src/report/jsonReport.js +16 -0
  58. package/src/report/textReport.js +19 -0
  59. package/src/rules/code/duplicateBlock.js +48 -0
  60. package/src/rules/code/firstResultDistance.js +23 -0
  61. package/src/rules/code/inputFileSource.js +103 -0
  62. package/src/rules/code/installImport.js +126 -0
  63. package/src/rules/code/platformApi.js +36 -0
  64. package/src/rules/document/countMismatch.js +42 -0
  65. package/src/rules/document/keywordMissing.js +21 -0
  66. package/src/rules/document/noQuestion.js +11 -0
  67. package/src/rules/document/promiseRecall.js +12 -0
  68. package/src/rules/document/readerAbsent.js +11 -0
  69. package/src/rules/finding.js +53 -0
  70. package/src/rules/orthography/confusable.js +9 -0
  71. package/src/rules/orthography/spacing.js +9 -0
  72. package/src/rules/orthography/spelling.js +9 -0
  73. package/src/rules/paragraph/factListParagraph.js +27 -0
  74. package/src/rules/paragraph/paraFragment.js +29 -0
  75. package/src/rules/paragraph/topicBreak.js +25 -0
  76. package/src/rules/registry.js +147 -0
  77. package/src/rules/sentence/cliche.js +9 -0
  78. package/src/rules/sentence/connectorRepeat.js +23 -0
  79. package/src/rules/sentence/danglingDeixis.js +19 -0
  80. package/src/rules/sentence/dash.js +21 -0
  81. package/src/rules/sentence/deixis.js +19 -0
  82. package/src/rules/sentence/doubleNegative.js +28 -0
  83. package/src/rules/sentence/doublePassive.js +17 -0
  84. package/src/rules/sentence/ellipsis.js +17 -0
  85. package/src/rules/sentence/endingRepeat.js +25 -0
  86. package/src/rules/sentence/euiChain.js +18 -0
  87. package/src/rules/sentence/fillerOpener.js +18 -0
  88. package/src/rules/sentence/hardWord.js +10 -0
  89. package/src/rules/sentence/imperativePeriod.js +20 -0
  90. package/src/rules/sentence/japaneseLoan.js +9 -0
  91. package/src/rules/sentence/longSentence.js +20 -0
  92. package/src/rules/sentence/negationRedefine.js +22 -0
  93. package/src/rules/sentence/nounPile.js +20 -0
  94. package/src/rules/sentence/redundantPair.js +9 -0
  95. package/src/rules/sentence/translationese.js +9 -0
  96. package/src/rules/shared/codeBlocks.js +44 -0
  97. package/src/rules/shared/dictionaryRule.js +35 -0
  98. package/src/rules/shared/runs.js +22 -0
  99. package/src/rules/structure/emojiBullet.js +34 -0
  100. package/src/rules/structure/headingSentence.js +17 -0
  101. package/src/rules/structure/headingSkip.js +18 -0
  102. package/src/rules/structure/headingUniform.js +36 -0
  103. package/src/rules/structure/introLong.js +20 -0
  104. package/src/rules/structure/sectionNoProse.js +16 -0
  105. package/src/rules/structure/sectionResult.js +37 -0
  106. package/src/text.js +63 -0
@@ -0,0 +1,80 @@
1
+ // @ts-check
2
+ /**
3
+ * 설정과 임계 기본값. 정본은 파이썬 config/settings.py 이고 여기는 같은 값을 든다. 필드 이름이 곧 설정 파일의 키다.
4
+ * 기본값을 바꾸면 양쪽을 같은 작업에서 바꾼다. tests/parity 가 두 구현의 결과를 견준다.
5
+ */
6
+
7
+ export const ANALYZERS = ["surface", "kiwi"];
8
+
9
+ /**
10
+ * @typedef {object} Config
11
+ * @property {Set<string>} disable 끌 규칙 이름
12
+ * @property {string} analyzer
13
+ * @property {string | null} keywordField
14
+ * @property {string | null} profile
15
+ * @property {Record<string, unknown[]>} dictionary
16
+ * @property {string | null} source 설정을 읽은 파일. 기본값이면 null. loadConfig 가 채운다
17
+ * @property {number} fragmentRun
18
+ * @property {number} introMaxParagraphs
19
+ * @property {number} headingUniformRatio
20
+ * @property {number} nounPileMin
21
+ * @property {number} endingRun
22
+ * @property {number} factListMinSentences
23
+ * @property {number} factListMaxMeanLength
24
+ * @property {number} topicBreakMinSentences
25
+ * @property {number} longSentenceMax
26
+ * @property {number} duplicateBlockRatio
27
+ * @property {number} firstResultMaxParagraphs
28
+ * @property {number} sectionResultMinParagraphs
29
+ */
30
+
31
+ /** @returns {Config} */
32
+ export function defaultConfig() {
33
+ return {
34
+ disable: new Set(),
35
+ analyzer: "surface",
36
+ keywordField: null,
37
+ profile: null,
38
+ dictionary: {},
39
+ source: null,
40
+ fragmentRun: 3,
41
+ introMaxParagraphs: 4,
42
+ headingUniformRatio: 0.75,
43
+ nounPileMin: 5,
44
+ endingRun: 4,
45
+ factListMinSentences: 3,
46
+ factListMaxMeanLength: 8.0,
47
+ topicBreakMinSentences: 2,
48
+ longSentenceMax: 30,
49
+ duplicateBlockRatio: 0.9,
50
+ firstResultMaxParagraphs: 4,
51
+ sectionResultMinParagraphs: 3,
52
+ };
53
+ }
54
+
55
+ /** @param {Config} config @param {string} ruleName */
56
+ export function enabled(config, ruleName) {
57
+ return !config.disable.has(ruleName);
58
+ }
59
+
60
+ /** @param {Record<string, unknown>} data @returns {Config} */
61
+ export function configFromMapping(data) {
62
+ const config = defaultConfig();
63
+ for (const [key, value] of Object.entries(data)) {
64
+ if (key === "disable") {
65
+ config.disable = new Set(/** @type {string[]} */ (value));
66
+ } else if (key === "analyzer") {
67
+ if (!ANALYZERS.includes(/** @type {string} */ (value))) {
68
+ throw new Error(`analyzer 는 ${ANALYZERS.join(" 또는 ")} 다: ${value}`);
69
+ }
70
+ config.analyzer = /** @type {string} */ (value);
71
+ } else if (key === "dictionary") {
72
+ config.dictionary = { .../** @type {Record<string, unknown[]>} */ (value) };
73
+ } else if (key !== "source" && key in config) {
74
+ config[key] = value;
75
+ } else {
76
+ throw new Error(`모르는 설정 키: ${key}. hanlint init 이 만드는 파일의 키만 쓴다`);
77
+ }
78
+ }
79
+ return config;
80
+ }
@@ -0,0 +1,239 @@
1
+ // @ts-check
2
+ /**
3
+ * hanlint 설정에 쓰이는 TOML 부분집합을 읽는다. 의존성 0 을 지키려고 직접 쓴다.
4
+ *
5
+ * 되는 것: 주석, `key = 값`, 점 키, `[table]`, `[[array]]`, 문자열 ("..." 와 '...'), 정수, 실수, 불리언,
6
+ * 여러 줄 배열, 인라인 테이블. 안 되는 것: 여러 줄 문자열, 날짜. 만나면 어디서 막혔는지 말하고 던진다.
7
+ */
8
+
9
+ const BACKSLASH = String.fromCharCode(92);
10
+ /** @type {Record<string, string>} */
11
+ const SIMPLE_ESCAPES = { n: "\n", t: "\t", r: "\r", '"': '"', b: "\b", f: "\f", [BACKSLASH]: BACKSLASH };
12
+
13
+ /** @param {string} text @returns {Record<string, unknown>} */
14
+ export function parseToml(text) {
15
+ return new Parser(text).document();
16
+ }
17
+
18
+ class Parser {
19
+ /** @param {string} text */
20
+ constructor(text) {
21
+ this.text = text;
22
+ this.pos = 0;
23
+ /** @type {Record<string, unknown>} */
24
+ this.root = {};
25
+ this.current = this.root;
26
+ }
27
+
28
+ /** @param {string} message */
29
+ error(message) {
30
+ const line = this.text.slice(0, this.pos).split("\n").length;
31
+ return new Error(`TOML ${line}번째 줄: ${message}. hanlint 가 읽는 TOML 부분집합이 아니다`);
32
+ }
33
+
34
+ document() {
35
+ for (;;) {
36
+ this.skipBlank();
37
+ if (this.pos >= this.text.length) return this.root;
38
+ if (this.text.startsWith("[[", this.pos)) this.arrayTable();
39
+ else if (this.text[this.pos] === "[") this.table();
40
+ else this.keyValue(this.current);
41
+ this.endOfLine();
42
+ }
43
+ }
44
+
45
+ skipBlank() {
46
+ while (this.pos < this.text.length) {
47
+ const c = this.text[this.pos];
48
+ if (c === "#") {
49
+ while (this.pos < this.text.length && this.text[this.pos] !== "\n") this.pos++;
50
+ } else if (c === " " || c === "\t" || c === "\n" || c === "\r") {
51
+ this.pos++;
52
+ } else {
53
+ return;
54
+ }
55
+ }
56
+ }
57
+
58
+ skipSpaces() {
59
+ while (this.pos < this.text.length && (this.text[this.pos] === " " || this.text[this.pos] === "\t")) this.pos++;
60
+ }
61
+
62
+ endOfLine() {
63
+ this.skipSpaces();
64
+ if (this.pos >= this.text.length) return;
65
+ const c = this.text[this.pos];
66
+ if (c === "#") {
67
+ while (this.pos < this.text.length && this.text[this.pos] !== "\n") this.pos++;
68
+ return;
69
+ }
70
+ if (c === "\n" || c === "\r") return;
71
+ throw this.error(`줄 끝에 남은 글자 '${c}'`);
72
+ }
73
+
74
+ /** @returns {string[]} */
75
+ keyPath() {
76
+ const parts = [];
77
+ for (;;) {
78
+ this.skipSpaces();
79
+ const c = this.text[this.pos];
80
+ if (c === '"' || c === "'") {
81
+ parts.push(this.string());
82
+ } else {
83
+ const match = /^[A-Za-z0-9_-]+/.exec(this.text.slice(this.pos));
84
+ if (!match) throw this.error("키가 없다");
85
+ parts.push(match[0]);
86
+ this.pos += match[0].length;
87
+ }
88
+ this.skipSpaces();
89
+ if (this.text[this.pos] === ".") {
90
+ this.pos++;
91
+ continue;
92
+ }
93
+ return parts;
94
+ }
95
+ }
96
+
97
+ /** @param {Record<string, unknown>} base @param {string[]} parts */
98
+ descend(base, parts) {
99
+ let node = base;
100
+ for (const part of parts) {
101
+ if (!(part in node)) node[part] = {};
102
+ const next = node[part];
103
+ if (Array.isArray(next)) node = /** @type {Record<string, unknown>} */ (next[next.length - 1]);
104
+ else if (typeof next === "object" && next !== null) node = /** @type {Record<string, unknown>} */ (next);
105
+ else throw this.error(`${part} 는 테이블이 아니다`);
106
+ }
107
+ return node;
108
+ }
109
+
110
+ table() {
111
+ this.pos++;
112
+ const parts = this.keyPath();
113
+ if (this.text[this.pos] !== "]") throw this.error("] 가 없다");
114
+ this.pos++;
115
+ this.current = this.descend(this.root, parts);
116
+ }
117
+
118
+ arrayTable() {
119
+ this.pos += 2;
120
+ const parts = this.keyPath();
121
+ if (!this.text.startsWith("]]", this.pos)) throw this.error("]] 가 없다");
122
+ this.pos += 2;
123
+ const parent = this.descend(this.root, parts.slice(0, -1));
124
+ const name = parts[parts.length - 1];
125
+ if (!(name in parent)) parent[name] = [];
126
+ const list = parent[name];
127
+ if (!Array.isArray(list)) throw this.error(`${name} 은 배열이 아니다`);
128
+ /** @type {Record<string, unknown>} */
129
+ const entry = {};
130
+ list.push(entry);
131
+ this.current = entry;
132
+ }
133
+
134
+ /** @param {Record<string, unknown>} target */
135
+ keyValue(target) {
136
+ const parts = this.keyPath();
137
+ if (this.text[this.pos] !== "=") throw this.error("= 가 없다");
138
+ this.pos++;
139
+ this.skipSpaces();
140
+ const node = this.descend(target, parts.slice(0, -1));
141
+ node[parts[parts.length - 1]] = this.value();
142
+ }
143
+
144
+ /** @returns {unknown} */
145
+ value() {
146
+ this.skipSpaces();
147
+ const c = this.text[this.pos];
148
+ if (c === '"' || c === "'") return this.string();
149
+ if (c === "[") return this.array();
150
+ if (c === "{") return this.inlineTable();
151
+ const rest = this.text.slice(this.pos);
152
+ const bool = /^(true|false)(?![A-Za-z0-9_])/.exec(rest);
153
+ if (bool) {
154
+ this.pos += bool[0].length;
155
+ return bool[1] === "true";
156
+ }
157
+ const number = /^[+-]?(?:\d[\d_]*)(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?(?![A-Za-z_])/.exec(rest);
158
+ if (number) {
159
+ this.pos += number[0].length;
160
+ return Number(number[0].replace(/_/g, ""));
161
+ }
162
+ throw this.error(`값을 읽을 수 없다: '${rest.slice(0, 12)}'`);
163
+ }
164
+
165
+ string() {
166
+ const quote = this.text[this.pos];
167
+ if (this.text.startsWith(quote.repeat(3), this.pos)) throw this.error("여러 줄 문자열은 지원하지 않는다");
168
+ this.pos++;
169
+ let out = "";
170
+ while (this.pos < this.text.length) {
171
+ const c = this.text[this.pos];
172
+ if (c === quote) {
173
+ this.pos++;
174
+ return out;
175
+ }
176
+ if (c === "\n") throw this.error("문자열이 닫히지 않았다");
177
+ if (quote === '"' && c === BACKSLASH) {
178
+ const next = this.text[this.pos + 1];
179
+ if (next in SIMPLE_ESCAPES) {
180
+ out += SIMPLE_ESCAPES[next];
181
+ this.pos += 2;
182
+ continue;
183
+ }
184
+ if (next === "u" || next === "U") {
185
+ const width = next === "u" ? 4 : 8;
186
+ const hex = this.text.slice(this.pos + 2, this.pos + 2 + width);
187
+ out += String.fromCodePoint(parseInt(hex, 16));
188
+ this.pos += 2 + width;
189
+ continue;
190
+ }
191
+ throw this.error(`모르는 이스케이프 ${BACKSLASH}${next}`);
192
+ }
193
+ out += c;
194
+ this.pos++;
195
+ }
196
+ throw this.error("문자열이 닫히지 않았다");
197
+ }
198
+
199
+ array() {
200
+ this.pos++;
201
+ /** @type {unknown[]} */
202
+ const items = [];
203
+ for (;;) {
204
+ this.skipBlank();
205
+ if (this.text[this.pos] === "]") {
206
+ this.pos++;
207
+ return items;
208
+ }
209
+ items.push(this.value());
210
+ this.skipBlank();
211
+ if (this.text[this.pos] === ",") this.pos++;
212
+ else if (this.text[this.pos] !== "]") throw this.error("배열에 , 나 ] 가 없다");
213
+ }
214
+ }
215
+
216
+ inlineTable() {
217
+ this.pos++;
218
+ /** @type {Record<string, unknown>} */
219
+ const table = {};
220
+ this.skipSpaces();
221
+ if (this.text[this.pos] === "}") {
222
+ this.pos++;
223
+ return table;
224
+ }
225
+ for (;;) {
226
+ this.keyValue(table);
227
+ this.skipSpaces();
228
+ if (this.text[this.pos] === ",") {
229
+ this.pos++;
230
+ continue;
231
+ }
232
+ if (this.text[this.pos] === "}") {
233
+ this.pos++;
234
+ return table;
235
+ }
236
+ throw this.error("인라인 테이블에 , 나 } 가 없다");
237
+ }
238
+ }
239
+ }
@@ -0,0 +1,63 @@
1
+ // @ts-check
2
+ /**
3
+ * data 폴더를 읽는다. 파이썬 정본의 투영이라 파일 모양이 같다. txt 는 한 줄에 항목 하나, json 은 toml 을 옮긴 것.
4
+ * 한 번 읽은 것은 기억한다.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ import { compile } from "../regex.js";
11
+
12
+ const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "data");
13
+ /** @type {Map<string, unknown>} */
14
+ const cache = new Map();
15
+
16
+ /**
17
+ * @template T
18
+ * @param {string} key
19
+ * @param {() => T} make
20
+ * @returns {T}
21
+ */
22
+ function cached(key, make) {
23
+ if (!cache.has(key)) cache.set(key, make());
24
+ return /** @type {T} */ (cache.get(key));
25
+ }
26
+
27
+ /** @param {string} name */
28
+ export function readText(name) {
29
+ return readFileSync(join(DATA_DIR, name), "utf-8");
30
+ }
31
+
32
+ /** `#` 으로 시작하면 주석, 빈 줄은 무시. @param {string} name @returns {string[]} */
33
+ export function loadLines(name) {
34
+ return cached(`lines:${name}`, () =>
35
+ readText(name)
36
+ .split(/\r?\n/)
37
+ .map((line) => line.trim())
38
+ .filter((line) => line && !line.startsWith("#")),
39
+ );
40
+ }
41
+
42
+ /** 한 줄에 정규식 하나. @param {string} name */
43
+ export function loadPatterns(name) {
44
+ return cached(`patterns:${name}`, () => loadLines(name).map((line) => compile(line)));
45
+ }
46
+
47
+ /** toml 의 `[[entry]]` 목록. @param {string} name @returns {Record<string, unknown>[]} */
48
+ export function loadEntries(name) {
49
+ return cached(`entries:${name}`, () => {
50
+ const data = JSON.parse(readText(name.replace(/\.toml$/, ".json")));
51
+ return data.entry ?? [];
52
+ });
53
+ }
54
+
55
+ /** @returns {Record<string, string>} 규칙 이름 → 기술서 (파이썬 docstring 의 투영) */
56
+ export function loadRuleDocs() {
57
+ return cached("ruleDocs", () => JSON.parse(readText("ruleDocs.json")));
58
+ }
59
+
60
+ /** @returns {string} */
61
+ export function loadVersion() {
62
+ return cached("version", () => JSON.parse(readText("version.json")).version);
63
+ }
@@ -0,0 +1,55 @@
1
+ // @ts-check
2
+ /**
3
+ * 문서 모델. 세 층이다. Block (빈 줄로 나뉜 덩어리), Section (H2 하나가 여는 절), Document (전체).
4
+ * 줄 번호는 1 부터 세고 원문 기준이다.
5
+ */
6
+
7
+ export const PROSE = "prose";
8
+ export const HEADING = "heading";
9
+ export const CODE = "code";
10
+ export const IMAGE = "image";
11
+ export const LIST = "list";
12
+ export const TABLE = "table";
13
+ export const EMBED = "embed";
14
+ export const HTML = "html";
15
+
16
+ /**
17
+ * @typedef {object} Block
18
+ * @property {string} kind
19
+ * @property {number} startLine
20
+ * @property {number} endLine
21
+ * @property {string} text
22
+ * @property {number} level heading 일 때 `#` 의 개수
23
+ * @property {number} index 문서 안 순서
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} Section
28
+ * @property {Block | null} heading
29
+ * @property {Block[]} blocks
30
+ */
31
+
32
+ /**
33
+ * @typedef {object} Document
34
+ * @property {string | null} path
35
+ * @property {Record<string, string>} frontmatter
36
+ * @property {Block[]} blocks
37
+ * @property {Section[]} sections
38
+ * @property {[string, number, number][]} disabled 인라인 제어가 끈 (규칙 또는 *, 시작 줄, 끝 줄)
39
+ */
40
+
41
+ /** @param {Section} section */
42
+ export function sectionTitle(section) {
43
+ return section.heading ? section.heading.text : "";
44
+ }
45
+
46
+ /** @param {Section} section */
47
+ export function sectionStartLine(section) {
48
+ if (section.heading) return section.heading.startLine;
49
+ return section.blocks.length ? section.blocks[0].startLine : 1;
50
+ }
51
+
52
+ /** @param {Document} doc @param {number | null} [level] */
53
+ export function headingsOf(doc, level = null) {
54
+ return doc.blocks.filter((b) => b.kind === HEADING && (level === null || b.level === level));
55
+ }
@@ -0,0 +1,183 @@
1
+ // @ts-check
2
+ /**
3
+ * 마크다운 텍스트를 문서 모델로 바꾼다. 파이썬 document/parseMarkdown.py 와 같은 판정이다.
4
+ * 빈 줄로 블록을 나누고 첫 줄로 종류를 정한다. 코드 펜스 안은 통째로 code 다. H2 가 절을 열고 첫 H2 앞은 도입 절이다.
5
+ */
6
+ import { splitLines } from "../text.js";
7
+ import { CODE, EMBED, HEADING, HTML, IMAGE, LIST, PROSE, TABLE } from "./model.js";
8
+
9
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
10
+ const FENCE = /^\s*(```|~~~)/;
11
+ const HEADING_LINE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
12
+ const IMAGE_LINE = /^!\[/;
13
+ const LIST_LINE = /^\s*(?:[-*+]|\d+[.)])\s+/;
14
+ const TABLE_LINE = /^\s*\|/;
15
+ const URL_LINE = /^\s*https?:\/\/\S+\s*$/;
16
+ const HTML_LINE = /^\s*</;
17
+ const META_KEY = /^[A-Za-z][A-Za-z0-9_]*$/;
18
+ const CONTROL = /^\s*<!--\s*hanlint-(disable-next-line|disable-next|disable|enable)\b([^>]*?)-->\s*$/;
19
+ const ALL_RULES = "*";
20
+
21
+ /** frontmatter 를 읽고 본문이 시작하는 줄 번호 (1 부터) 를 함께 준다. @param {string} text */
22
+ export function parseFrontmatter(text) {
23
+ const match = FRONTMATTER.exec(text);
24
+ if (!match) return { meta: {}, firstLine: 1 };
25
+ /** @type {Record<string, string>} */
26
+ const meta = {};
27
+ for (const line of splitLines(match[1])) {
28
+ const at = line.indexOf(":");
29
+ if (at < 0) continue;
30
+ const key = line.slice(0, at).trim();
31
+ if (META_KEY.test(key)) meta[key] = line.slice(at + 1).trim();
32
+ }
33
+ return { meta, firstLine: (match[0].match(/\n/g) ?? []).length + 1 };
34
+ }
35
+
36
+ /** @param {string} firstLine */
37
+ export function classify(firstLine) {
38
+ if (HEADING_LINE.test(firstLine)) return HEADING;
39
+ if (IMAGE_LINE.test(firstLine)) return IMAGE;
40
+ if (TABLE_LINE.test(firstLine)) return TABLE;
41
+ if (LIST_LINE.test(firstLine)) return LIST;
42
+ if (URL_LINE.test(firstLine)) return EMBED;
43
+ if (HTML_LINE.test(firstLine)) return HTML;
44
+ return PROSE;
45
+ }
46
+
47
+ /**
48
+ * @param {string} text
49
+ * @param {number} firstLine
50
+ * @returns {import("./model.js").Block[]}
51
+ */
52
+ export function splitBlocks(text, firstLine) {
53
+ /** @type {import("./model.js").Block[]} */
54
+ const blocks = [];
55
+ /** @type {string[]} */
56
+ let buffer = [];
57
+ let bufferStart = 0;
58
+ let lineNo = firstLine - 1;
59
+ /** @type {string | null} */
60
+ let fence = null;
61
+ let fenceStart = 0;
62
+ /** @type {string[]} */
63
+ let fenceLines = [];
64
+
65
+ const flush = () => {
66
+ if (!buffer.length) return;
67
+ const kind = classify(buffer[0]);
68
+ let joined = buffer.join("\n");
69
+ let level = 0;
70
+ if (kind === HEADING) {
71
+ const match = /** @type {RegExpExecArray} */ (HEADING_LINE.exec(buffer[0]));
72
+ level = match[1].length;
73
+ joined = match[2];
74
+ }
75
+ blocks.push({ kind, startLine: bufferStart, endLine: bufferStart + buffer.length - 1, text: joined, level, index: blocks.length });
76
+ buffer = [];
77
+ };
78
+
79
+ for (const raw of splitLines(text)) {
80
+ lineNo += 1;
81
+ const line = raw.replace(/\r+$/, "");
82
+ if (fence) {
83
+ fenceLines.push(line);
84
+ if (FENCE.test(line) && line.trim().startsWith(fence)) {
85
+ blocks.push({ kind: CODE, startLine: fenceStart, endLine: lineNo, text: fenceLines.join("\n"), level: 0, index: blocks.length });
86
+ fence = null;
87
+ fenceLines = [];
88
+ }
89
+ continue;
90
+ }
91
+ const opening = FENCE.exec(line);
92
+ if (opening) {
93
+ flush();
94
+ fence = opening[1];
95
+ fenceStart = lineNo;
96
+ fenceLines = [line];
97
+ continue;
98
+ }
99
+ if (!line.trim()) {
100
+ flush();
101
+ continue;
102
+ }
103
+ if (CONTROL.test(line)) {
104
+ flush();
105
+ blocks.push({ kind: HTML, startLine: lineNo, endLine: lineNo, text: line, level: 0, index: blocks.length });
106
+ continue;
107
+ }
108
+ if (HEADING_LINE.test(line) && buffer.length) flush();
109
+ if (!buffer.length) bufferStart = lineNo;
110
+ buffer.push(line);
111
+ if (HEADING_LINE.test(line)) flush();
112
+ }
113
+ flush();
114
+ if (fence) {
115
+ blocks.push({ kind: CODE, startLine: fenceStart, endLine: lineNo, text: fenceLines.join("\n"), level: 0, index: blocks.length });
116
+ }
117
+ return blocks;
118
+ }
119
+
120
+ /** @param {import("./model.js").Block[]} blocks @returns {import("./model.js").Section[]} */
121
+ export function groupSections(blocks) {
122
+ /** @type {import("./model.js").Section[]} */
123
+ const sections = [{ heading: null, blocks: [] }];
124
+ for (const block of blocks) {
125
+ if (block.kind === HEADING && block.level === 2) {
126
+ sections.push({ heading: block, blocks: [] });
127
+ continue;
128
+ }
129
+ sections[sections.length - 1].blocks.push(block);
130
+ }
131
+ return sections;
132
+ }
133
+
134
+ /** @param {string} raw */
135
+ function controlNames(raw) {
136
+ const names = raw.trim().split(/[\s,]+/).filter(Boolean);
137
+ return names.length ? names : [ALL_RULES];
138
+ }
139
+
140
+ /** @param {import("./model.js").Block[]} blocks @returns {[string, number, number][]} */
141
+ export function disabledRanges(blocks) {
142
+ /** @type {[string, number, number][]} */
143
+ const ranges = [];
144
+ /** @type {Map<string, number>} */
145
+ const opened = new Map();
146
+ const lastLine = blocks.length ? blocks[blocks.length - 1].endLine : 1;
147
+ blocks.forEach((block, index) => {
148
+ const match = block.kind === HTML ? CONTROL.exec(block.text) : null;
149
+ if (!match) return;
150
+ const action = match[1];
151
+ const names = controlNames(match[2]);
152
+ if (action === "disable-next" || action === "disable-next-line") {
153
+ if (index + 1 < blocks.length) {
154
+ const target = blocks[index + 1];
155
+ for (const name of names) ranges.push([name, target.startLine, target.endLine]);
156
+ }
157
+ } else if (action === "disable") {
158
+ for (const name of names) if (!opened.has(name)) opened.set(name, block.startLine);
159
+ } else {
160
+ const closing = names.length === 1 && names[0] === ALL_RULES ? [...opened.keys()] : names;
161
+ for (const name of closing) {
162
+ if (opened.has(name)) {
163
+ ranges.push([name, /** @type {number} */ (opened.get(name)), block.endLine]);
164
+ opened.delete(name);
165
+ }
166
+ }
167
+ }
168
+ });
169
+ for (const [name, start] of opened) ranges.push([name, start, lastLine]);
170
+ return ranges;
171
+ }
172
+
173
+ /**
174
+ * @param {string} text
175
+ * @param {string | null} [path]
176
+ * @returns {import("./model.js").Document}
177
+ */
178
+ export function parseMarkdown(text, path = null) {
179
+ const { meta, firstLine } = parseFrontmatter(text);
180
+ const body = firstLine === 1 ? text : splitLines(text).slice(firstLine - 1).join("\n");
181
+ const blocks = splitBlocks(body, firstLine);
182
+ return { path, frontmatter: meta, blocks, sections: groupSections(blocks), disabled: disabledRanges(blocks) };
183
+ }
@@ -0,0 +1,43 @@
1
+ // @ts-check
2
+ /**
3
+ * 산문에서 마크다운 표식을 걷는다. 인라인 코드는 백틱만 떼고 내용은 남긴다. 링크는 보이는 글자만. 강조 표식은 뗀다.
4
+ * 줄바꿈은 남겨 줄 번호 계산에 쓴다. 파이썬 document/plainText.py 와 같다.
5
+ * 인라인 코드가 있던 자리는 codeSpans 로 다시 찾는다. 백틱 안은 사용이 아니라 인용이다.
6
+ */
7
+
8
+ const INLINE_CODE = /`([^`\n]*)`/g;
9
+ const LINK = /\[([^\]]*)\]\([^)]*\)/g;
10
+ const EMPHASIS = /(\*\*|__)(?=\S)(.+?)(?<=\S)\1/g;
11
+ // 파이썬의 \w 는 유니코드 글자라 한글을 포함한다. JS 는 \p{L}\p{N}_ 로 같은 뜻을 적는다.
12
+ const SINGLE_EMPHASIS = /(?<![\p{L}\p{N}_*])\*(?=\S)([^*\n]+?)(?<=\S)\*(?![\p{L}\p{N}_*])/gu;
13
+ const SPACES = /[ \t]+/g;
14
+
15
+ /** @param {string} text */
16
+ export function plainText(text) {
17
+ text = text.replace(INLINE_CODE, "$1");
18
+ text = text.replace(LINK, "$1");
19
+ text = text.replace(EMPHASIS, "$2");
20
+ text = text.replace(SINGLE_EMPHASIS, "$1");
21
+ return text.replace(SPACES, " ").trim();
22
+ }
23
+
24
+ /**
25
+ * 원문의 인라인 코드 내용이 plain 안에서 차지하는 [시작, 끝]. 앞에서부터 차례로 찾고 못 찾으면 건너뛴다.
26
+ * @param {string} raw
27
+ * @param {string} plain
28
+ * @returns {[number, number][]}
29
+ */
30
+ export function codeSpans(raw, plain) {
31
+ /** @type {[number, number][]} */
32
+ const spans = [];
33
+ let cursor = 0;
34
+ for (const match of raw.matchAll(INLINE_CODE)) {
35
+ const needle = match[1].replace(SPACES, " ");
36
+ if (!needle) continue;
37
+ const at = plain.indexOf(needle, cursor);
38
+ if (at < 0) continue;
39
+ spans.push([at, at + needle.length]);
40
+ cursor = at + needle.length;
41
+ }
42
+ return spans;
43
+ }
Binary file