@timiagama/ecr 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +411 -7
- package/dist/bin.d.ts +20 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +26 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli/corpus-loader.d.ts +198 -0
- package/dist/cli/corpus-loader.d.ts.map +1 -0
- package/dist/cli/corpus-loader.js +351 -0
- package/dist/cli/corpus-loader.js.map +1 -0
- package/dist/cli/corpus-statistics.d.ts +113 -0
- package/dist/cli/corpus-statistics.d.ts.map +1 -0
- package/dist/cli/corpus-statistics.js +119 -0
- package/dist/cli/corpus-statistics.js.map +1 -0
- package/dist/cli/diagnostic-reporter.d.ts +103 -0
- package/dist/cli/diagnostic-reporter.d.ts.map +1 -0
- package/dist/cli/diagnostic-reporter.js +254 -0
- package/dist/cli/diagnostic-reporter.js.map +1 -0
- package/dist/cli/project-ignore.d.ts +113 -0
- package/dist/cli/project-ignore.d.ts.map +1 -0
- package/dist/cli/project-ignore.js +199 -0
- package/dist/cli/project-ignore.js.map +1 -0
- package/dist/cli/safe-text.d.ts +25 -0
- package/dist/cli/safe-text.d.ts.map +1 -0
- package/dist/cli/safe-text.js +34 -0
- package/dist/cli/safe-text.js.map +1 -0
- package/dist/cli/supervisor.d.ts +108 -0
- package/dist/cli/supervisor.d.ts.map +1 -0
- package/dist/cli/supervisor.js +139 -0
- package/dist/cli/supervisor.js.map +1 -0
- package/dist/cli.d.ts +247 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +591 -0
- package/dist/cli.js.map +1 -0
- package/dist/corpus-validator.d.ts +183 -0
- package/dist/corpus-validator.d.ts.map +1 -0
- package/dist/corpus-validator.js +479 -0
- package/dist/corpus-validator.js.map +1 -0
- package/dist/document-identity-rule.d.ts +198 -0
- package/dist/document-identity-rule.d.ts.map +1 -0
- package/dist/document-identity-rule.js +244 -0
- package/dist/document-identity-rule.js.map +1 -0
- package/dist/ecr.d.ts +103 -0
- package/dist/ecr.d.ts.map +1 -0
- package/dist/ecr.js +216 -0
- package/dist/ecr.js.map +1 -0
- package/dist/glob-pattern.d.ts +94 -0
- package/dist/glob-pattern.d.ts.map +1 -0
- package/dist/glob-pattern.js +188 -0
- package/dist/glob-pattern.js.map +1 -0
- package/dist/heading-source-form.d.ts +77 -0
- package/dist/heading-source-form.d.ts.map +1 -0
- package/dist/heading-source-form.js +129 -0
- package/dist/heading-source-form.js.map +1 -0
- package/dist/identifier-grammar.d.ts +212 -0
- package/dist/identifier-grammar.d.ts.map +1 -0
- package/dist/identifier-grammar.js +273 -0
- package/dist/identifier-grammar.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/inline-reference-rule.d.ts +557 -0
- package/dist/inline-reference-rule.d.ts.map +1 -0
- package/dist/inline-reference-rule.js +802 -0
- package/dist/inline-reference-rule.js.map +1 -0
- package/dist/meta-documents.d.ts +91 -0
- package/dist/meta-documents.d.ts.map +1 -0
- package/dist/meta-documents.js +133 -0
- package/dist/meta-documents.js.map +1 -0
- package/dist/per-document-visitor.d.ts +510 -0
- package/dist/per-document-visitor.d.ts.map +1 -0
- package/dist/per-document-visitor.js +1032 -0
- package/dist/per-document-visitor.js.map +1 -0
- package/dist/references-section-rule.d.ts +482 -0
- package/dist/references-section-rule.d.ts.map +1 -0
- package/dist/references-section-rule.js +777 -0
- package/dist/references-section-rule.js.map +1 -0
- package/dist/section-hierarchy-rule.d.ts +299 -0
- package/dist/section-hierarchy-rule.d.ts.map +1 -0
- package/dist/section-hierarchy-rule.js +482 -0
- package/dist/section-hierarchy-rule.js.map +1 -0
- package/dist/source-alignment.d.ts +41 -0
- package/dist/source-alignment.d.ts.map +1 -0
- package/dist/source-alignment.js +166 -0
- package/dist/source-alignment.js.map +1 -0
- package/dist/source-lines.d.ts +69 -0
- package/dist/source-lines.d.ts.map +1 -0
- package/dist/source-lines.js +104 -0
- package/dist/source-lines.js.map +1 -0
- package/dist/spec-version.d.ts +19 -0
- package/dist/spec-version.d.ts.map +1 -0
- package/dist/spec-version.js +19 -0
- package/dist/spec-version.js.map +1 -0
- package/dist/types.d.ts +219 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/examples/docs/0. Orientation/0.0 - System Overview.md +34 -0
- package/examples/docs/0. Orientation/0.0.1 - Glossary.md +21 -0
- package/examples/docs/0. Orientation/0.0.2 - Quality Gates.md +22 -0
- package/examples/docs/3. Ingestion/3.1 - Ingestion - Validation Rules.md +33 -0
- package/examples/docs/4. Payments/4.10 - Settlement.md +22 -0
- package/examples/docs/4. Payments/4.2 - Payment Processing Contract.md +33 -0
- package/examples/docs/8. Orchestration/8.1 - Workflow Orchestration Contract.md +38 -0
- package/examples/docs/8. Orchestration/8.1.3 - Retry Policy.md +17 -0
- package/examples/docs/8. Orchestration/8.10 - Dead Letter Queue.md +20 -0
- package/examples/docs/README.md +7 -0
- package/package.json +65 -7
- package/protocol/navigation-protocol.md +223 -0
- package/spec/v2/1 - ECR - Structural Specification.md +1467 -0
- package/spec/v2/2 - ECR - User Guide.md +845 -0
- package/spec/v2/3 - Design Rationale - The Section Separator.md +243 -0
- package/src/bin.ts +30 -0
- package/src/cli/corpus-loader.ts +482 -0
- package/src/cli/corpus-statistics.ts +196 -0
- package/src/cli/diagnostic-reporter.ts +344 -0
- package/src/cli/project-ignore.ts +238 -0
- package/src/cli/safe-text.ts +39 -0
- package/src/cli/supervisor.ts +204 -0
- package/src/cli.ts +836 -0
- package/src/corpus-validator.ts +642 -0
- package/src/document-identity-rule.ts +422 -0
- package/src/ecr.ts +298 -0
- package/src/glob-pattern.ts +242 -0
- package/src/heading-source-form.ts +173 -0
- package/src/identifier-grammar.ts +380 -0
- package/src/index.ts +78 -0
- package/src/inline-reference-rule.ts +1235 -0
- package/src/meta-documents.ts +159 -0
- package/src/per-document-visitor.ts +1528 -0
- package/src/references-section-rule.ts +1182 -0
- package/src/section-hierarchy-rule.ts +705 -0
- package/src/source-alignment.ts +209 -0
- package/src/source-lines.ts +123 -0
- package/src/spec-version.ts +19 -0
- package/src/types.ts +267 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heading Source Form
|
|
3
|
+
*
|
|
4
|
+
* Checks that a heading carrying a DocID or SectionID is written in the one
|
|
5
|
+
* source form the published recipes can find (1#9.11 rule 1): an ATX heading
|
|
6
|
+
* whose line begins with its `#` characters, then exactly one space, then the
|
|
7
|
+
* identifier in literal characters.
|
|
8
|
+
*
|
|
9
|
+
* The Markdown parser accepts far more than that. A setext heading, an
|
|
10
|
+
* indented one, one inside a blockquote, one with a tab or two spaces after
|
|
11
|
+
* the hashes, and one whose identifier is emphasised, escaped or written as a
|
|
12
|
+
* character reference all parse to the same identifier -- and none of them is
|
|
13
|
+
* found by `^# 8\.1[^0-9.#]` or `^#+ 8\.1#3([^0-9]|$)`. So the check reads the
|
|
14
|
+
* heading's own source line and asks the question the recipe asks.
|
|
15
|
+
*
|
|
16
|
+
* It also asks whether that line is a line at all to a search engine. Two
|
|
17
|
+
* things can hide a correctly written heading from `^`: a lone carriage
|
|
18
|
+
* return before it, which Markdown treats as a line ending and the engines do
|
|
19
|
+
* not, and a byte-order mark before the first line, which ripgrep skips and
|
|
20
|
+
* GNU grep does not.
|
|
21
|
+
*
|
|
22
|
+
* Shared by ECR101 (the H1) and ECR102 (every deeper heading).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { PositionRange } from './types.js';
|
|
26
|
+
import { SourceLines } from './source-lines.js';
|
|
27
|
+
|
|
28
|
+
/** The `data.cause` of a heading whose source form no recipe can find. */
|
|
29
|
+
export const HEADING_SOURCE_FORM_CAUSE: string = 'heading-source-form';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Why a heading cannot be found, carried as `data.obstruction`.
|
|
33
|
+
*
|
|
34
|
+
* - `form`: the heading line is not `#… identifier` from its first column.
|
|
35
|
+
* - `lone-carriage-return`: it is, but a lone CR precedes it, so to a search
|
|
36
|
+
* it is the middle of the line before.
|
|
37
|
+
* - `byte-order-mark`: it is, but it is the first line and a byte-order mark
|
|
38
|
+
* precedes it, which GNU grep reads as text before the `#`.
|
|
39
|
+
*/
|
|
40
|
+
export type HeadingObstruction = 'form' | 'lone-carriage-return' | 'byte-order-mark';
|
|
41
|
+
|
|
42
|
+
/** The UTF-8 byte-order mark, as it appears at the start of decoded text. */
|
|
43
|
+
const BYTE_ORDER_MARK: string = '';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What may follow a DocID on its heading line, per the document recipe
|
|
47
|
+
* `^# 8\.1[^0-9.#]`: anything but a digit, `.` or `#`, so that `8.1` is not
|
|
48
|
+
* found as the start of `8.10`, `8.1.3` or `8.1#3`.
|
|
49
|
+
*/
|
|
50
|
+
const AFTER_DOC_ID: RegExp = /^[^0-9.#]/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What may follow a SectionID on its heading line, per the section recipe
|
|
54
|
+
* `^#+ 8\.1#3([^0-9]|$)`: anything but a digit, or the end of the line.
|
|
55
|
+
*/
|
|
56
|
+
const AFTER_SECTION_ID: RegExp = /^(?:[^0-9]|$)/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Tells whether headings are written where the recipes look for them.
|
|
60
|
+
*/
|
|
61
|
+
export class HeadingSourceForm {
|
|
62
|
+
/** The document's source, by line; absent when none was supplied. */
|
|
63
|
+
private readonly lines: SourceLines | undefined;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param sourceText - The document's raw Markdown. When absent, as in unit
|
|
67
|
+
* tests that supply only parsed heading text, there is
|
|
68
|
+
* no source to consult and every heading passes.
|
|
69
|
+
*/
|
|
70
|
+
public constructor(sourceText: string | undefined) {
|
|
71
|
+
this.lines = sourceText === undefined ? undefined : new SourceLines(sourceText);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Finds what, if anything, stops the recipe for a heading from finding it.
|
|
76
|
+
*
|
|
77
|
+
* @param range - The heading node's range
|
|
78
|
+
* @param depth - The heading's depth, 1 for the H1
|
|
79
|
+
* @param identifier - The DocID or SectionID parsed from the heading
|
|
80
|
+
* @returns The obstruction, or `undefined` when the recipe would find it
|
|
81
|
+
*/
|
|
82
|
+
public findObstruction(
|
|
83
|
+
range: PositionRange | undefined,
|
|
84
|
+
depth: number,
|
|
85
|
+
identifier: string,
|
|
86
|
+
): HeadingObstruction | undefined {
|
|
87
|
+
if (this.lines === undefined || range === undefined) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lineNumber: number = range.start.line;
|
|
92
|
+
const line: string = this.lines.readLine(lineNumber) ?? '';
|
|
93
|
+
|
|
94
|
+
// A mark before the first line is reported only when it is what stands
|
|
95
|
+
// in the way: after it, the line must be a correct heading. A mark
|
|
96
|
+
// followed by a newline obstructs nothing, since the H1 then starts a
|
|
97
|
+
// later line, which both engines find.
|
|
98
|
+
if (lineNumber === 0 && line.startsWith(BYTE_ORDER_MARK)) {
|
|
99
|
+
return HeadingSourceForm.tellLineMatches(line.slice(1), depth, identifier)
|
|
100
|
+
? 'byte-order-mark'
|
|
101
|
+
: 'form';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!HeadingSourceForm.tellLineMatches(line, depth, identifier)) {
|
|
105
|
+
return 'form';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return this.lines.tellStartsSearchLine(lineNumber) ? undefined : 'lone-carriage-return';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Reports whether a line reads as the recipe for its heading expects.
|
|
113
|
+
*
|
|
114
|
+
* @param line - The heading's source line
|
|
115
|
+
* @param depth - The heading's depth
|
|
116
|
+
* @param identifier - The DocID or SectionID parsed from the heading
|
|
117
|
+
* @returns `true` when the line is `#… identifier` followed by an allowed character
|
|
118
|
+
*/
|
|
119
|
+
private static tellLineMatches(line: string, depth: number, identifier: string): boolean {
|
|
120
|
+
const prefix: string = `${'#'.repeat(depth)} ${identifier}`;
|
|
121
|
+
|
|
122
|
+
if (!line.startsWith(prefix)) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rest: string = line.slice(prefix.length);
|
|
127
|
+
|
|
128
|
+
return depth === 1 ? AFTER_DOC_ID.test(rest) : AFTER_SECTION_ID.test(rest);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The explanation a diagnostic gives for an unnavigable heading.
|
|
133
|
+
*
|
|
134
|
+
* @param obstruction - What stops the recipe from finding it
|
|
135
|
+
* @param depth - The heading's depth
|
|
136
|
+
* @param identifier - The DocID or SectionID parsed from the heading
|
|
137
|
+
* @returns A message naming the obstruction and the fix
|
|
138
|
+
*/
|
|
139
|
+
public static explain(
|
|
140
|
+
obstruction: HeadingObstruction,
|
|
141
|
+
depth: number,
|
|
142
|
+
identifier: string,
|
|
143
|
+
): string {
|
|
144
|
+
const hashes: string = '#'.repeat(depth);
|
|
145
|
+
const opening: string =
|
|
146
|
+
`The heading for "${identifier}" is not in the source form the navigation ` +
|
|
147
|
+
`recipes search for, so no search finds it.`;
|
|
148
|
+
|
|
149
|
+
switch (obstruction) {
|
|
150
|
+
case 'lone-carriage-return':
|
|
151
|
+
return (
|
|
152
|
+
`${opening} It follows a lone carriage return (CR) line ending, and search ` +
|
|
153
|
+
`tools start a new line only at a line feed, so to them it is the middle of ` +
|
|
154
|
+
`the line before. Save the file with LF or CRLF line endings.`
|
|
155
|
+
);
|
|
156
|
+
case 'byte-order-mark':
|
|
157
|
+
// Not `opening`: ripgrep skips the mark and does find this heading.
|
|
158
|
+
return (
|
|
159
|
+
`The heading for "${identifier}" is not discoverable by every published ` +
|
|
160
|
+
`recipe. The file begins with a UTF-8 byte-order mark, which sits before the ` +
|
|
161
|
+
`heading's # characters; ripgrep skips it, but GNU grep reads it as text there ` +
|
|
162
|
+
`and finds no heading. Save the file as UTF-8 without a byte-order mark.`
|
|
163
|
+
);
|
|
164
|
+
default:
|
|
165
|
+
return (
|
|
166
|
+
`${opening} Write it as an ATX heading at the start of its line -- ` +
|
|
167
|
+
`"${hashes} ${identifier} - ..." -- with one space after the # characters ` +
|
|
168
|
+
`and the identifier in literal characters, without formatting, escapes or ` +
|
|
169
|
+
`indentation.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocID and SectionID Grammar Parsing & Validation
|
|
3
|
+
*
|
|
4
|
+
* Implements the identifier grammar defined in the ECR specification (1#9.2).
|
|
5
|
+
*
|
|
6
|
+
* Grammar:
|
|
7
|
+
* DocID ::= Digit+ ("." Digit+)*
|
|
8
|
+
* SectionPath ::= Digit+ ("." Digit+)*
|
|
9
|
+
* SectionID ::= DocID "#" SectionPath
|
|
10
|
+
*
|
|
11
|
+
* All operations are deterministic and side-effect-free.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { DocID, SectionID } from './types.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Result types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Represents the outcome of parsing a string as a DocID.
|
|
22
|
+
*
|
|
23
|
+
* When parsing succeeds, `valid` is `true` and `docId` holds the parsed value.
|
|
24
|
+
* When parsing fails, `valid` is `false`.
|
|
25
|
+
*/
|
|
26
|
+
export type DocIdParseResult =
|
|
27
|
+
| { readonly valid: true; readonly docId: DocID }
|
|
28
|
+
| { readonly valid: false };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Represents the outcome of parsing a string as a SectionID.
|
|
32
|
+
*
|
|
33
|
+
* When parsing succeeds, `valid` is `true`, `sectionId` holds the parsed value,
|
|
34
|
+
* and `docId` holds the DocID prefix that the SectionID extends.
|
|
35
|
+
* When parsing fails, `valid` is `false`.
|
|
36
|
+
*/
|
|
37
|
+
export type SectionIdParseResult =
|
|
38
|
+
| {
|
|
39
|
+
readonly valid: true;
|
|
40
|
+
readonly sectionId: SectionID;
|
|
41
|
+
readonly docId: DocID;
|
|
42
|
+
/** The section path following the `#` separator, e.g. `3.1` in `8.1#3.1`. */
|
|
43
|
+
readonly sectionPath: string;
|
|
44
|
+
}
|
|
45
|
+
| { readonly valid: false };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Represents the outcome of extracting a SectionID and title from a sub-heading.
|
|
49
|
+
*
|
|
50
|
+
* When extraction succeeds, `valid` is `true` and both `sectionId` and `title`
|
|
51
|
+
* are present. When it fails, `valid` is `false`.
|
|
52
|
+
*/
|
|
53
|
+
export type SectionHeadingParseResult =
|
|
54
|
+
| { readonly valid: true; readonly sectionId: SectionID; readonly title: string }
|
|
55
|
+
| { readonly valid: false };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Represents the outcome of extracting a DocID and title from an H1 heading string.
|
|
59
|
+
*
|
|
60
|
+
* When extraction succeeds, `valid` is `true` and both `docId` and `title` are
|
|
61
|
+
* present. When extraction fails, `valid` is `false`.
|
|
62
|
+
*/
|
|
63
|
+
export type HeadingParseResult =
|
|
64
|
+
| { readonly valid: true; readonly docId: DocID; readonly title: string }
|
|
65
|
+
| { readonly valid: false };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Represents the outcome of validating a heading separator.
|
|
69
|
+
*
|
|
70
|
+
* `valid` is `true` when the heading carries a recognisable separator: a
|
|
71
|
+
* hyphen-minus, en dash or em dash after the identifier. When none is found,
|
|
72
|
+
* `valid` is `false` and `detectedSeparator` holds the canonical separator
|
|
73
|
+
* for use in the diagnostic message.
|
|
74
|
+
*/
|
|
75
|
+
export type SeparatorValidationResult =
|
|
76
|
+
| { readonly valid: true }
|
|
77
|
+
| { readonly valid: false; readonly detectedSeparator: string };
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Internal constants
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Regular expression matching the DocID grammar: one or more numeric segments
|
|
85
|
+
* separated by dots. Each segment is one or more digits.
|
|
86
|
+
*
|
|
87
|
+
* Anchored to match the entire string.
|
|
88
|
+
*/
|
|
89
|
+
const DOC_ID_PATTERN: RegExp = /^\d+(\.\d+)*$/;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Regular expression matching the SectionID grammar: a DocID, the
|
|
93
|
+
* separator `#`, and a section path of one or more dot-separated numeric
|
|
94
|
+
* segments.
|
|
95
|
+
*
|
|
96
|
+
* The separator is what makes an identifier unambiguous. Without it, `0.0.2.1`
|
|
97
|
+
* could denote section 1 of document `0.0.2` or section 2.1 of document `0.0`,
|
|
98
|
+
* and a corpus numbering its documents hierarchically will contain both.
|
|
99
|
+
*
|
|
100
|
+
* Group 1: the DocID. Group 2: the section path.
|
|
101
|
+
*/
|
|
102
|
+
const SECTION_ID_PATTERN: RegExp = /^(\d+(?:\.\d+)*)#(\d+(?:\.\d+)*)$/;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Canonical separator, used when a separator must be rendered in a message.
|
|
106
|
+
*/
|
|
107
|
+
const HYPHEN_MINUS_SEPARATOR: string = ' - ';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Regular expression matching a heading of the form `DocID <dash> Title`.
|
|
111
|
+
*
|
|
112
|
+
* The separator may be a hyphen-minus (U+002D), an en dash (U+2013) or an
|
|
113
|
+
* em dash (U+2014), with any amount of surrounding whitespace including none.
|
|
114
|
+
*
|
|
115
|
+
* Real-world corpora mix all three interchangeably, often within one document,
|
|
116
|
+
* and the separator carries no structural meaning: identity is carried entirely
|
|
117
|
+
* by the numeric identifier. Rejecting a dash variant would fail documents that
|
|
118
|
+
* are perfectly navigable, so all three are accepted.
|
|
119
|
+
*
|
|
120
|
+
* Group 1: the candidate DocID. Group 2: the title.
|
|
121
|
+
*/
|
|
122
|
+
const HEADING_PATTERN: RegExp = /^(\d+(?:\.\d+)*)\s*[-–—]\s*(.+)$/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Detects whether a heading carries any recognised separator, irrespective of
|
|
126
|
+
* which dash variant is used.
|
|
127
|
+
*/
|
|
128
|
+
const ANY_SEPARATOR_PATTERN: RegExp = /\d\s*[-–—]\s*\S/;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Regular expression matching a sub-heading of the form `SectionID <dash> Title`.
|
|
132
|
+
*
|
|
133
|
+
* Group 1: the SectionID. Group 2: the title.
|
|
134
|
+
*/
|
|
135
|
+
const SECTION_HEADING_PATTERN: RegExp =
|
|
136
|
+
/^(\d+(?:\.\d+)*#\d+(?:\.\d+)*)\s*[-\u2013\u2014]\s*(.+)$/;
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Identifier grammar parser
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Provides parsing and validation for DocID and SectionID identifiers
|
|
145
|
+
* as defined in the ECR specification (1#9.2).
|
|
146
|
+
*
|
|
147
|
+
* Grammar:
|
|
148
|
+
* DocID ::= Digit+ ("." Digit+)*
|
|
149
|
+
* SectionPath ::= Digit+ ("." Digit+)*
|
|
150
|
+
* SectionID ::= DocID "#" SectionPath
|
|
151
|
+
*
|
|
152
|
+
* This class encapsulates all identifier-level grammar operations
|
|
153
|
+
* required by the ECR structural specification.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* const grammar = new IdentifierGrammar();
|
|
158
|
+
* const result = grammar.parseDocId('3.1');
|
|
159
|
+
* // { valid: true, docId: '3.1' }
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export class IdentifierGrammar {
|
|
163
|
+
/**
|
|
164
|
+
* Parses a string and determines whether it conforms to the DocID grammar.
|
|
165
|
+
*
|
|
166
|
+
* A valid DocID consists of one or more numeric segments separated by dots,
|
|
167
|
+
* where each segment contains one or more digits.
|
|
168
|
+
*
|
|
169
|
+
* @param input - The string to parse
|
|
170
|
+
* @returns A parse result indicating validity and, on success, the parsed DocID
|
|
171
|
+
*/
|
|
172
|
+
public parseDocId(input: string): DocIdParseResult {
|
|
173
|
+
const isValid: boolean = this.matchesDocIdGrammar(input);
|
|
174
|
+
|
|
175
|
+
if (isValid) {
|
|
176
|
+
return { valid: true, docId: input };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { valid: false };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parses a string and determines whether it conforms to the SectionID grammar.
|
|
184
|
+
*
|
|
185
|
+
* A SectionID is a DocID, the separator `#`, and a section path
|
|
186
|
+
* of one or more dot-separated numeric segments. The separator is mandatory:
|
|
187
|
+
* a dotted identifier with no `#` is a DocID, never a SectionID.
|
|
188
|
+
*
|
|
189
|
+
* @param input - The string to parse
|
|
190
|
+
* @returns A parse result indicating validity and, on success, the SectionID,
|
|
191
|
+
* the DocID it belongs to, and the section path within that document
|
|
192
|
+
*/
|
|
193
|
+
public parseSectionId(input: string): SectionIdParseResult {
|
|
194
|
+
const match: RegExpExecArray | null = SECTION_ID_PATTERN.exec(input);
|
|
195
|
+
|
|
196
|
+
if (match === null) {
|
|
197
|
+
return { valid: false };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const docIdValue: string = match[1] ?? '';
|
|
201
|
+
const sectionPathValue: string = match[2] ?? '';
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
valid: true,
|
|
205
|
+
sectionId: input,
|
|
206
|
+
docId: docIdValue,
|
|
207
|
+
sectionPath: sectionPathValue,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Determines whether a SectionID belongs to a given DocID.
|
|
213
|
+
*
|
|
214
|
+
* This is an exact comparison of the SectionID's document
|
|
215
|
+
* part against the supplied DocID. No prefix matching is involved, so a
|
|
216
|
+
* SectionID of document `8.1.3` does not belong to document `8.1`, and a
|
|
217
|
+
* SectionID of `8.10` does not belong to `8.1`.
|
|
218
|
+
*
|
|
219
|
+
* @param sectionId - The SectionID to check
|
|
220
|
+
* @param docId - The DocID the SectionID should belong to
|
|
221
|
+
* @returns `true` if the SectionID names a section of that document
|
|
222
|
+
*/
|
|
223
|
+
public tellSectionIdExtendsDocId(sectionId: SectionID, docId: DocID): boolean {
|
|
224
|
+
const parseResult: SectionIdParseResult = this.parseSectionId(sectionId);
|
|
225
|
+
|
|
226
|
+
if (!parseResult.valid) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return parseResult.docId === docId;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Counts the number of numeric segments in an identifier string.
|
|
235
|
+
*
|
|
236
|
+
* Segments are the numeric components separated by `"."`.
|
|
237
|
+
* For example, `"3.1.2"` has three segments.
|
|
238
|
+
*
|
|
239
|
+
* @param identifier - A DocID or SectionID string
|
|
240
|
+
* @returns The number of numeric segments
|
|
241
|
+
*/
|
|
242
|
+
public showSegmentCount(identifier: string): number {
|
|
243
|
+
if (identifier === '') {
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const segments: readonly string[] = identifier.split('.');
|
|
248
|
+
return segments.length;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Counts the segments in a SectionID's section path.
|
|
253
|
+
*
|
|
254
|
+
* For `8.1#3.1` the section path is `3.1`, so the length is 2. The DocID's
|
|
255
|
+
* own depth does not contribute.
|
|
256
|
+
*
|
|
257
|
+
* @param sectionId - The SectionID to measure
|
|
258
|
+
* @returns The number of section-path segments, or 0 if the input is not a SectionID
|
|
259
|
+
*/
|
|
260
|
+
public showSectionPathLength(sectionId: SectionID): number {
|
|
261
|
+
const parseResult: SectionIdParseResult = this.parseSectionId(sectionId);
|
|
262
|
+
|
|
263
|
+
if (!parseResult.valid) {
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return this.showSegmentCount(parseResult.sectionPath);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Computes the section-path length required of a heading at a given depth.
|
|
272
|
+
*
|
|
273
|
+
* A heading at Markdown depth *d* carries exactly *d - 1*
|
|
274
|
+
* section-path segments, independent of how deep its document's DocID is.
|
|
275
|
+
* An H2 carries one segment, an H3 two, and so on.
|
|
276
|
+
*
|
|
277
|
+
* @param headingDepth - The Markdown heading depth (2 for H2, 3 for H3, etc.)
|
|
278
|
+
* @returns The required number of section-path segments
|
|
279
|
+
*/
|
|
280
|
+
public showExpectedSectionPathLength(headingDepth: number): number {
|
|
281
|
+
return headingDepth - 1;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Extracts the DocID and title from an H1 heading string.
|
|
286
|
+
*
|
|
287
|
+
* The heading must match the pattern: DocID <dash> Title, where the dash
|
|
288
|
+
* is a hyphen-minus, en dash or em dash with any surrounding whitespace.
|
|
289
|
+
* The title must be non-empty after trimming leading and trailing
|
|
290
|
+
* whitespace.
|
|
291
|
+
*
|
|
292
|
+
* @param headingText - The plain text content of the H1 heading node
|
|
293
|
+
* @returns A parse result indicating validity and, on success, the extracted DocID and title
|
|
294
|
+
*/
|
|
295
|
+
public parseHeading(headingText: string): HeadingParseResult {
|
|
296
|
+
const match: RegExpExecArray | null = HEADING_PATTERN.exec(headingText.trim());
|
|
297
|
+
|
|
298
|
+
if (match === null) {
|
|
299
|
+
return { valid: false };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const candidateDocId: string = match[1] ?? '';
|
|
303
|
+
const title: string = match[2] ?? '';
|
|
304
|
+
|
|
305
|
+
const docIdResult: DocIdParseResult = this.parseDocId(candidateDocId);
|
|
306
|
+
|
|
307
|
+
if (!docIdResult.valid) {
|
|
308
|
+
return { valid: false };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const trimmedTitle: string = title.trim();
|
|
312
|
+
|
|
313
|
+
if (trimmedTitle.length === 0) {
|
|
314
|
+
return { valid: false };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { valid: true, docId: docIdResult.docId, title };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Extracts the SectionID and title from a sub-heading string.
|
|
322
|
+
*
|
|
323
|
+
* A sub-heading carries a SectionID, so it must contain the `#` separator.
|
|
324
|
+
* An H1 carries a DocID and is parsed by {@link IdentifierGrammar.parseHeading}
|
|
325
|
+
* instead.
|
|
326
|
+
*
|
|
327
|
+
* @param headingText - The plain text content of a heading node at depth \>= 2
|
|
328
|
+
* @returns A parse result indicating validity and, on success, the SectionID and title
|
|
329
|
+
*/
|
|
330
|
+
public parseSectionHeading(headingText: string): SectionHeadingParseResult {
|
|
331
|
+
const match: RegExpExecArray | null = SECTION_HEADING_PATTERN.exec(headingText.trim());
|
|
332
|
+
|
|
333
|
+
if (match === null) {
|
|
334
|
+
return { valid: false };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const sectionId: string = match[1] ?? '';
|
|
338
|
+
const title: string = match[2] ?? '';
|
|
339
|
+
|
|
340
|
+
if (title.trim().length === 0) {
|
|
341
|
+
return { valid: false };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { valid: true, sectionId, title };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Validates that a heading string carries a separator between its
|
|
349
|
+
* identifier and its title.
|
|
350
|
+
*
|
|
351
|
+
* Per the ECR specification (1#9.3, 1#9.4), the separator may be a
|
|
352
|
+
* hyphen-minus (U+002D), an en dash (U+2013) or an em dash (U+2014), with
|
|
353
|
+
* any surrounding whitespace. Only a heading with no separator at all is
|
|
354
|
+
* invalid.
|
|
355
|
+
*
|
|
356
|
+
* @param headingText - The plain text content of a heading node
|
|
357
|
+
* @returns A validation result indicating whether the separator is valid
|
|
358
|
+
*/
|
|
359
|
+
public validateSeparator(headingText: string): SeparatorValidationResult {
|
|
360
|
+
if (ANY_SEPARATOR_PATTERN.test(headingText)) {
|
|
361
|
+
return { valid: true };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { valid: false, detectedSeparator: HYPHEN_MINUS_SEPARATOR };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// -------------------------------------------------------------------------
|
|
368
|
+
// Private helpers
|
|
369
|
+
// -------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Tests whether a string matches the DocID grammar: `Digit+ ("." Digit+)*`.
|
|
373
|
+
*
|
|
374
|
+
* @param input - The string to test
|
|
375
|
+
* @returns `true` if the input matches the DocID grammar, `false` otherwise
|
|
376
|
+
*/
|
|
377
|
+
private matchesDocIdGrammar(input: string): boolean {
|
|
378
|
+
return DOC_ID_PATTERN.test(input);
|
|
379
|
+
}
|
|
380
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
DocID,
|
|
3
|
+
SectionID,
|
|
4
|
+
DiagnosticSeverity,
|
|
5
|
+
Position,
|
|
6
|
+
PositionRange,
|
|
7
|
+
Diagnostic,
|
|
8
|
+
ReferenceDirection,
|
|
9
|
+
InlineReferenceKind,
|
|
10
|
+
SectionNode,
|
|
11
|
+
ReferenceEdge,
|
|
12
|
+
InlineReferenceEdge,
|
|
13
|
+
ExtractedDocument,
|
|
14
|
+
LintInput,
|
|
15
|
+
LintResult,
|
|
16
|
+
CorpusDocumentEntry,
|
|
17
|
+
CorpusIndex,
|
|
18
|
+
CorpusResult,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
|
|
21
|
+
export { IdentifierGrammar } from "./identifier-grammar.js";
|
|
22
|
+
export type {
|
|
23
|
+
DocIdParseResult,
|
|
24
|
+
SectionIdParseResult,
|
|
25
|
+
HeadingParseResult,
|
|
26
|
+
SectionHeadingParseResult,
|
|
27
|
+
SeparatorValidationResult,
|
|
28
|
+
} from "./identifier-grammar.js";
|
|
29
|
+
|
|
30
|
+
export { DocumentIdentityRule, DOCUMENT_IDENTITY_RULE_ID } from "./document-identity-rule.js";
|
|
31
|
+
export type {
|
|
32
|
+
HeadingNodeData,
|
|
33
|
+
DocumentIdentity,
|
|
34
|
+
DocumentIdentityRuleResult,
|
|
35
|
+
DocumentIdentityRuleOptions,
|
|
36
|
+
} from "./document-identity-rule.js";
|
|
37
|
+
|
|
38
|
+
export { SectionHierarchyRule, SECTION_HIERARCHY_RULE_ID } from "./section-hierarchy-rule.js";
|
|
39
|
+
export type {
|
|
40
|
+
SectionHierarchyRuleOptions,
|
|
41
|
+
SectionHierarchyRuleResult,
|
|
42
|
+
} from "./section-hierarchy-rule.js";
|
|
43
|
+
|
|
44
|
+
export { ReferencesSectionRule, REFERENCES_SECTION_RULE_ID } from "./references-section-rule.js";
|
|
45
|
+
export type {
|
|
46
|
+
ListItemNodeData,
|
|
47
|
+
ListItemSegment,
|
|
48
|
+
ParsedReferenceEntry,
|
|
49
|
+
ReferencesSectionRuleResult,
|
|
50
|
+
ReferencesSectionRuleOptions,
|
|
51
|
+
} from "./references-section-rule.js";
|
|
52
|
+
|
|
53
|
+
export { InlineReferenceRule, INLINE_REFERENCE_RULE_ID } from "./inline-reference-rule.js";
|
|
54
|
+
export type {
|
|
55
|
+
TextNodeData,
|
|
56
|
+
InlineSegment,
|
|
57
|
+
InlineSegmentKind,
|
|
58
|
+
DetectedInlineReference,
|
|
59
|
+
InlineReferenceRuleResult,
|
|
60
|
+
InlineReferenceRuleOptions,
|
|
61
|
+
} from "./inline-reference-rule.js";
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
PerDocumentVisitor,
|
|
65
|
+
UNPARSABLE_DOCUMENT_RULE_ID,
|
|
66
|
+
UNPARSABLE_DOCUMENT_CAUSE,
|
|
67
|
+
} from "./per-document-visitor.js";
|
|
68
|
+
export type {
|
|
69
|
+
PerDocumentVisitorOptions,
|
|
70
|
+
} from "./per-document-visitor.js";
|
|
71
|
+
|
|
72
|
+
export { CorpusValidator } from "./corpus-validator.js";
|
|
73
|
+
|
|
74
|
+
export { Ecr } from "./ecr.js";
|
|
75
|
+
export type { CorpusDocumentInput } from "./ecr.js";
|
|
76
|
+
|
|
77
|
+
export { MetaDocumentFilter, DEFAULT_META_DOCUMENT_NAMES } from "./meta-documents.js";
|
|
78
|
+
export { ECR_SPEC_VERSION } from "./spec-version.js";
|