chaffjs 0.8.0 → 0.10.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/address-chain.d.ts +9 -0
- package/dist/address-chain.d.ts.map +1 -0
- package/dist/address-chain.js +105 -0
- package/dist/address-chain.js.map +1 -0
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +1 -1
- package/dist/cli-args.js.map +1 -1
- package/dist/cli-text.d.ts.map +1 -1
- package/dist/cli-text.js +4 -0
- package/dist/cli-text.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +2 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/feedback.d.ts +35 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/feedback.js +157 -0
- package/dist/commands/feedback.js.map +1 -0
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +3 -2
- package/dist/commands/test.js.map +1 -1
- package/dist/commands/tree.d.ts.map +1 -1
- package/dist/commands/tree.js +3 -1
- package/dist/commands/tree.js.map +1 -1
- package/dist/config/by-path.d.ts +5 -1
- package/dist/config/by-path.d.ts.map +1 -1
- package/dist/config/by-path.js +1 -4
- package/dist/config/by-path.js.map +1 -1
- package/dist/config/load.d.ts +2 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +3 -1
- package/dist/config/load.js.map +1 -1
- package/dist/detectors/char-shape.d.ts.map +1 -1
- package/dist/detectors/char-shape.js +3 -2
- package/dist/detectors/char-shape.js.map +1 -1
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +4 -1
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/lexicon.d.ts.map +1 -1
- package/dist/detectors/lexicon.js +8 -1
- package/dist/detectors/lexicon.js.map +1 -1
- package/dist/detectors/structure-tree.d.ts +5 -0
- package/dist/detectors/structure-tree.d.ts.map +1 -1
- package/dist/detectors/structure-tree.js +50 -1
- package/dist/detectors/structure-tree.js.map +1 -1
- package/dist/detectors/team.d.ts.map +1 -1
- package/dist/detectors/team.js +7 -2
- package/dist/detectors/team.js.map +1 -1
- package/dist/detectors/word-list.d.ts +3 -0
- package/dist/detectors/word-list.d.ts.map +1 -0
- package/dist/detectors/word-list.js +3 -0
- package/dist/detectors/word-list.js.map +1 -0
- package/dist/document.d.ts +2 -2
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +4 -2
- package/dist/document.js.map +1 -1
- package/dist/feedback/draft.d.ts +28 -0
- package/dist/feedback/draft.d.ts.map +1 -0
- package/dist/feedback/draft.js +66 -0
- package/dist/feedback/draft.js.map +1 -0
- package/dist/feedback/excerpt.d.ts +10 -0
- package/dist/feedback/excerpt.d.ts.map +1 -0
- package/dist/feedback/excerpt.js +20 -0
- package/dist/feedback/excerpt.js.map +1 -0
- package/dist/legal-address.d.ts +2 -0
- package/dist/legal-address.d.ts.map +1 -0
- package/dist/legal-address.js +16 -0
- package/dist/legal-address.js.map +1 -0
- package/dist/orthography.d.ts.map +1 -1
- package/dist/orthography.js +15 -0
- package/dist/orthography.js.map +1 -1
- package/dist/plugin.d.ts +98 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/profile/for-file.d.ts +7 -0
- package/dist/profile/for-file.d.ts.map +1 -0
- package/dist/profile/for-file.js +6 -0
- package/dist/profile/for-file.js.map +1 -0
- package/dist/profile/load.d.ts +4 -0
- package/dist/profile/load.d.ts.map +1 -0
- package/dist/profile/load.js +25 -0
- package/dist/profile/load.js.map +1 -0
- package/dist/profile/parse.d.ts +19 -0
- package/dist/profile/parse.d.ts.map +1 -0
- package/dist/profile/parse.js +95 -0
- package/dist/profile/parse.js.map +1 -0
- package/dist/profile/select.d.ts +22 -0
- package/dist/profile/select.d.ts.map +1 -0
- package/dist/profile/select.js +21 -0
- package/dist/profile/select.js.map +1 -0
- package/dist/reasons.d.ts +1 -0
- package/dist/reasons.d.ts.map +1 -1
- package/dist/reasons.js +2 -0
- package/dist/reasons.js.map +1 -1
- package/dist/render/compact.d.ts.map +1 -1
- package/dist/render/compact.js +3 -1
- package/dist/render/compact.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +40 -7
- package/dist/run.js.map +1 -1
- package/dist/structure/build.d.ts +2 -1
- package/dist/structure/build.d.ts.map +1 -1
- package/dist/structure/build.js +105 -11
- package/dist/structure/build.js.map +1 -1
- package/dist/structure/date-order.d.ts +12 -0
- package/dist/structure/date-order.d.ts.map +1 -0
- package/dist/structure/date-order.js +30 -0
- package/dist/structure/date-order.js.map +1 -0
- package/dist/structure/issues.d.ts.map +1 -1
- package/dist/structure/issues.js +9 -2
- package/dist/structure/issues.js.map +1 -1
- package/dist/structure/of.d.ts +2 -1
- package/dist/structure/of.d.ts.map +1 -1
- package/dist/structure/of.js +7 -1
- package/dist/structure/of.js.map +1 -1
- package/dist/structure/relative-find.d.ts +8 -0
- package/dist/structure/relative-find.d.ts.map +1 -0
- package/dist/structure/relative-find.js +190 -0
- package/dist/structure/relative-find.js.map +1 -0
- package/dist/structure/relative-resolve.d.ts +3 -0
- package/dist/structure/relative-resolve.d.ts.map +1 -0
- package/dist/structure/relative-resolve.js +175 -0
- package/dist/structure/relative-resolve.js.map +1 -0
- package/dist/structure/runs.d.ts +20 -0
- package/dist/structure/runs.d.ts.map +1 -0
- package/dist/structure/runs.js +63 -0
- package/dist/structure/runs.js.map +1 -0
- package/dist/structure/total.d.ts +14 -0
- package/dist/structure/total.d.ts.map +1 -0
- package/dist/structure/total.js +119 -0
- package/dist/structure/total.js.map +1 -0
- package/dist/structure/unnumbered.d.ts +15 -0
- package/dist/structure/unnumbered.d.ts.map +1 -0
- package/dist/structure/unnumbered.js +28 -0
- package/dist/structure/unnumbered.js.map +1 -0
- package/dist/structure/unread.d.ts +8 -0
- package/dist/structure/unread.d.ts.map +1 -0
- package/dist/structure/unread.js +34 -0
- package/dist/structure/unread.js.map +1 -0
- package/dist/structure/weekday.d.ts +7 -0
- package/dist/structure/weekday.d.ts.map +1 -0
- package/dist/structure/weekday.js +21 -0
- package/dist/structure/weekday.js.map +1 -0
- package/package.json +4 -3
- package/profiles/statute.yaml +66 -0
- package/rules/date-order.yaml +26 -0
- package/rules/date-weekday-mismatch.yaml +26 -0
- package/rules/internal-jargon.yaml +1 -1
- package/rules/total-mismatch.yaml +26 -0
- package/skills/chaff/SKILL.md +24 -4
- package/src/address-chain.ts +113 -0
- package/src/cli-args.ts +1 -1
- package/src/cli-text.ts +4 -0
- package/src/cli.ts +20 -1
- package/src/commands/eval.ts +2 -1
- package/src/commands/feedback.ts +210 -0
- package/src/commands/test.ts +3 -2
- package/src/commands/tree.ts +3 -1
- package/src/config/by-path.ts +11 -3
- package/src/config/load.ts +5 -1
- package/src/detectors/char-shape.ts +5 -4
- package/src/detectors/index.ts +4 -1
- package/src/detectors/lexicon.ts +11 -1
- package/src/detectors/structure-tree.ts +67 -1
- package/src/detectors/team.ts +7 -2
- package/src/detectors/word-list.ts +2 -0
- package/src/document.ts +11 -3
- package/src/feedback/draft.ts +102 -0
- package/src/feedback/excerpt.ts +23 -0
- package/src/orthography.ts +15 -0
- package/src/plugin.ts +95 -0
- package/src/profile/for-file.ts +11 -0
- package/src/profile/load.ts +26 -0
- package/src/profile/parse.ts +119 -0
- package/src/profile/select.ts +35 -0
- package/src/reasons.ts +5 -0
- package/src/render/compact.ts +4 -1
- package/src/run.ts +42 -7
- package/src/structure/build.ts +135 -15
- package/src/structure/date-order.ts +44 -0
- package/src/structure/issues.ts +10 -2
- package/src/structure/of.ts +15 -3
- package/src/structure/relative-find.ts +237 -0
- package/src/structure/relative-resolve.ts +209 -0
- package/src/structure/runs.ts +71 -0
- package/src/structure/total.ts +157 -0
- package/src/structure/unnumbered.ts +33 -0
- package/src/structure/unread.ts +39 -0
- package/src/structure/weekday.ts +25 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { StructureNode } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 相対の参照(relative-find.ts が見つけたもの)に番地を与える。木ができてからでないと「次条」は決まらないので、ここは木を読むだけの後段。
|
|
5
|
+
* 前・次は、書いた場所を含む同じ深さのまとまりの前後。条は文書を通して並ぶので文書全体、項と号は同じ親の中で数える。
|
|
6
|
+
* 同は、その前の参照(相対のものも含む)の番地を、その深さまで切ったもの。他の文書を指していれば、それも引き継ぐ。
|
|
7
|
+
* 番地を決められないもの(最初の条の「前条」、何も引いていない「同条」)は木から外す。当て推量の番地で誤りを言わない。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** range は「第四十三条から第五十五条まで 削除」のように、一行で並びをまとめたもの。番地は最初のものしか持たない。 */
|
|
11
|
+
type Place = { readonly address: string; readonly level: number; readonly range?: boolean };
|
|
12
|
+
|
|
13
|
+
type Last = { readonly target: string; readonly document: string | undefined };
|
|
14
|
+
|
|
15
|
+
const NUMBERED = new Set(["chapter", "article", "item"]);
|
|
16
|
+
|
|
17
|
+
const placeOf = (node: StructureNode): Place | undefined =>
|
|
18
|
+
NUMBERED.has(node.kind) && node.level !== undefined ? { address: node.address, level: node.level, range: node.ordinalTo !== undefined } : undefined;
|
|
19
|
+
|
|
20
|
+
/** 番号の付いた子。Markdown の見出し(section)は番号の外なので、その中まで見る。 */
|
|
21
|
+
const numberedChildren = (node: StructureNode, level: number): StructureNode[] =>
|
|
22
|
+
node.children.flatMap((child) => {
|
|
23
|
+
if (NUMBERED.has(child.kind)) return child.level === level ? [child] : [];
|
|
24
|
+
return child.kind === "section" ? numberedChildren(child, level) : [];
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
type Context = {
|
|
28
|
+
readonly articles: readonly Place[];
|
|
29
|
+
/** 条の深さ。同の番地を切るときの起点。 */
|
|
30
|
+
readonly articleLevel: number | undefined;
|
|
31
|
+
/** 最初のものに番号を振らない深さ(法令の項)。 */
|
|
32
|
+
readonly implicitLevel: number | undefined;
|
|
33
|
+
/** 深さごとに、最後に名指しされた番地。「前項」は項だけを名指しするので、「同条」の行き先を変えない。 */
|
|
34
|
+
readonly last: Map<number, Last>;
|
|
35
|
+
/** 参照を、始まる位置で引けるようにする。「若しくは第九項」のような並びの続きが、元の参照を引く。 */
|
|
36
|
+
readonly byStart: Map<number, Last>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** 同じ親の下で同じ深さのもの。番号の無い最初のもの(法令の第 1 項)があるなら、それを先頭に足す。 */
|
|
40
|
+
const siblingsUnder = (parent: StructureNode, level: number, implicitLevel: number | undefined): Place[] => {
|
|
41
|
+
const children = numberedChildren(parent, level);
|
|
42
|
+
const explicit = children.flatMap((child) => {
|
|
43
|
+
const place = placeOf(child);
|
|
44
|
+
return place === undefined ? [] : [place];
|
|
45
|
+
});
|
|
46
|
+
const firstOrdinal = children[0]?.ordinal;
|
|
47
|
+
const implicit = level === implicitLevel && parent.level === level - 1 && firstOrdinal !== 1;
|
|
48
|
+
return implicit ? [{ address: `${parent.address}.1`, level }, ...explicit] : explicit;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Anchor = { readonly place: Place; readonly siblings: readonly Place[] };
|
|
52
|
+
|
|
53
|
+
/** 書いた場所を含む、その深さのまとまり。第 1 項の本文にいて項が開いていなければ、番号の無い第 1 項。 */
|
|
54
|
+
const anchorAt = (path: readonly StructureNode[], level: number, context: Context): Anchor | undefined => {
|
|
55
|
+
const index = path.findLastIndex((node) => NUMBERED.has(node.kind) && node.level === level);
|
|
56
|
+
const node = path[index];
|
|
57
|
+
if (node !== undefined) {
|
|
58
|
+
const place = placeOf(node);
|
|
59
|
+
// 親は番号の付いた一つ上のまとまり。あいだの Markdown の見出しは飛ばす。無ければ文書(条より外のまとまり)。
|
|
60
|
+
const parent = path.slice(0, index).findLast((candidate) => NUMBERED.has(candidate.kind) && (candidate.level ?? 0) < level) ?? path[0];
|
|
61
|
+
if (place === undefined || parent === undefined) return undefined;
|
|
62
|
+
return { place, siblings: node.kind === "article" ? context.articles : siblingsUnder(parent, level, context.implicitLevel) };
|
|
63
|
+
}
|
|
64
|
+
const parent = path.findLast((candidate) => NUMBERED.has(candidate.kind) && candidate.level === level - 1);
|
|
65
|
+
if (level !== context.implicitLevel || parent === undefined) return undefined;
|
|
66
|
+
const siblings = siblingsUnder(parent, level, context.implicitLevel);
|
|
67
|
+
const first = siblings[0];
|
|
68
|
+
return first === undefined ? undefined : { place: first, siblings };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* by だけ前後にずらす。範囲の行(第四十三条から第五十五条まで 削除)は中の条を一つずつ持たないので、数えてまたげば行き先がずれる。
|
|
73
|
+
* 前に数えて範囲をまたぐか範囲に当たる、後ろに数えて範囲をまたぐなら決めない。後ろに数えて範囲に当たるのは、その最初の条なので正しい。
|
|
74
|
+
*/
|
|
75
|
+
const shifted = (anchor: Anchor, by: number): Place | undefined => {
|
|
76
|
+
const index = anchor.siblings.findIndex((place) => place.address === anchor.place.address);
|
|
77
|
+
if (index === -1) return undefined;
|
|
78
|
+
const crossed = by > 0 ? anchor.siblings.slice(index + 1, index + by) : anchor.siblings.slice(Math.max(0, index + by), index);
|
|
79
|
+
return crossed.some((place) => place.range === true) ? undefined : anchor.siblings[index + by];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** 前の全部(前各項)は、前に一つでもあれば最初のもの。 */
|
|
83
|
+
const allBefore = (anchor: Anchor): Place | undefined => (shifted(anchor, -1) === undefined ? undefined : anchor.siblings[0]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 前・次・本で決まる場所。「各」(count 0)は前の全部なので、最初のものを指す。
|
|
87
|
+
*/
|
|
88
|
+
const byPosition = (way: string, count: number, anchor: Anchor): Place | undefined => {
|
|
89
|
+
if (way === "current") return anchor.place;
|
|
90
|
+
if (way === "after") return shifted(anchor, Math.max(count, 1));
|
|
91
|
+
return count === 0 ? allBefore(anchor) : shifted(anchor, -count);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** 同: その前の参照の番地を、この深さまで切る。条より外のまとまりは番地の形が違うので扱わない。 */
|
|
95
|
+
const bySame = (level: number, last: Last | undefined): Place | undefined => (last === undefined ? undefined : { address: last.target, level });
|
|
96
|
+
|
|
97
|
+
/** 並びの続き: 元の参照の番地を、この深さまで切る。元の参照の番地が決まらなければ決めない。 */
|
|
98
|
+
const continued = (level: number, node: StructureNode, context: Context): Last | undefined => {
|
|
99
|
+
const origin = context.byStart.get(node.span.start - Number(node.attrs["continues"]));
|
|
100
|
+
if (origin === undefined || context.articleLevel === undefined || level < context.articleLevel) return undefined;
|
|
101
|
+
const parts = origin.target.split(".");
|
|
102
|
+
const kept = level - context.articleLevel + 1;
|
|
103
|
+
return parts.length < kept ? undefined : { target: parts.slice(0, kept).join("."), document: origin.document };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 参照が名指しした深さを覚える。名指しは、書き出しの深さ(第〇条なら条、前項なら項)から番地の深さまで。
|
|
108
|
+
* 番地を深さで切る起点は条。条より外のまとまりは番地の形が違うので覚えない。
|
|
109
|
+
*/
|
|
110
|
+
const remember = (context: Context, target: string, document: string | undefined, from: number): void => {
|
|
111
|
+
const articleLevel = context.articleLevel;
|
|
112
|
+
// 章・節は番地の形(ch1)が条から始まらないので覚えない。覚えると「同条」が章を指してしまう。
|
|
113
|
+
if (articleLevel === undefined || from < articleLevel) return;
|
|
114
|
+
const parts = target.split(".");
|
|
115
|
+
parts.forEach((_, index) => {
|
|
116
|
+
const level = articleLevel + index;
|
|
117
|
+
if (level >= from) context.last.set(level, { target: parts.slice(0, index + 1).join("."), document });
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** 「第二項第三号」を足す。一つ深いものは続け、二つ深い号は番号の無い第 1 項の中(x.1.n)に置く。 */
|
|
122
|
+
const withSuffix = (place: Place, suffix: string, implicitLevel: number | undefined): Place | undefined =>
|
|
123
|
+
suffix
|
|
124
|
+
.split(",")
|
|
125
|
+
.filter((part) => part !== "")
|
|
126
|
+
.reduce<Place | undefined>((current, part) => {
|
|
127
|
+
const [depth, value] = part.split(":").map(Number);
|
|
128
|
+
if (current === undefined || depth === undefined || value === undefined) return undefined;
|
|
129
|
+
if (depth === current.level + 1) return { address: `${current.address}.${String(value)}`, level: depth };
|
|
130
|
+
if (implicitLevel === current.level + 1 && depth === current.level + 2) return { address: `${current.address}.1.${String(value)}`, level: depth };
|
|
131
|
+
return undefined;
|
|
132
|
+
}, place);
|
|
133
|
+
|
|
134
|
+
/** 番号の無い第 1 項(x.1)は、条そのものでもある。 */
|
|
135
|
+
const fallbackOf = (place: Place, implicitLevel: number | undefined): string | undefined =>
|
|
136
|
+
place.level === implicitLevel && place.address.endsWith(".1") ? place.address.slice(0, -".1".length) : undefined;
|
|
137
|
+
|
|
138
|
+
const baseOf = (way: string, node: StructureNode, anchor: Anchor | undefined, last: Last | undefined, context: Context): Place | undefined => {
|
|
139
|
+
const level = Number(node.attrs["level"]);
|
|
140
|
+
if (way === "same") return bySame(level, last);
|
|
141
|
+
if (way === "continue") {
|
|
142
|
+
const origin = continued(level, node, context);
|
|
143
|
+
return origin === undefined ? undefined : { address: origin.target, level };
|
|
144
|
+
}
|
|
145
|
+
return anchor === undefined ? undefined : byPosition(way, Number(node.attrs["count"]), anchor);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** 同と並びの続きは、元の参照が他の文書を指していれば、それを引き継ぐ。前・次・本はこの文書の中。 */
|
|
149
|
+
const documentOf = (way: string, node: StructureNode, last: Last | undefined, context: Context): string | undefined => {
|
|
150
|
+
if (way === "same") return last?.document;
|
|
151
|
+
return way === "continue" ? continued(Number(node.attrs["level"]), node, context)?.document : undefined;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const resolveOne = (node: StructureNode, path: readonly StructureNode[], context: Context): StructureNode | undefined => {
|
|
155
|
+
const way = String(node.attrs["relative"]);
|
|
156
|
+
const level = Number(node.attrs["level"]);
|
|
157
|
+
const last = context.last.get(level);
|
|
158
|
+
const anchor = way === "same" || way === "continue" ? undefined : anchorAt(path, level, context);
|
|
159
|
+
const base = baseOf(way, node, anchor, last, context);
|
|
160
|
+
const place = base === undefined ? undefined : withSuffix(base, String(node.attrs["suffix"] ?? ""), context.implicitLevel);
|
|
161
|
+
if (place === undefined) return undefined;
|
|
162
|
+
const fallback = fallbackOf(place, context.implicitLevel);
|
|
163
|
+
const document = documentOf(way, node, last, context);
|
|
164
|
+
const attrs = {
|
|
165
|
+
target: place.address,
|
|
166
|
+
label: String(node.attrs["label"]),
|
|
167
|
+
...(fallback === undefined ? {} : { fallback }),
|
|
168
|
+
...(document === undefined ? {} : { document }),
|
|
169
|
+
};
|
|
170
|
+
return { ...node, attrs };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const isRelative = (node: StructureNode): boolean => node.kind === "reference" && node.attrs["relative"] !== undefined;
|
|
174
|
+
|
|
175
|
+
/** 文書の順に読み、参照ごとに「その前の参照」を覚える。 */
|
|
176
|
+
const walk = (node: StructureNode, path: readonly StructureNode[], context: Context): StructureNode | undefined => {
|
|
177
|
+
if (isRelative(node)) {
|
|
178
|
+
const resolved = resolveOne(node, path, context);
|
|
179
|
+
const target = resolved === undefined ? undefined : String(resolved.attrs["target"]);
|
|
180
|
+
const document = optional(resolved?.attrs["document"]);
|
|
181
|
+
if (target !== undefined) context.byStart.set(node.span.start, { target, document });
|
|
182
|
+
if (target !== undefined) remember(context, target, document, Number(node.attrs["names"] ?? node.attrs["level"]));
|
|
183
|
+
return resolved;
|
|
184
|
+
}
|
|
185
|
+
if (node.kind === "reference") {
|
|
186
|
+
const target = String(node.attrs["target"]);
|
|
187
|
+
const document = optional(node.attrs["document"]);
|
|
188
|
+
context.byStart.set(node.span.start, { target, document });
|
|
189
|
+
remember(context, target, document, context.articleLevel ?? 0);
|
|
190
|
+
}
|
|
191
|
+
const inside = [...path, node];
|
|
192
|
+
const children = node.children.flatMap((child) => {
|
|
193
|
+
const kept = walk(child, inside, context);
|
|
194
|
+
return kept === undefined ? [] : [kept];
|
|
195
|
+
});
|
|
196
|
+
return { ...node, children };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const optional = (value: string | number | undefined): string | undefined => (value === undefined ? undefined : String(value));
|
|
200
|
+
|
|
201
|
+
const articlesOf = (node: StructureNode): Place[] => {
|
|
202
|
+
const place = node.kind === "article" ? placeOf(node) : undefined;
|
|
203
|
+
return [...(place === undefined ? [] : [place]), ...node.children.flatMap(articlesOf)];
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const resolveRelative = (tree: StructureNode, implicitLevel: number | undefined): StructureNode => {
|
|
207
|
+
const articles = articlesOf(tree);
|
|
208
|
+
return walk(tree, [], { articles, articleLevel: articles[0]?.level, implicitLevel, last: new Map(), byStart: new Map() }) ?? tree;
|
|
209
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 箇条書きと表の行の並び。続いた箇条書きの同じ深さの項目か、続いた表の行を一つの並びとして読む。
|
|
3
|
+
* 並びの中の値(日付、金額)を比べる rule が共通に使う。
|
|
4
|
+
*/
|
|
5
|
+
type Kind = "list" | "table";
|
|
6
|
+
|
|
7
|
+
const LIST_ITEM = /^[ \t]{0,12}(?:[-*+]|\d{1,3}[.)])[ \t]/u;
|
|
8
|
+
const TABLE_ROW = /^[ \t]{0,12}\|/u;
|
|
9
|
+
|
|
10
|
+
const TABLE_RULE = /^[ \t]{0,12}\|?[ \t]{0,4}:?-{3,}/u;
|
|
11
|
+
const INDENT = /^[ \t]*/u;
|
|
12
|
+
|
|
13
|
+
export type Line = { readonly start: number; readonly end: number; readonly kind: Kind | undefined; readonly indent: number; readonly rule: boolean };
|
|
14
|
+
|
|
15
|
+
const kindOf = (text: string): Kind | undefined => {
|
|
16
|
+
if (TABLE_ROW.test(text)) return "table";
|
|
17
|
+
return LIST_ITEM.test(text) ? "list" : undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** 先頭に | を書かない表(「Step | Date」「--- | ---」)の行。| のある見出しの下の区切り行から、| のある行が続くあいだ。 */
|
|
21
|
+
const PIPELESS_RULE = /^[ \t]{0,12}:?-{3,}:?[ \t]{0,4}\|/u;
|
|
22
|
+
|
|
23
|
+
const pipelessTableLines = (texts: readonly string[]): Set<number> => {
|
|
24
|
+
const inTable = new Set<number>();
|
|
25
|
+
texts.forEach((text, index) => {
|
|
26
|
+
if (!PIPELESS_RULE.test(text) || !(texts[index - 1] ?? "").includes("|")) return;
|
|
27
|
+
for (let row = index + 1; row < texts.length && (texts[row] ?? "").includes("|"); row += 1) inTable.add(row);
|
|
28
|
+
});
|
|
29
|
+
return inTable;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const linesOf = (source: string): Line[] => {
|
|
33
|
+
const texts = source.split("\n");
|
|
34
|
+
const pipeless = pipelessTableLines(texts);
|
|
35
|
+
const found: Line[] = [];
|
|
36
|
+
let start = 0;
|
|
37
|
+
texts.forEach((text, index) => {
|
|
38
|
+
const indent = INDENT.exec(text)?.[0].length ?? 0;
|
|
39
|
+
const kind = pipeless.has(index) ? "table" : kindOf(text);
|
|
40
|
+
found.push({ start, end: start + text.length, kind, indent, rule: TABLE_RULE.test(text) });
|
|
41
|
+
start += text.length + 1;
|
|
42
|
+
});
|
|
43
|
+
return found;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 続いた箇条書きや表の中で、同じ深さの行を一つの並びにする。
|
|
48
|
+
* 子の項目(深い字下げ)は親の並びを切らずに、親ごとに別の並びになる。浅い項目が来たら、それより深い並びは閉じる。
|
|
49
|
+
* 表の区切り行(|---|)のすぐ前の行は見出しなので、並びから外す。
|
|
50
|
+
*/
|
|
51
|
+
export const runsOf = (source: string): Line[][] => {
|
|
52
|
+
const runs: Line[][] = [];
|
|
53
|
+
const open = new Map<number, Line[]>();
|
|
54
|
+
const close = (deeperThan: number): void => [...open.keys()].filter((depth) => depth > deeperThan).forEach((depth) => open.delete(depth));
|
|
55
|
+
linesOf(source).forEach((line, index, all) => {
|
|
56
|
+
if (line.kind === undefined || all[index - 1]?.kind !== line.kind) close(-1);
|
|
57
|
+
if (line.kind === undefined) return;
|
|
58
|
+
if (line.rule) {
|
|
59
|
+
open.get(line.indent)?.pop();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
close(line.indent);
|
|
63
|
+
const run = open.get(line.indent) ?? [];
|
|
64
|
+
if (!open.has(line.indent)) {
|
|
65
|
+
open.set(line.indent, run);
|
|
66
|
+
runs.push(run);
|
|
67
|
+
}
|
|
68
|
+
run.push(line);
|
|
69
|
+
});
|
|
70
|
+
return runs;
|
|
71
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { StructureIssue } from "./issues.ts";
|
|
2
|
+
import { runsOf, type Line } from "./runs.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 合計の行と、その上に並べた金額の和。箇条書きの続いた項目か、表の続いた行で、合計の語(合計・小計・Total)で始まる行を読む。
|
|
6
|
+
* 同じ単位で同じ列(箇条書きは一つの列)の金額を上から足し、合計の行の金額と合わなければ言う。
|
|
7
|
+
* 小計と税のあとの総計のように、足し方が何通りかある。どの足し方でも合わないときだけ言う。
|
|
8
|
+
*/
|
|
9
|
+
export type Amount = { readonly offset: number; readonly end: number; readonly value: number; readonly unit: string };
|
|
10
|
+
|
|
11
|
+
const CENTS = 100;
|
|
12
|
+
const MIN_PARTS = 2;
|
|
13
|
+
|
|
14
|
+
/** 金額の前に書いた負の印(-500、▲500、-$500)。行頭の箇条書きの - は印にしない。 */
|
|
15
|
+
const NEGATIVE_BEFORE = /[-−▲△][ \t]?(?:[$€£¥¥][ \t]?)?$/u;
|
|
16
|
+
|
|
17
|
+
/** 括弧に入れた金額((1,200))。会計では負の数だが、補足の金額とも読めて符号が決まらない。 */
|
|
18
|
+
const OPEN_PAREN_BEFORE = /[((][ \t]?(?:[$€£¥¥][ \t]?)?$/u;
|
|
19
|
+
|
|
20
|
+
type Cell = { readonly column: number; readonly unit: string };
|
|
21
|
+
/** cents が undefined の金額は、符号が決まらない。 */
|
|
22
|
+
type Placed = Amount & Cell & { readonly cents: number | undefined };
|
|
23
|
+
type Entry = { readonly label: boolean; readonly amounts: readonly Placed[] };
|
|
24
|
+
|
|
25
|
+
/** 表の列の区切り。\| は列の中に書いた | なので数えない。 */
|
|
26
|
+
const CELL_SEPARATOR = /(?<!\\)\|/u;
|
|
27
|
+
|
|
28
|
+
const columnOf = (source: string, line: Line, offset: number): number => source.slice(line.start, offset).split(CELL_SEPARATOR).length - 1;
|
|
29
|
+
|
|
30
|
+
const isNegative = (source: string, line: Line, amount: Amount): boolean => {
|
|
31
|
+
const before = source.slice(line.start, amount.offset);
|
|
32
|
+
const marker = NEGATIVE_BEFORE.exec(before);
|
|
33
|
+
return marker !== null && before.slice(0, marker.index).trim().length > 0;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** 行の最初の文字列(表なら最初の列)から、強調の印と箇条書きの印を除いたもの。 */
|
|
37
|
+
const leadingText = (text: string): string =>
|
|
38
|
+
text
|
|
39
|
+
.replace(/^[ \t]*(?:[-*+]|\d{1,3}[.)])?[ \t]*\|?[ \t]*/u, "")
|
|
40
|
+
.replace(/^[*_]+/u, "")
|
|
41
|
+
.trimStart();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 合計の語のすぐ後ろ。行や列の終わり、区切り(: |)、括弧の注記(合計(税込))、金額。
|
|
45
|
+
* 「計画」や「Total conversion: 25%」のように語が続くものは合計の行ではない。
|
|
46
|
+
*/
|
|
47
|
+
const AFTER_LABEL = /^[*_]*[ \t\u3000]*(?:$|[::|((]|[-−▲△$€£¥¥\p{N}])/u;
|
|
48
|
+
|
|
49
|
+
const isTotalLabel = (text: string, labels: readonly string[]): boolean => {
|
|
50
|
+
const lead = leadingText(text).toLowerCase();
|
|
51
|
+
return labels.some((label) => lead.startsWith(label.toLowerCase()) && AFTER_LABEL.test(lead.slice(label.length)));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const centsOf = (source: string, line: Line, amount: Amount): number | undefined => {
|
|
55
|
+
if (OPEN_PAREN_BEFORE.test(source.slice(line.start, amount.offset))) return undefined;
|
|
56
|
+
return Math.round(amount.value * CENTS) * (isNegative(source, line, amount) ? -1 : 1);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const entryOf = (source: string, line: Line, amounts: readonly Amount[], labels: readonly string[]): Entry => ({
|
|
60
|
+
label: isTotalLabel(source.slice(line.start, line.end), labels),
|
|
61
|
+
amounts: amounts
|
|
62
|
+
.filter((amount) => amount.offset >= line.start && amount.offset <= line.end)
|
|
63
|
+
.map((amount) => ({ ...amount, column: columnOf(source, line, amount.offset), cents: centsOf(source, line, amount) })),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const sameCell = (a: Cell, b: Cell): boolean => a.column === b.column && a.unit === b.unit;
|
|
67
|
+
|
|
68
|
+
const sumOf = (values: readonly number[]): number => values.reduce((sum, value) => sum + value, 0);
|
|
69
|
+
|
|
70
|
+
type RowsAbove = { readonly parts: number[]; readonly totals: number[]; sinceLast: number[] };
|
|
71
|
+
|
|
72
|
+
/** 行の中で、その列と単位の値。無ければ null、二つ以上あるか、列に別の単位しか無いか、符号が決まらなければ undefined。 */
|
|
73
|
+
const valueIn = (entry: Entry, cell: Cell): number | null | undefined => {
|
|
74
|
+
const inColumn = entry.amounts.filter((amount) => amount.column === cell.column);
|
|
75
|
+
const matching = inColumn.filter((amount) => sameCell(amount, cell));
|
|
76
|
+
if (matching.length === 0) return inColumn.length === 0 ? null : undefined;
|
|
77
|
+
return matching.length === 1 ? matching[0]?.cents : undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const addTo = (above: RowsAbove, entry: Entry, value: number): void => {
|
|
81
|
+
if (entry.label) {
|
|
82
|
+
above.totals.push(value);
|
|
83
|
+
above.sinceLast = [];
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
above.parts.push(value);
|
|
87
|
+
above.sinceLast.push(value);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 合計の行より上の、同じ列と単位の値。項目の行と合計の行に分け、最後の合計の行より後の項目も取る。
|
|
92
|
+
* 足し方が決まらない行(valueIn が undefined)が一つでもあれば undefined。1.2万円と円のように単位が割れた列もここで止まる。
|
|
93
|
+
*/
|
|
94
|
+
const windowAbove = (entries: readonly Entry[], cell: Cell): RowsAbove | undefined => {
|
|
95
|
+
const values = entries.map((entry) => valueIn(entry, cell));
|
|
96
|
+
if (values.includes(undefined)) return undefined;
|
|
97
|
+
const above: RowsAbove = { parts: [], totals: [], sinceLast: [] };
|
|
98
|
+
entries.forEach((entry, index) => {
|
|
99
|
+
const value = values[index];
|
|
100
|
+
if (typeof value === "number") addTo(above, entry, value);
|
|
101
|
+
});
|
|
102
|
+
return above;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** 足し方の候補: 上の項目すべて(小計を並べた総計もこれ)、最後の合計より後の項目、最後の合計にその後の項目。項目が無ければ小計の和だけ。 */
|
|
106
|
+
const candidateSums = (above: RowsAbove): number[] => {
|
|
107
|
+
if (above.parts.length === 0) return [sumOf(above.totals)];
|
|
108
|
+
const last = above.totals.at(-1);
|
|
109
|
+
return [sumOf(above.parts), sumOf(above.sinceLast), ...(last === undefined ? [] : [last + sumOf(above.sinceLast)])];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** 項目が無く小計だけが並ぶ表は、小計の和。 */
|
|
113
|
+
const reportedSum = (above: RowsAbove): number => (above.parts.length === 0 ? sumOf(above.totals) : (above.totals.at(-1) ?? 0) + sumOf(above.sinceLast));
|
|
114
|
+
|
|
115
|
+
/** 和と呼べるだけの行があるか。項目が二つ以上か、項目が無く小計が二つ以上。 */
|
|
116
|
+
const enoughRows = (above: RowsAbove): boolean => above.parts.length >= MIN_PARTS || (above.parts.length === 0 && above.totals.length >= MIN_PARTS);
|
|
117
|
+
|
|
118
|
+
/** 和は、合計の行と同じ小数の桁で見せる($10,160.00 なら $10,260.00)。端数があれば 2 桁。 */
|
|
119
|
+
const formatCents = (cents: number, written: string): string => {
|
|
120
|
+
const digits = Math.max(/\.\d{2}/u.test(written) ? 2 : 0, cents % CENTS === 0 ? 0 : 2);
|
|
121
|
+
return (cents / CENTS).toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** 数の前に書いた単位($1,200、$ 1,200)の始まり。木の数量は数から始まるので、見せるときは前の単位も入れる。 */
|
|
125
|
+
const unitStartBefore = (source: string, amount: Amount): number | undefined => {
|
|
126
|
+
const gap = /[ \t]/u.test(source[amount.offset - 1] ?? "") ? 1 : 0;
|
|
127
|
+
const start = amount.offset - gap - amount.unit.length;
|
|
128
|
+
return start >= 0 && source.slice(start, amount.offset - gap) === amount.unit ? start : undefined;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** 合計の行に書いた金額と、上の和を、同じ書き方で見せる。 */
|
|
132
|
+
const shownAmounts = (source: string, total: Amount, sumCents: number): Record<string, string> => {
|
|
133
|
+
const unitStart = unitStartBefore(source, total);
|
|
134
|
+
const written = source.slice(unitStart ?? total.offset, total.end);
|
|
135
|
+
const sum = formatCents(sumCents, written);
|
|
136
|
+
if (unitStart !== undefined) return { written, sum: `${source.slice(unitStart, total.offset)}${sum}` };
|
|
137
|
+
return { written, sum: written.endsWith(total.unit) ? `${sum}${total.unit}` : sum };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const mismatchesIn = (source: string, entries: readonly Entry[]): StructureIssue[] =>
|
|
141
|
+
entries.flatMap((entry, index) => {
|
|
142
|
+
if (!entry.label) return [];
|
|
143
|
+
return entry.amounts.flatMap((total) => {
|
|
144
|
+
const writtenCents = valueIn(entry, total);
|
|
145
|
+
const above = windowAbove(entries.slice(0, index), total);
|
|
146
|
+
if (typeof writtenCents !== "number" || above === undefined || !enoughRows(above) || candidateSums(above).includes(writtenCents)) return [];
|
|
147
|
+
return [{ offset: total.offset, values: shownAmounts(source, total, reportedSum(above)) }];
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export const totalMismatches = (source: string, amounts: readonly Amount[], labels: readonly string[]): StructureIssue[] =>
|
|
152
|
+
runsOf(source).flatMap((run) =>
|
|
153
|
+
mismatchesIn(
|
|
154
|
+
source,
|
|
155
|
+
run.map((line) => entryOf(source, line, amounts, labels)),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DocumentProfile, NumberedLine } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/** 開いている単位と、その行の番号の後ろが本文だったか(見出しでなく)。見出しは前の行から付くことがあるので、行そのものを覚えておく。 */
|
|
4
|
+
export type OpenUnit = { readonly numbered: NumberedLine; readonly bodyOnLine: boolean };
|
|
5
|
+
|
|
6
|
+
type Rule = NonNullable<DocumentProfile["unnumbered"]>;
|
|
7
|
+
|
|
8
|
+
const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
9
|
+
|
|
10
|
+
const compiled = new Map<string, RegExp>();
|
|
11
|
+
|
|
12
|
+
const patternOf = (indent: string): RegExp => {
|
|
13
|
+
const found = compiled.get(indent) ?? new RegExp(`^${escape(indent)}(?<rest>\\S.*)$`, "u");
|
|
14
|
+
compiled.set(indent, found);
|
|
15
|
+
return found;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 番号を書かない単位(古い法令は第 2 項以降に番号を振らず、字下げした行を新しい項にする)。
|
|
20
|
+
* inside の種類の単位の行に本文が続くときだけ、その中で開いている depth の単位の次(無ければ 2 番目)として読む。
|
|
21
|
+
* 番号を書いた単位(「2」)がすでにあれば、字下げは番号の代わりではない。
|
|
22
|
+
*/
|
|
23
|
+
export const unnumberedUnit = (line: string, open: readonly OpenUnit[], rule: Rule | undefined): NumberedLine | undefined => {
|
|
24
|
+
if (rule === undefined) return undefined;
|
|
25
|
+
const rest = patternOf(rule.indent).exec(line)?.groups?.["rest"];
|
|
26
|
+
if (rest === undefined) return undefined;
|
|
27
|
+
const holder = [...open].reverse().find((unit) => unit.numbered.kind === rule.inside);
|
|
28
|
+
if (holder === undefined || !holder.bodyOnLine) return undefined;
|
|
29
|
+
const previous = [...open].reverse().find((unit) => unit.numbered.depth === rule.depth)?.numbered;
|
|
30
|
+
if (previous !== undefined && previous.label !== "") return undefined;
|
|
31
|
+
const number = String((previous === undefined ? 1 : Number(previous.number)) + 1);
|
|
32
|
+
return { kind: "item", depth: rule.depth, number, absolute: false, label: "", heading: "", rest: rest.trim(), ordinal: Number(number) };
|
|
33
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { StructureNode } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 番号の付いた条項が本文にあるのに、木にほとんど入っていない文書。PDF から取り出した契約書は、字下げが深かったり
|
|
5
|
+
* 行が本文につながったりして、番号の行として読めないことが多い。そのとき構造の rule は「0 件」ではなく「読めなかった」と言う。
|
|
6
|
+
*/
|
|
7
|
+
const NUMBERED = new Set(["chapter", "article", "item"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 「11.3. Liability Cap」「2.1 Term」: 二段以上の番号と、大文字で始まる語。行頭か、文の区切り(「. 12.10. Waiver」)の後ろ。
|
|
11
|
+
* 語の後ろの番号(「Version 1.2 Released」「Figure 2.1 Revenue」)は条項ではない。
|
|
12
|
+
* 「1. TERM OF CONTRACT」: 行頭の一段の番号と、大文字だけの語。本文の箇条書き(「1. First」)とは分ける。
|
|
13
|
+
*/
|
|
14
|
+
const CLAUSE_NUMBERS = [/(?:^[ \t]{0,20}|[.;:][ \t]{1,10})\d{1,3}(?:\.\d{1,3}){1,4}\.?[ \t]{1,10}\p{Lu}/gmu, /^[ \t]{0,20}\d{1,3}\.[ \t]{1,10}\p{Lu}{2}/gmu];
|
|
15
|
+
|
|
16
|
+
/** 本文の条項番号がこれ以上あれば、番号で組んだ文書。 */
|
|
17
|
+
const MIN_CLAUSES = 5;
|
|
18
|
+
/** 木に入った番号が本文の条項番号のこの割合に届かなければ、読めていない。 */
|
|
19
|
+
const READ_SHARE = 0.25;
|
|
20
|
+
|
|
21
|
+
export type Unread = { readonly clauses: number; readonly units: number };
|
|
22
|
+
|
|
23
|
+
const numberedUnits = (tree: StructureNode): number => {
|
|
24
|
+
let count = 0;
|
|
25
|
+
const pending: StructureNode[] = [tree];
|
|
26
|
+
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
|
27
|
+
if (NUMBERED.has(node.kind)) count += 1;
|
|
28
|
+
pending.push(...node.children);
|
|
29
|
+
}
|
|
30
|
+
return count;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** 読めていなければ、本文の条項番号の数と木の番号の数。読めていれば undefined。 */
|
|
34
|
+
export const unreadStructure = (source: string, tree: StructureNode): Unread | undefined => {
|
|
35
|
+
const clauses = CLAUSE_NUMBERS.reduce((total, pattern) => total + [...source.matchAll(pattern)].length, 0);
|
|
36
|
+
if (clauses < MIN_CLAUSES) return undefined;
|
|
37
|
+
const units = numberedUnits(tree);
|
|
38
|
+
return units < clauses * READ_SHARE ? { clauses, units } : undefined;
|
|
39
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { StructureNode } from "../plugin.ts";
|
|
2
|
+
import type { StructureIssue } from "./issues.ts";
|
|
3
|
+
import { inDocumentOrder } from "./issues.ts";
|
|
4
|
+
|
|
5
|
+
// 日付と、その横に書いた曜日の食い違い。暦で決まるので、両方読めれば誤りは機械で言える。
|
|
6
|
+
|
|
7
|
+
const FULL_DATE = /^(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})$/u;
|
|
8
|
+
|
|
9
|
+
/** 暦の曜日。日曜日が 0。存在しない日(2月30日)は undefined。 */
|
|
10
|
+
export const weekdayOf = (value: string): number | undefined => {
|
|
11
|
+
const groups = FULL_DATE.exec(value)?.groups;
|
|
12
|
+
if (groups === undefined) return undefined;
|
|
13
|
+
const [year, month, day] = [Number(groups["y"]), Number(groups["m"]), Number(groups["d"])];
|
|
14
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
15
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date.getUTCDay() : undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** 年まで書いた日付で、書いた曜日が暦と違うもの。年の無い日付は曜日を決められないので見ない。 */
|
|
19
|
+
export const weekdayMismatches = (tree: StructureNode): StructureIssue[] =>
|
|
20
|
+
inDocumentOrder(tree).flatMap((node) => {
|
|
21
|
+
const written = node.attrs["weekday"];
|
|
22
|
+
if (node.kind !== "date" || typeof written !== "number") return [];
|
|
23
|
+
const actual = weekdayOf(String(node.attrs["value"]));
|
|
24
|
+
return actual === undefined || actual === written ? [] : [{ offset: node.span.start, values: { date: String(node.attrs["value"]), written, actual } }];
|
|
25
|
+
});
|