@sushichan044/kg-core 0.6.4 → 0.7.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/dist/lint.mjs ADDED
@@ -0,0 +1,812 @@
1
+ import { a as characterClass, c as ManuscriptResult, d as NamespacedId, f as ManuscriptRange, h as readonlyObject, i as OPENING_BRACKETS, l as ManuscriptDiagnostic, m as readonlyArray, n as leadingSpaces, o as graphemeSegmenter, r as CLOSING_BRACKETS, s as ValidationIssue, t as questionOrExclamationSpacings, u as DiagnosticSeverity } from "./question-or-exclamation-spacing-B5o23ZLf.mjs";
2
+ import { n as assertNever, t as describeCause } from "./describe-cause-BV8mZ3Um.mjs";
3
+ import * as v from "valibot";
4
+ //#region src/proofreading/invalid-rule-options.ts
5
+ const InvalidRuleOptions = { describe: (error) => `ルール ${error.ruleId} のオプション "${error.option}" が不正です: ${error.issues.map(({ message }) => message).join(", ")}` };
6
+ //#endregion
7
+ //#region src/proofreading/proofread-error.ts
8
+ const ProofreadError = {
9
+ /**
10
+ * Human-readable rendering. Branch on `kind` instead when the caller needs to react.
11
+ */
12
+ describe: (error) => {
13
+ switch (error.kind) {
14
+ case "InvalidRuleId": return `ルール ID "${error.ruleId}" は namespace/name の形式ではありません`;
15
+ case "DuplicateRuleId": return `ルール ID ${error.ruleId} が重複しています`;
16
+ case "InvalidRuleMetadata": return `ルール ${error.ruleId} の metadata が不正です: ${joinIssues(error.issues)}`;
17
+ case "InvalidReport": return `ルール ${error.ruleId} の報告が契約を満たしていません: ${joinIssues(error.issues)}`;
18
+ case "UnknownMessageId": return `ルール ${error.ruleId} が未宣言のメッセージ ID "${error.messageId}" を報告しました`;
19
+ case "RuleThrew": return `ルール ${error.ruleId} が例外を投げました: ${describeCause(error.cause)}`;
20
+ default: return assertNever(error);
21
+ }
22
+ } };
23
+ function joinIssues(issues) {
24
+ return issues.map(({ message }) => message).join(", ");
25
+ }
26
+ //#endregion
27
+ //#region src/proofreading/internal/interpolate.ts
28
+ /**
29
+ * Fills `{{ name }}` placeholders from the report's data; an absent key renders as empty.
30
+ */
31
+ function interpolate(template, data) {
32
+ return template.replaceAll(/\{\{\s*([^}\s]+)\s*\}\}/gu, (_match, key) => String(data?.[key] ?? ""));
33
+ }
34
+ const ProofreadingReport = { schema: readonlyObject({
35
+ range: ManuscriptRange.schema,
36
+ messageId: v.string(),
37
+ data: v.optional(v.pipe(v.record(v.string(), v.union([v.string(), v.number()])), v.readonly())),
38
+ /**
39
+ * Omitted means `error`: a rule that says nothing is claiming it found a real defect.
40
+ */
41
+ severity: v.optional(DiagnosticSeverity.schema)
42
+ }) };
43
+ //#endregion
44
+ //#region src/proofreading/proofreading-rule.ts
45
+ const ProofreadingRuleMessagesSchema = v.pipe(v.record(v.string(), v.string()), v.readonly());
46
+ const ProofreadingRuleMeta = {
47
+ schema: readonlyObject({
48
+ id: v.string(),
49
+ messages: ProofreadingRuleMessagesSchema
50
+ }),
51
+ messagesSchema: ProofreadingRuleMessagesSchema
52
+ };
53
+ //#endregion
54
+ //#region src/proofreading/proofread-manuscript.ts
55
+ const reportsSchema = readonlyArray(ProofreadingReport.schema);
56
+ function prepareRules(rules) {
57
+ const prepared = [];
58
+ const seen = /* @__PURE__ */ new Set();
59
+ for (const rule of rules) {
60
+ const id = NamespacedId.parse(rule.meta.id);
61
+ if (id === void 0) return ManuscriptResult.fail({
62
+ kind: "InvalidRuleId",
63
+ ruleId: rule.meta.id
64
+ });
65
+ if (seen.has(id)) return ManuscriptResult.fail({
66
+ kind: "DuplicateRuleId",
67
+ ruleId: id
68
+ });
69
+ const messages = v.safeParse(ProofreadingRuleMeta.messagesSchema, rule.meta.messages);
70
+ if (!messages.success) return ManuscriptResult.fail({
71
+ kind: "InvalidRuleMetadata",
72
+ ruleId: id,
73
+ issues: ValidationIssue.from(messages.issues)
74
+ });
75
+ seen.add(id);
76
+ prepared.push({
77
+ id,
78
+ messages: messages.output,
79
+ rule
80
+ });
81
+ }
82
+ return ManuscriptResult.succeed(prepared);
83
+ }
84
+ /**
85
+ * Runs one rule and returns what it reported. A composed-only rule against a parsed manuscript is
86
+ * simply skipped: the caller asked for a stage this rule cannot see.
87
+ */
88
+ function collectReports({ id, rule }, parsed, composed) {
89
+ const reports = [];
90
+ const context = { report: (report) => {
91
+ reports.push(report);
92
+ } };
93
+ try {
94
+ switch (rule.kind) {
95
+ case "parsed":
96
+ rule.check(parsed, context);
97
+ break;
98
+ case "composed":
99
+ if (composed !== null) rule.check(composed, context);
100
+ break;
101
+ default: assertNever(rule);
102
+ }
103
+ } catch (cause) {
104
+ return ManuscriptResult.fail({
105
+ kind: "RuleThrew",
106
+ ruleId: id,
107
+ cause
108
+ });
109
+ }
110
+ const validated = v.safeParse(reportsSchema, reports);
111
+ return validated.success ? ManuscriptResult.succeed(validated.output) : ManuscriptResult.fail({
112
+ kind: "InvalidReport",
113
+ ruleId: id,
114
+ issues: ValidationIssue.from(validated.issues)
115
+ });
116
+ }
117
+ function toDiagnostics({ id, messages }, reports, source) {
118
+ const diagnostics = [];
119
+ for (const report of reports) {
120
+ const template = messages[report.messageId];
121
+ if (template === void 0) return ManuscriptResult.fail({
122
+ kind: "UnknownMessageId",
123
+ ruleId: id,
124
+ messageId: report.messageId
125
+ });
126
+ diagnostics.push(ManuscriptDiagnostic.of({
127
+ source,
128
+ origin: {
129
+ kind: "rule",
130
+ id
131
+ },
132
+ severity: report.severity ?? "error",
133
+ code: report.messageId,
134
+ message: interpolate(template, report.data),
135
+ range: report.range
136
+ }));
137
+ }
138
+ return ManuscriptResult.succeed(diagnostics);
139
+ }
140
+ function run(parsed, composed, rules) {
141
+ const prepared = prepareRules(rules);
142
+ if (!prepared.ok) return prepared;
143
+ const diagnostics = [];
144
+ for (const rule of prepared.value) {
145
+ const reports = collectReports(rule, parsed, composed);
146
+ if (!reports.ok) return reports;
147
+ const produced = toDiagnostics(rule, reports.value, parsed.source);
148
+ if (!produced.ok) return produced;
149
+ diagnostics.push(...produced.value);
150
+ }
151
+ return ManuscriptResult.succeed([...diagnostics].sort(ManuscriptDiagnostic.compare));
152
+ }
153
+ function proofreadManuscript(manuscript, options) {
154
+ return "composerId" in manuscript ? run(manuscript.parsed, manuscript, options.rules) : run(manuscript, null, options.rules);
155
+ }
156
+ //#endregion
157
+ //#region src/proofreading/rules/internal/display-line.ts
158
+ /**
159
+ * Splits on CRLF, CR and LF. The text after the last break is always emitted, even when empty.
160
+ */
161
+ function splitDisplayLines(text) {
162
+ const lines = [];
163
+ let start = 0;
164
+ let index = 0;
165
+ while (index < text.length) {
166
+ const character = text[index];
167
+ if (character !== "\n" && character !== "\r") {
168
+ index += 1;
169
+ continue;
170
+ }
171
+ lines.push({
172
+ text: text.slice(start, index),
173
+ start
174
+ });
175
+ index += character === "\r" && text[index + 1] === "\n" ? 2 : 1;
176
+ start = index;
177
+ }
178
+ lines.push({
179
+ text: text.slice(start),
180
+ start
181
+ });
182
+ return lines;
183
+ }
184
+ //#endregion
185
+ //#region src/proofreading/rules/internal/rule-range.ts
186
+ const EMPTY_BOUNDS = {
187
+ start: 0,
188
+ end: 0
189
+ };
190
+ /**
191
+ * Turns a span of display text into a full range. The source span is the smallest contiguous run
192
+ * that covers the display span, so it includes any notation the parser removed in between.
193
+ */
194
+ function displayRange(manuscript, start, end) {
195
+ const covered = manuscript.graphemes.filter(({ range }) => range.display.start < end && range.display.end > start);
196
+ const first = covered[0];
197
+ const last = covered.at(-1);
198
+ if (first === void 0 || last === void 0) return ManuscriptRange.of({
199
+ source: EMPTY_BOUNDS,
200
+ display: {
201
+ start,
202
+ end
203
+ },
204
+ graphemes: EMPTY_BOUNDS
205
+ });
206
+ const merged = ManuscriptRange.merge(covered.map(({ range }) => range));
207
+ return ManuscriptRange.of({
208
+ source: {
209
+ start: first.range.source.start + clampWithin(start - first.range.display.start, first.range),
210
+ end: end === last.range.display.end ? last.range.source.end : last.range.source.start + clampWithin(end - last.range.display.start, last.range)
211
+ },
212
+ display: {
213
+ start,
214
+ end
215
+ },
216
+ graphemes: merged?.graphemes ?? EMPTY_BOUNDS
217
+ });
218
+ }
219
+ /**
220
+ * A display offset inside a grapheme cannot map past that grapheme's own source span.
221
+ */
222
+ function clampWithin(offset, range) {
223
+ return Math.min(offset, range.source.end - range.source.start);
224
+ }
225
+ /**
226
+ * Turns a span of source text into a full range, for rules that inspect the raw source.
227
+ */
228
+ function sourceRange(manuscript, source) {
229
+ const covered = manuscript.graphemes.filter(({ range }) => range.source.start < source.end && range.source.end > source.start);
230
+ const enclosing = manuscript.annotations.find(({ range }) => range.source.start <= source.start && range.source.end >= source.end);
231
+ const merged = ManuscriptRange.merge(covered.map(({ range }) => range)) ?? enclosing?.range ?? null;
232
+ return ManuscriptRange.of({
233
+ source,
234
+ display: merged?.display ?? EMPTY_BOUNDS,
235
+ graphemes: merged?.graphemes ?? EMPTY_BOUNDS
236
+ });
237
+ }
238
+ //#endregion
239
+ //#region src/proofreading/rules/internal/define-rule.ts
240
+ const DEFAULT_MESSAGE_ID = "default";
241
+ /**
242
+ * Builds a single-message rule; the body reports ranges and the wrapper names the message.
243
+ */
244
+ function defineParsedRule(id, message, check) {
245
+ return {
246
+ kind: "parsed",
247
+ meta: {
248
+ id,
249
+ messages: { [DEFAULT_MESSAGE_ID]: message }
250
+ },
251
+ check: (manuscript, context) => {
252
+ check(manuscript, (range, options) => {
253
+ context.report({
254
+ ...options,
255
+ range,
256
+ messageId: DEFAULT_MESSAGE_ID
257
+ });
258
+ });
259
+ }
260
+ };
261
+ }
262
+ /**
263
+ * Builds a rule that scans each display line with a sticky pattern. Matching per line rather than
264
+ * across the whole text keeps findings from spanning a line break.
265
+ */
266
+ function defineMatchRule(options) {
267
+ return defineParsedRule(options.id, options.message, (manuscript, report) => {
268
+ for (const line of splitDisplayLines(manuscript.displayText)) {
269
+ options.pattern.lastIndex = 0;
270
+ let match = options.pattern.exec(line.text);
271
+ while (match !== null) {
272
+ if (options.accept?.(match) ?? true) {
273
+ const start = line.start + match.index;
274
+ report(displayRange(manuscript, start, start + match[0].length));
275
+ }
276
+ match = options.pattern.exec(line.text);
277
+ }
278
+ }
279
+ });
280
+ }
281
+ //#endregion
282
+ //#region src/proofreading/rules/consistent-kanji-opening.ts
283
+ const RULE_ID$5 = "kg/consistent-kanji-opening";
284
+ const MESSAGE = "「{{ closed }}」と「{{ opened }}」が混在しています。文脈上の使い分けか表記ゆれかを確認してください";
285
+ const KanjiOpeningPairSchema = v.pipe(readonlyObject({
286
+ closed: v.pipe(v.string(), v.nonEmpty()),
287
+ opened: v.pipe(v.string(), v.nonEmpty())
288
+ }), v.check(({ closed, opened }) => closed !== opened, "a pair must hold two different spellings"));
289
+ const PairsSchema = v.pipe(v.array(KanjiOpeningPairSchema), v.nonEmpty(), v.readonly());
290
+ const DEFAULT_PAIRS = [
291
+ {
292
+ closed: "出来る",
293
+ opened: "できる"
294
+ },
295
+ {
296
+ closed: "出来ない",
297
+ opened: "できない"
298
+ },
299
+ {
300
+ closed: "下さい",
301
+ opened: "ください"
302
+ },
303
+ {
304
+ closed: "頂く",
305
+ opened: "いただく"
306
+ },
307
+ {
308
+ closed: "致す",
309
+ opened: "いたす"
310
+ },
311
+ {
312
+ closed: "何故",
313
+ opened: "なぜ"
314
+ },
315
+ {
316
+ closed: "為に",
317
+ opened: "ために"
318
+ },
319
+ {
320
+ closed: "時に",
321
+ opened: "ときに"
322
+ },
323
+ {
324
+ closed: "様に",
325
+ opened: "ように"
326
+ },
327
+ {
328
+ closed: "方が",
329
+ opened: "ほうが"
330
+ }
331
+ ];
332
+ /**
333
+ * Reports the first kanji occurrence of every pair the manuscript also writes in kana. Whether the
334
+ * two spellings are a deliberate distinction is the author's call, so the finding is a warning.
335
+ */
336
+ function build$3(pairs) {
337
+ return defineParsedRule(RULE_ID$5, MESSAGE, (manuscript, report) => {
338
+ for (const { closed, opened } of pairs) {
339
+ if (!manuscript.displayText.includes(opened)) continue;
340
+ const index = manuscript.displayText.indexOf(closed);
341
+ if (index === -1) continue;
342
+ report(displayRange(manuscript, index, index + closed.length), {
343
+ data: {
344
+ closed,
345
+ opened
346
+ },
347
+ severity: "warning"
348
+ });
349
+ }
350
+ });
351
+ }
352
+ const consistentKanjiOpeningRule = () => build$3(DEFAULT_PAIRS);
353
+ function createConsistentKanjiOpeningRule(options = {}) {
354
+ if (options.pairs === void 0) return ManuscriptResult.succeed(consistentKanjiOpeningRule());
355
+ const pairs = v.safeParse(PairsSchema, options.pairs);
356
+ if (!pairs.success) return ManuscriptResult.fail({
357
+ kind: "InvalidRuleOptions",
358
+ ruleId: RULE_ID$5,
359
+ option: "pairs",
360
+ issues: ValidationIssue.from(pairs.issues)
361
+ });
362
+ return ManuscriptResult.succeed(build$3(pairs.output));
363
+ }
364
+ //#endregion
365
+ //#region src/proofreading/rules/internal/mixed-width.ts
366
+ /**
367
+ * Builds a rule that fires when a manuscript uses both widths of one character class. Which width
368
+ * is right is the author's decision, so the finding is a warning, and one finding at the first
369
+ * halfwidth occurrence says everything a list of every mixed character would.
370
+ */
371
+ function defineMixedWidthRule({ id, message, halfwidth, fullwidth }) {
372
+ return defineParsedRule(id, message, (manuscript, report) => {
373
+ const found = halfwidth.exec(manuscript.displayText);
374
+ const counterpart = fullwidth.exec(manuscript.displayText);
375
+ if (found === null || counterpart === null) return;
376
+ report(displayRange(manuscript, found.index, found.index + found[0].length), {
377
+ data: {
378
+ halfwidth: found[0],
379
+ fullwidth: counterpart[0]
380
+ },
381
+ severity: "warning"
382
+ });
383
+ });
384
+ }
385
+ //#endregion
386
+ //#region src/proofreading/rules/consistent-latin-width.ts
387
+ /**
388
+ * Halfwidth and fullwidth Latin letters (半角英字 and 全角英字) in one work. In vertical writing the two
389
+ * are set differently — a fullwidth letter stands upright on its own em, a halfwidth one joins a
390
+ * run that may turn sideways — so mixing them is 表記ゆれ the reader can see. Which one a work uses is
391
+ * its own choice, hence a warning.
392
+ */
393
+ const consistentLatinWidthRule = () => defineMixedWidthRule({
394
+ id: "kg/consistent-latin-width",
395
+ message: "半角英字「{{ halfwidth }}」と全角英字「{{ fullwidth }}」が混在しています。作品内の方針を確認してください",
396
+ halfwidth: /[A-Za-z]/u,
397
+ fullwidth: /[A-Za-z]/u
398
+ });
399
+ //#endregion
400
+ //#region src/proofreading/rules/consistent-numeral-width.ts
401
+ /**
402
+ * Halfwidth and fullwidth digits (半角数字 and 全角数字) in one work. A fullwidth digit stands upright on
403
+ * its own em; a halfwidth pair sets as 縦中横 inside one. Mixing them is 表記ゆれ, but which one a work
404
+ * uses is its own choice, hence a warning.
405
+ */
406
+ const consistentNumeralWidthRule = () => defineMixedWidthRule({
407
+ id: "kg/consistent-numeral-width",
408
+ message: "半角数字「{{ halfwidth }}」と全角数字「{{ fullwidth }}」が混在しています。作品内の方針を確認してください",
409
+ halfwidth: /[0-9]/u,
410
+ fullwidth: /[0-9]/u
411
+ });
412
+ //#endregion
413
+ //#region src/proofreading/rules/dash.ts
414
+ const RULE_ID$4 = "kg/dash";
415
+ const DEFAULT_PREFERRED = "―";
416
+ const DashCharacterSchema = v.picklist([
417
+ "―",
418
+ "—",
419
+ "─"
420
+ ]);
421
+ const DASH_LIKE_RUN = /[—–―─━﹣--]+/gu;
422
+ const EVEN_COUNT_MESSAGE = "連続するダッシュの数は偶数にしてください";
423
+ function messageIdFor(run, preferred) {
424
+ if (run === "-") return void 0;
425
+ if (run !== preferred.repeat(run.length)) return "character";
426
+ return run.length % 2 === 1 ? "even-count" : void 0;
427
+ }
428
+ /**
429
+ * The dash a novel sets is the 2倍ダッシュ: two copies of one character read as a single rule two ems
430
+ * long. That is why an odd run is a finding — half a dash is left over — and why the characters
431
+ * must match, since only a repeat of the same character joins without a seam. JLReq classes the
432
+ * dash as 分離禁止文字 (cl-08) for the same reason: the pair must not be split across lines.
433
+ */
434
+ function build$2(preferred) {
435
+ const messages = {
436
+ character: `ダッシュには「${preferred}」を使ってください`,
437
+ "even-count": EVEN_COUNT_MESSAGE
438
+ };
439
+ return {
440
+ kind: "parsed",
441
+ meta: {
442
+ id: RULE_ID$4,
443
+ messages
444
+ },
445
+ check: (manuscript, context) => {
446
+ for (const line of splitDisplayLines(manuscript.displayText)) {
447
+ DASH_LIKE_RUN.lastIndex = 0;
448
+ let match = DASH_LIKE_RUN.exec(line.text);
449
+ while (match !== null) {
450
+ const run = match[0];
451
+ const messageId = messageIdFor(run, preferred);
452
+ if (messageId !== void 0) {
453
+ const start = line.start + match.index;
454
+ context.report({
455
+ range: displayRange(manuscript, start, start + run.length),
456
+ messageId
457
+ });
458
+ }
459
+ match = DASH_LIKE_RUN.exec(line.text);
460
+ }
461
+ }
462
+ }
463
+ };
464
+ }
465
+ const dashRule = () => build$2(DEFAULT_PREFERRED);
466
+ function createDashRule(options = {}) {
467
+ if (options.preferred === void 0) return ManuscriptResult.succeed(dashRule());
468
+ const preferred = v.safeParse(DashCharacterSchema, options.preferred);
469
+ if (!preferred.success) return ManuscriptResult.fail({
470
+ kind: "InvalidRuleOptions",
471
+ ruleId: RULE_ID$4,
472
+ option: "preferred",
473
+ issues: ValidationIssue.from(preferred.issues)
474
+ });
475
+ return ManuscriptResult.succeed(build$2(preferred.output));
476
+ }
477
+ //#endregion
478
+ //#region src/proofreading/rules/ellipsis-character.ts
479
+ /**
480
+ * Stand-ins for the ellipsis (三点リーダー) `…`. Only the real character is set on its own em and pairs
481
+ * into a 2倍リーダ; a run of ASCII periods is neither. Runs of `。` and `・` are left to the rules that
482
+ * already count them, so one substitute is never reported twice.
483
+ */
484
+ const ellipsisCharacterRule = () => defineMatchRule({
485
+ id: "kg/ellipsis-character",
486
+ pattern: /\.{3,}|⋯+/gu,
487
+ message: "三点リーダーには「…」を使ってください"
488
+ });
489
+ //#endregion
490
+ //#region src/proofreading/rules/even-ellipsis.ts
491
+ /**
492
+ * The ellipsis a novel sets is the 2倍リーダ: two copies of `…` read as one mark six dots long. An odd
493
+ * run leaves half of it, the same way an odd dash run does.
494
+ */
495
+ const evenEllipsisRule = () => defineMatchRule({
496
+ id: "kg/even-ellipsis",
497
+ pattern: /…+/gu,
498
+ accept: (match) => match[0].length % 2 === 1,
499
+ message: "連続する三点リーダーの数は偶数にしてください"
500
+ });
501
+ //#endregion
502
+ //#region src/proofreading/rules/fullwidth-japanese-punctuation.ts
503
+ const RULE_ID$3 = "kg/fullwidth-japanese-punctuation";
504
+ const MESSAGES$2 = {
505
+ "halfwidth-japanese-punctuation": "半角の「{{ character }}」があります。日本語の句読点・括弧・感嘆符・疑問符は全角にしてください",
506
+ "halfwidth-near-japanese": "日本語の前後に半角の「{{ character }}」があります。全角にすべきか確認してください"
507
+ };
508
+ const JAPANESE_CHARACTER = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}々〆ヵヶ]/u;
509
+ const CHECKS = [
510
+ {
511
+ pattern: /[。、「」]/gu,
512
+ messageId: "halfwidth-japanese-punctuation",
513
+ severity: "error",
514
+ requiresJapanese: false
515
+ },
516
+ {
517
+ pattern: /[!?]/gu,
518
+ messageId: "halfwidth-japanese-punctuation",
519
+ severity: "error",
520
+ requiresJapanese: true
521
+ },
522
+ {
523
+ pattern: /[,.()[\]{}]/gu,
524
+ messageId: "halfwidth-near-japanese",
525
+ severity: "warning",
526
+ requiresJapanese: true
527
+ }
528
+ ];
529
+ function isBesideJapanese(text, index) {
530
+ const before = text[index - 1];
531
+ const after = text[index + 1];
532
+ return before !== void 0 && JAPANESE_CHARACTER.test(before) || after !== void 0 && JAPANESE_CHARACTER.test(after);
533
+ }
534
+ /**
535
+ * A period inside a run of three or more is a stand-in for `…`, which `kg/ellipsis-character`
536
+ * already reports.
537
+ */
538
+ function isEllipsisSubstitute(text, index) {
539
+ if (text[index] !== ".") return false;
540
+ let start = index;
541
+ while (text[start - 1] === ".") start -= 1;
542
+ let end = index;
543
+ while (text[end + 1] === ".") end += 1;
544
+ return end - start + 1 >= 3;
545
+ }
546
+ /**
547
+ * Halfwidth punctuation (約物) where Japanese prose calls for the fullwidth form. Japanese
548
+ * punctuation is designed to occupy one em, and the composer's spacing rules are stated in those
549
+ * terms, so a halfwidth form neither lands on the grid nor picks up the spacing its character class
550
+ * calls for.
551
+ *
552
+ * The three checks differ in how much the surroundings decide the answer, which is why only the
553
+ * first is decisive on its own.
554
+ */
555
+ const fullwidthJapanesePunctuationRule = () => ({
556
+ kind: "parsed",
557
+ meta: {
558
+ id: RULE_ID$3,
559
+ messages: MESSAGES$2
560
+ },
561
+ check: (manuscript, context) => {
562
+ for (const line of splitDisplayLines(manuscript.displayText)) for (const { pattern, messageId, severity, requiresJapanese } of CHECKS) for (const match of line.text.matchAll(pattern)) {
563
+ if (requiresJapanese && !isBesideJapanese(line.text, match.index)) continue;
564
+ if (isEllipsisSubstitute(line.text, match.index)) continue;
565
+ const start = line.start + match.index;
566
+ context.report({
567
+ range: displayRange(manuscript, start, start + match[0].length),
568
+ messageId,
569
+ data: { character: match[0] },
570
+ severity
571
+ });
572
+ }
573
+ }
574
+ });
575
+ //#endregion
576
+ //#region src/proofreading/rules/max-arabic-numeral-digits.ts
577
+ const RULE_ID$2 = "kg/max-arabic-numeral-digits";
578
+ const DEFAULT_MAX_DIGITS = 2;
579
+ const MaxDigitsSchema = v.pipe(v.number(), v.finite(), v.integer(), v.minValue(1));
580
+ /**
581
+ * Arabic numerals in vertical writing (縦組). A short run is set as 縦中横 (JLReq 3.2.5) and stays
582
+ * upright inside one em, but a longer one has nowhere to go: it either turns the line sideways or
583
+ * breaks across it. The usual limit is two digits, past which a novel spells the number in kanji.
584
+ */
585
+ function build$1(maxDigits) {
586
+ return defineMatchRule({
587
+ id: RULE_ID$2,
588
+ pattern: /([0-90-9]+)(?:[..]([0-90-9]+))?/gu,
589
+ accept: (match) => (match[1]?.length ?? 0) > maxDigits || (match[2]?.length ?? 0) > maxDigits,
590
+ message: `${maxDigits}桁を超えるアラビア数字が使われています`
591
+ });
592
+ }
593
+ const maxArabicNumeralDigitsRule = () => build$1(DEFAULT_MAX_DIGITS);
594
+ function createMaxArabicNumeralDigitsRule(options = {}) {
595
+ if (options.maxDigits === void 0) return ManuscriptResult.succeed(maxArabicNumeralDigitsRule());
596
+ const maxDigits = v.safeParse(MaxDigitsSchema, options.maxDigits);
597
+ if (!maxDigits.success) return ManuscriptResult.fail({
598
+ kind: "InvalidRuleOptions",
599
+ ruleId: RULE_ID$2,
600
+ option: "maxDigits",
601
+ issues: ValidationIssue.from(maxDigits.issues)
602
+ });
603
+ return ManuscriptResult.succeed(build$1(maxDigits.output));
604
+ }
605
+ //#endregion
606
+ //#region src/proofreading/rules/minus-before-number.ts
607
+ /**
608
+ * A minus sign (U+2212, JLReq 演算記号) with no number after it. It is almost always a mistyped dash or
609
+ * prolonged sound mark, which look alike but belong to different character classes and so are
610
+ * spaced and broken differently.
611
+ */
612
+ const minusBeforeNumberRule = () => defineMatchRule({
613
+ id: "kg/minus-before-number",
614
+ pattern: /−(?![0-90-9〇一二三四五六七八九十])/gu,
615
+ message: "マイナス記号の直後には数字が必要です"
616
+ });
617
+ //#endregion
618
+ //#region src/proofreading/rules/no-consecutive-interpunct.ts
619
+ /**
620
+ * A repeated interpunct (中黒, JLReq 中点類 cl-05). The interpunct separates items; repeating it is
621
+ * almost always a stand-in for an ellipsis, which `kg/ellipsis-character` names properly.
622
+ */
623
+ const noConsecutiveInterpunctRule = () => defineMatchRule({
624
+ id: "kg/no-consecutive-interpunct",
625
+ pattern: /・・+/gu,
626
+ message: "中黒が連続しています"
627
+ });
628
+ //#endregion
629
+ //#region src/proofreading/rules/no-consecutive-punctuation.ts
630
+ /**
631
+ * A repeated full stop or comma (句読点 — JLReq 句点類 cl-06 and 読点類 cl-07). Each ends its clause once,
632
+ * so a run of them is not punctuation at all: it is a sentence trailing off, which Japanese prose
633
+ * writes with `…`.
634
+ */
635
+ const noConsecutivePunctuationRule = () => defineMatchRule({
636
+ id: "kg/no-consecutive-punctuation",
637
+ pattern: /。。+|、、+/gu,
638
+ message: "句読点が連続しています"
639
+ });
640
+ //#endregion
641
+ //#region src/proofreading/rules/internal/decoration-line.ts
642
+ const DECORATION_LINE = /^[**◆◇■□●○▽▼△▲☆★・…―─━ー\-==~~\s]+$/u;
643
+ /**
644
+ * A line made only of symbols and spaces: a scene break or a section marker rather than a
645
+ * paragraph. Indentation rules skip these, because their layout is deliberate.
646
+ */
647
+ function isDecorationLine(text) {
648
+ return DECORATION_LINE.test(text);
649
+ }
650
+ //#endregion
651
+ //#region src/proofreading/rules/paragraph-opening.ts
652
+ const RULE_ID$1 = "kg/paragraph-opening";
653
+ const MESSAGES$1 = {
654
+ "leading-character": "段落の先頭には全角スペースまたは開き括弧が必要です",
655
+ "indent-width": "段落冒頭の字下げは全角スペース1字にしてください",
656
+ "unexpected-indent": "括弧から始まる段落を字下げすることはできません"
657
+ };
658
+ const OpeningBracketsSchema = v.pipe(v.string(), v.nonEmpty());
659
+ /**
660
+ * Paragraph indentation (字下げ, JLReq 3.5.1). Japanese prose opens a paragraph with exactly one
661
+ * ideographic space (和字間隔), and a paragraph opening with a bracket (始め括弧類) carries none, because
662
+ * the bracket is already set on a half em and reads as the indent itself.
663
+ *
664
+ * The three findings are branches of that one decision rather than separate rules, so a line can
665
+ * never collect two of them at once.
666
+ */
667
+ function build(openingBrackets) {
668
+ return {
669
+ kind: "parsed",
670
+ meta: {
671
+ id: RULE_ID$1,
672
+ messages: MESSAGES$1
673
+ },
674
+ check: (manuscript, context) => {
675
+ const report = (start, end, messageId) => {
676
+ context.report({
677
+ range: displayRange(manuscript, start, end),
678
+ messageId
679
+ });
680
+ };
681
+ for (const line of splitDisplayLines(manuscript.displayText)) {
682
+ if (isDecorationLine(line.text)) continue;
683
+ const indent = leadingSpaces(line.text);
684
+ const opening = line.text[indent.length];
685
+ if (opening === void 0) continue;
686
+ if (openingBrackets.includes(opening)) {
687
+ if (indent !== "") report(line.start, line.start + indent.length, "unexpected-indent");
688
+ } else if (!line.text.startsWith(" ")) {
689
+ const length = (line.text.codePointAt(0) ?? 0) > 65535 ? 2 : 1;
690
+ report(line.start, line.start + length, "leading-character");
691
+ } else if (indent.length !== 1) report(line.start, line.start + indent.length, "indent-width");
692
+ }
693
+ }
694
+ };
695
+ }
696
+ const paragraphOpeningRule = () => build(OPENING_BRACKETS);
697
+ function createParagraphOpeningRule(options = {}) {
698
+ if (options.openingBrackets === void 0) return ManuscriptResult.succeed(paragraphOpeningRule());
699
+ const brackets = v.safeParse(OpeningBracketsSchema, options.openingBrackets);
700
+ if (!brackets.success) return ManuscriptResult.fail({
701
+ kind: "InvalidRuleOptions",
702
+ ruleId: RULE_ID$1,
703
+ option: "openingBrackets",
704
+ issues: ValidationIssue.from(brackets.issues)
705
+ });
706
+ return ManuscriptResult.succeed(build(brackets.output));
707
+ }
708
+ //#endregion
709
+ //#region src/proofreading/rules/punctuation-before-closing-quote.ts
710
+ const PATTERN = new RegExp(`[。、]+(?=[${characterClass(CLOSING_BRACKETS)}])`, "gu");
711
+ /**
712
+ * A full stop or comma immediately before a closing bracket (終わり括弧類). The bracket already closes
713
+ * the quotation, and both characters are set on a half em, so the pair reads as a gap in the line
714
+ * rather than as punctuation. Japanese practice drops the punctuation and keeps the bracket.
715
+ */
716
+ const punctuationBeforeClosingQuoteRule = () => defineMatchRule({
717
+ id: "kg/punctuation-before-closing-quote",
718
+ pattern: PATTERN,
719
+ message: "閉じ括弧の直前に句読点を置くことはできません"
720
+ });
721
+ //#endregion
722
+ //#region src/proofreading/rules/space-after-question-or-exclamation.ts
723
+ const RULE_ID = "kg/space-after-question-or-exclamation";
724
+ const MESSAGES = {
725
+ default: "感嘆符または疑問符の直後には全角スペースか閉じ括弧が必要です",
726
+ "space-width": "感嘆符または疑問符の直後の空白は全角スペース1字にしてください",
727
+ "before-closing-bracket": "閉じ括弧の直前に空白を置くことはできません"
728
+ };
729
+ /**
730
+ * The gap that follows every run of ! or ? (区切り約物, JLReq 3.1.6). A dividing punctuation mark ends a
731
+ * sentence without a full stop, so a full em of white stands in for the one a 句点 would have
732
+ * carried. A run at the end of a line or right before a closing bracket is already separated from
733
+ * what follows and needs no gap; anywhere else it takes exactly one ideographic space.
734
+ */
735
+ const spaceAfterQuestionOrExclamationRule = () => ({
736
+ kind: "parsed",
737
+ meta: {
738
+ id: RULE_ID,
739
+ messages: MESSAGES
740
+ },
741
+ check: (manuscript, context) => {
742
+ const report = (start, end, messageId) => {
743
+ context.report({
744
+ range: displayRange(manuscript, start, end),
745
+ messageId
746
+ });
747
+ };
748
+ for (const line of splitDisplayLines(manuscript.displayText)) for (const spacing of questionOrExclamationSpacings(line.text)) switch (spacing.kind) {
749
+ case "valid": break;
750
+ case "missing":
751
+ report(line.start + spacing.marks.start, line.start + spacing.marks.end, "default");
752
+ break;
753
+ case "invalid-space":
754
+ report(line.start + spacing.gap.start, line.start + spacing.gap.end, "space-width");
755
+ break;
756
+ case "before-closing-bracket":
757
+ report(line.start + spacing.gap.start, line.start + spacing.gap.end, "before-closing-bracket");
758
+ break;
759
+ default: assertNever(spacing);
760
+ }
761
+ }
762
+ });
763
+ //#endregion
764
+ //#region src/proofreading/rules/variant-character.ts
765
+ /**
766
+ * Mongolian free variation selectors, variation selectors, CJK compatibility ideographs, IVSes.
767
+ */
768
+ function isVariantCharacter(character) {
769
+ const codePoint = character.codePointAt(0);
770
+ if (codePoint === void 0) return false;
771
+ return codePoint >= 6155 && codePoint <= 6157 || codePoint === 6159 || codePoint >= 65024 && codePoint <= 65039 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 194560 && codePoint <= 195103 || codePoint >= 917760 && codePoint <= 917999;
772
+ }
773
+ /**
774
+ * Variant forms of a character (異体字) and the selectors that request them (字形選択子 / IVS). Whether
775
+ * such a form survives depends on the font and the platform, so a manuscript that relies on one
776
+ * will not read the same everywhere it is published.
777
+ *
778
+ * Scans the source rather than the display text: notation removal must not hide a variant.
779
+ */
780
+ const variantCharacterRule = () => defineParsedRule("kg/variant-character", "異体字または字形選択子が使われています", (manuscript, report) => {
781
+ for (const { index, segment } of graphemeSegmenter.segment(manuscript.source)) if (Array.from(segment).some(isVariantCharacter)) report(sourceRange(manuscript, {
782
+ start: index,
783
+ end: index + segment.length
784
+ }));
785
+ });
786
+ //#endregion
787
+ //#region src/proofreading/rules/default-rules.ts
788
+ /**
789
+ * The built-in rule set at its default configuration. Infallible by construction: every rule is
790
+ * built from constants, so there is no options error for a caller to handle.
791
+ *
792
+ * Rules whose answer depends on the work's own conventions — which width numerals take, whether a
793
+ * word is written in kanji or kana — are exported individually instead of included here.
794
+ */
795
+ function createDefaultProofreadingRules() {
796
+ return [
797
+ paragraphOpeningRule(),
798
+ punctuationBeforeClosingQuoteRule(),
799
+ spaceAfterQuestionOrExclamationRule(),
800
+ evenEllipsisRule(),
801
+ dashRule(),
802
+ ellipsisCharacterRule(),
803
+ noConsecutivePunctuationRule(),
804
+ noConsecutiveInterpunctRule(),
805
+ minusBeforeNumberRule(),
806
+ maxArabicNumeralDigitsRule(),
807
+ fullwidthJapanesePunctuationRule(),
808
+ variantCharacterRule()
809
+ ];
810
+ }
811
+ //#endregion
812
+ export { InvalidRuleOptions, ProofreadError, ProofreadingRuleMeta, consistentKanjiOpeningRule, consistentLatinWidthRule, consistentNumeralWidthRule, createConsistentKanjiOpeningRule, createDashRule, createDefaultProofreadingRules, createMaxArabicNumeralDigitsRule, createParagraphOpeningRule, dashRule, ellipsisCharacterRule, evenEllipsisRule, fullwidthJapanesePunctuationRule, maxArabicNumeralDigitsRule, minusBeforeNumberRule, noConsecutiveInterpunctRule, noConsecutivePunctuationRule, paragraphOpeningRule, proofreadManuscript, punctuationBeforeClosingQuoteRule, spaceAfterQuestionOrExclamationRule, variantCharacterRule };