@wdprlib/ast 4.3.0 → 5.0.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/THIRD-PARTY-LICENSES.md +8 -0
- package/dist/index.cjs +602 -306
- package/dist/index.d.cts +157 -70
- package/dist/index.d.ts +157 -70
- package/dist/index.js +602 -306
- package/licenses/re2js-MIT.txt +21 -0
- package/package.json +5 -1
- package/src/build-info.generated.ts +2 -2
- package/src/element.ts +4 -4
- package/src/index.ts +12 -0
- package/src/rating.ts +44 -0
- package/src/readable-text.ts +220 -0
- package/src/text-excerpt.ts +109 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Oleksii Vasyliev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wdprlib/ast",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "AST types for Wikidot markup",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ast",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"dist",
|
|
19
19
|
"src",
|
|
20
20
|
"LICENSE",
|
|
21
|
+
"licenses",
|
|
21
22
|
"THIRD-PARTY-LICENSES.md"
|
|
22
23
|
],
|
|
23
24
|
"type": "module",
|
|
@@ -40,5 +41,8 @@
|
|
|
40
41
|
},
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"re2js": "2.8.6"
|
|
43
47
|
}
|
|
44
48
|
}
|
|
@@ -5,7 +5,7 @@ export const buildInfo: Readonly<{
|
|
|
5
5
|
sha: string | null;
|
|
6
6
|
dirty: boolean | null;
|
|
7
7
|
}> = Object.freeze({
|
|
8
|
-
version: "
|
|
9
|
-
sha: "
|
|
8
|
+
version: "5.0.0",
|
|
9
|
+
sha: "705ff97e22abd7bfb49d7f8f30087e6f09654c91",
|
|
10
10
|
dirty: false,
|
|
11
11
|
});
|
package/src/element.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { CssLengthUnit } from "./css";
|
|
20
|
+
import type { RateModuleData, CustomRateModuleData } from "./rating";
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
// Primitive types
|
|
@@ -499,10 +500,8 @@ export type Module =
|
|
|
499
500
|
/** Max depth, or null for unlimited */
|
|
500
501
|
depth: number | null;
|
|
501
502
|
}
|
|
502
|
-
|
|
|
503
|
-
|
|
504
|
-
module: "rate";
|
|
505
|
-
}
|
|
503
|
+
| RateModuleData
|
|
504
|
+
| CustomRateModuleData
|
|
506
505
|
| {
|
|
507
506
|
/** `[[module TagCloud]]` — weighted cloud of page tags */
|
|
508
507
|
module: "tag-cloud";
|
|
@@ -547,6 +546,7 @@ export type Module =
|
|
|
547
546
|
"created-at"?: string;
|
|
548
547
|
"updated-at"?: string;
|
|
549
548
|
rating?: string;
|
|
549
|
+
"rating-axis"?: string;
|
|
550
550
|
votes?: string;
|
|
551
551
|
name?: string;
|
|
552
552
|
fullname?: string;
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
export { buildInfo } from "./build-info.generated";
|
|
14
|
+
export { extractReadableText, extractFirstParagraph, countCharacters } from "./readable-text";
|
|
15
|
+
export type { ReadableTextOptions } from "./readable-text";
|
|
16
|
+
export { excerptText, compileTextExcerpt } from "./text-excerpt";
|
|
17
|
+
export type { TextExcerptOptions } from "./text-excerpt";
|
|
18
|
+
export type {
|
|
19
|
+
RatingRef,
|
|
20
|
+
RatingVote,
|
|
21
|
+
RatingAggregate,
|
|
22
|
+
RatingState,
|
|
23
|
+
RateModuleData,
|
|
24
|
+
CustomRateModuleData,
|
|
25
|
+
} from "./rating";
|
|
14
26
|
|
|
15
27
|
export type { Position, Point } from "./position";
|
|
16
28
|
export { createPoint, createPosition } from "./position";
|
package/src/rating.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** A rating always belongs to the host's displayed page. */
|
|
2
|
+
export type RatingRef = { kind: "main" } | { kind: "custom"; axisKey: string };
|
|
3
|
+
|
|
4
|
+
/** Zero is a neutral vote, not a cancellation. */
|
|
5
|
+
export type RatingVote = -1 | 0 | 1;
|
|
6
|
+
|
|
7
|
+
/** Aggregates and the treatment of neutral votes are computed by the host. */
|
|
8
|
+
export interface RatingAggregate {
|
|
9
|
+
points: number;
|
|
10
|
+
votes: number;
|
|
11
|
+
percent: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** An authorized, registered rating supplied by the host for the current viewer. */
|
|
15
|
+
export interface RatingState {
|
|
16
|
+
ref: RatingRef;
|
|
17
|
+
label: string;
|
|
18
|
+
/** Plain-text vote labels, e.g. { 1: "+", 0: "φ", [-1]: "-" }. Omitted entries use + / Ø / –. */
|
|
19
|
+
voteLabels?: Readonly<Partial<Record<RatingVote, string>>>;
|
|
20
|
+
/** Host policy: e.g. [1, -1], [1], [-1], or [1, 0, -1]. */
|
|
21
|
+
allowedVotes: readonly RatingVote[];
|
|
22
|
+
/** False keeps a visible, read-only widget. Omit the state to hide it entirely. */
|
|
23
|
+
canVote: boolean;
|
|
24
|
+
/** Independent of permission to cast a vote. */
|
|
25
|
+
canCancel: boolean;
|
|
26
|
+
/** Null means no vote; zero is a saved neutral vote. */
|
|
27
|
+
currentVote: RatingVote | null;
|
|
28
|
+
/** Null hides all aggregate values without preventing voting. */
|
|
29
|
+
aggregate: RatingAggregate | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `[[module Rate]]` accepts no attributes. A null reference marks an invalid declaration. */
|
|
33
|
+
export interface RateModuleData {
|
|
34
|
+
module: "rate";
|
|
35
|
+
ref: Extract<RatingRef, { kind: "main" }> | null;
|
|
36
|
+
state?: RatingState;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `[[module CustomRate key="theme"]]` accepts only key; it never registers or changes policy. */
|
|
40
|
+
export interface CustomRateModuleData {
|
|
41
|
+
module: "custom-rate";
|
|
42
|
+
ref: Extract<RatingRef, { kind: "custom" }> | null;
|
|
43
|
+
state?: RatingState;
|
|
44
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { Element, SyntaxTree } from "./element";
|
|
2
|
+
import { evaluateExpression, formatExprValue, isTruthy } from "./expr-eval";
|
|
3
|
+
|
|
4
|
+
export interface ReadableTextOptions {
|
|
5
|
+
/** Exclude a subtree, for example an ACS/license container identified by the host. */
|
|
6
|
+
exclude?: (element: Element) => boolean;
|
|
7
|
+
/** Match host-rendered labels or supply text for date/math leaves. Undefined uses the default. */
|
|
8
|
+
resolveText?: (
|
|
9
|
+
element: Extract<Element, { element: "link" | "user" | "date" | "math" | "math-inline" }>,
|
|
10
|
+
) => string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const segmenter = new Intl.Segmenter("und", { granularity: "grapheme" });
|
|
14
|
+
|
|
15
|
+
/** Count graphemes, including whitespace, in the supplied readable text. */
|
|
16
|
+
export function countCharacters(text: string): number {
|
|
17
|
+
let count = 0;
|
|
18
|
+
for (const _segment of segmenter.segment(text)) count++;
|
|
19
|
+
return count;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract semantic text from a resolved document, without executing HTML, CSS or JS.
|
|
24
|
+
* Paragraph boundaries become blank lines; other whitespace is normalized. Tabs and
|
|
25
|
+
* collapsibles include all panels. Footnotes are appended once, without reference UI.
|
|
26
|
+
* Math/date leaves require resolveText; source TeX and timestamps are not prose.
|
|
27
|
+
*/
|
|
28
|
+
export function extractReadableText(ast: SyntaxTree, options: ReadableTextOptions = {}): string {
|
|
29
|
+
return extractText(ast, options, false);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Extract the first nonempty paragraph, excluding headings and appended footnotes. */
|
|
33
|
+
export function extractFirstParagraph(ast: SyntaxTree, options: ReadableTextOptions = {}): string {
|
|
34
|
+
return extractText(ast, options, true);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extractText(
|
|
38
|
+
ast: SyntaxTree,
|
|
39
|
+
options: ReadableTextOptions,
|
|
40
|
+
firstParagraphOnly: boolean,
|
|
41
|
+
): string {
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
const notes = new Set<number>();
|
|
44
|
+
let footnoteIndex = 0;
|
|
45
|
+
let firstParagraph: string | undefined;
|
|
46
|
+
const emit = (value: string, omit: boolean) => {
|
|
47
|
+
if (!omit) parts.push(value);
|
|
48
|
+
};
|
|
49
|
+
const block = (elements: Element[], omit: boolean) => {
|
|
50
|
+
emit("\n\n", omit);
|
|
51
|
+
visit(elements, omit);
|
|
52
|
+
emit("\n\n", omit);
|
|
53
|
+
};
|
|
54
|
+
const visitBranch = (elements: Element[], omit: boolean) => {
|
|
55
|
+
const end =
|
|
56
|
+
elements.findLastIndex(
|
|
57
|
+
(element) => element.element !== "text" || element.data.trim() !== "",
|
|
58
|
+
) + 1;
|
|
59
|
+
visit(elements.slice(0, end), omit);
|
|
60
|
+
};
|
|
61
|
+
const visit = (elements: Element[], inheritedOmit = false): void => {
|
|
62
|
+
for (const element of elements) {
|
|
63
|
+
if (firstParagraphOnly && firstParagraph !== undefined) return;
|
|
64
|
+
const omit = inheritedOmit || options.exclude?.(element) === true;
|
|
65
|
+
switch (element.element) {
|
|
66
|
+
case "text":
|
|
67
|
+
case "raw":
|
|
68
|
+
case "email":
|
|
69
|
+
emit(element.data, omit);
|
|
70
|
+
break;
|
|
71
|
+
case "container": {
|
|
72
|
+
const start = parts.length;
|
|
73
|
+
const type = element.data.type;
|
|
74
|
+
const kind: string = typeof type === "string" ? type : "block";
|
|
75
|
+
const hidden = omit || ["ruby-text", "hidden", "invisible"].includes(kind);
|
|
76
|
+
if (
|
|
77
|
+
[
|
|
78
|
+
"paragraph",
|
|
79
|
+
"div",
|
|
80
|
+
"blockquote",
|
|
81
|
+
"note",
|
|
82
|
+
"heading",
|
|
83
|
+
"block",
|
|
84
|
+
"table-row",
|
|
85
|
+
"definition-list",
|
|
86
|
+
].includes(kind)
|
|
87
|
+
)
|
|
88
|
+
block(element.data.elements, hidden);
|
|
89
|
+
else visit(element.data.elements, hidden);
|
|
90
|
+
if (firstParagraphOnly && kind === "paragraph" && firstParagraph === undefined) {
|
|
91
|
+
const text = normalizeText(parts.slice(start).join(""));
|
|
92
|
+
if (text) firstParagraph = text;
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case "color":
|
|
97
|
+
case "anchor":
|
|
98
|
+
case "include":
|
|
99
|
+
visit(element.data.elements, omit);
|
|
100
|
+
break;
|
|
101
|
+
case "collapsible":
|
|
102
|
+
block(element.data.elements, omit);
|
|
103
|
+
break;
|
|
104
|
+
case "tab-view":
|
|
105
|
+
for (const tab of element.data) {
|
|
106
|
+
emit(`\n\n${tab.label}\n`, omit);
|
|
107
|
+
block(tab.elements, omit);
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
case "table":
|
|
111
|
+
emit("\n\n", omit);
|
|
112
|
+
for (const row of element.data.rows) {
|
|
113
|
+
for (const cell of row.cells) {
|
|
114
|
+
visit(cell.elements, omit);
|
|
115
|
+
emit(" ", omit);
|
|
116
|
+
}
|
|
117
|
+
emit("\n", omit);
|
|
118
|
+
}
|
|
119
|
+
emit("\n", omit);
|
|
120
|
+
break;
|
|
121
|
+
case "definition-list":
|
|
122
|
+
for (const entry of element.data) {
|
|
123
|
+
block(entry.key, omit);
|
|
124
|
+
visit(entry.value, omit);
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
case "list":
|
|
128
|
+
for (const item of element.data.items) {
|
|
129
|
+
emit("\n", omit);
|
|
130
|
+
if (item["item-type"] === "elements") visit(item.elements, omit);
|
|
131
|
+
else visit([{ element: "list", data: item.data }], omit);
|
|
132
|
+
}
|
|
133
|
+
emit("\n", omit);
|
|
134
|
+
break;
|
|
135
|
+
case "link": {
|
|
136
|
+
const label = element.data.label;
|
|
137
|
+
const destination =
|
|
138
|
+
typeof element.data.link === "string" ? element.data.link : element.data.link.page;
|
|
139
|
+
const fallback =
|
|
140
|
+
label === "page"
|
|
141
|
+
? destination
|
|
142
|
+
: "text" in label
|
|
143
|
+
? label.text
|
|
144
|
+
: (label.url ?? destination);
|
|
145
|
+
emit(options.resolveText?.(element) ?? fallback, omit);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case "user":
|
|
149
|
+
emit(options.resolveText?.(element) ?? element.data.name, omit);
|
|
150
|
+
break;
|
|
151
|
+
case "date":
|
|
152
|
+
case "math":
|
|
153
|
+
case "math-inline":
|
|
154
|
+
emit(options.resolveText?.(element) ?? "", omit);
|
|
155
|
+
break;
|
|
156
|
+
case "image":
|
|
157
|
+
emit(element.data.attributes.alt ?? "", omit);
|
|
158
|
+
break;
|
|
159
|
+
case "gallery":
|
|
160
|
+
if (element.data.content.type === "items")
|
|
161
|
+
for (const item of element.data.content.items) emit(`${item.alt ?? ""}\n`, omit);
|
|
162
|
+
break;
|
|
163
|
+
case "code":
|
|
164
|
+
emit(`\n\n${element.data.contents}\n\n`, omit);
|
|
165
|
+
break;
|
|
166
|
+
case "footnote":
|
|
167
|
+
case "footnote-ref": {
|
|
168
|
+
const index = element.element === "footnote" ? footnoteIndex++ : element.data - 1;
|
|
169
|
+
if (!omit) notes.add(index);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case "bibliography-block":
|
|
173
|
+
for (const entry of element.data.entries) block(entry.value, omit);
|
|
174
|
+
break;
|
|
175
|
+
case "if":
|
|
176
|
+
visitBranch(
|
|
177
|
+
isTruthy(element.data.condition) ? element.data.then : element.data.else,
|
|
178
|
+
omit,
|
|
179
|
+
);
|
|
180
|
+
break;
|
|
181
|
+
case "ifexpr": {
|
|
182
|
+
const result = evaluateExpression(element.data.expression);
|
|
183
|
+
if (result.success)
|
|
184
|
+
visitBranch(result.value !== 0 ? element.data.then : element.data.else, omit);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "expr": {
|
|
188
|
+
const result = evaluateExpression(element.data.expression);
|
|
189
|
+
if (result.success) emit(formatExprValue(result.value), omit);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case "line-break":
|
|
193
|
+
case "line-breaks":
|
|
194
|
+
emit("\n", omit);
|
|
195
|
+
break;
|
|
196
|
+
case "horizontal-rule":
|
|
197
|
+
case "content-separator":
|
|
198
|
+
emit("\n\n", omit);
|
|
199
|
+
break;
|
|
200
|
+
default:
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
visit(ast.elements);
|
|
206
|
+
if (firstParagraphOnly) return firstParagraph ?? "";
|
|
207
|
+
for (const index of notes) {
|
|
208
|
+
const note = ast.footnotes?.[index];
|
|
209
|
+
if (note) block(note, false);
|
|
210
|
+
}
|
|
211
|
+
return normalizeText(parts.join(""));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeText(text: string): string {
|
|
215
|
+
return text
|
|
216
|
+
.replace(/[^\S\n]+/g, " ")
|
|
217
|
+
.replace(/ ?\n ?/g, "\n")
|
|
218
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
219
|
+
.trim();
|
|
220
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { RE2JS } from "re2js";
|
|
2
|
+
|
|
3
|
+
export interface TextExcerptOptions {
|
|
4
|
+
/** RE2 pattern, without / delimiters. Omit to truncate the entire text. */
|
|
5
|
+
pattern?: string;
|
|
6
|
+
/** Supported flags: i (case insensitive), m (line anchors), s (dot includes newline). */
|
|
7
|
+
flags?: string;
|
|
8
|
+
/** Capture number or name. Defaults to 0, the complete match. */
|
|
9
|
+
group?: number | string;
|
|
10
|
+
/** One-based, non-overlapping match number. Defaults to 1; maximum 10,000. */
|
|
11
|
+
match?: number;
|
|
12
|
+
/** Grapheme limit, default 200. Invalid, negative, or zero lengths return empty text. */
|
|
13
|
+
maxLength?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const segmenter = new Intl.Segmenter("und", { granularity: "grapheme" });
|
|
17
|
+
const emptyExcerpt = (_text: string): string => "";
|
|
18
|
+
const MAX_PATTERN_LENGTH = 4_096;
|
|
19
|
+
const MAX_INPUT_LENGTH = 1_000_000;
|
|
20
|
+
const MAX_PROGRAM_SIZE = 4_096;
|
|
21
|
+
const MAX_MATCHES = 10_000;
|
|
22
|
+
const SEARCH_BUDGET = 16_000_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compile once and reuse across pages. Invalid options, unsupported RE2 syntax, missing
|
|
26
|
+
* matches/groups and exceeded limits return empty text. Regex inputs are limited to
|
|
27
|
+
* 4,096 pattern code units and 1,000,000 text code units. The compiled program is limited
|
|
28
|
+
* to 4,096 instructions; searches share a conservative 16,000,000-unit work budget
|
|
29
|
+
* based on program size, capture count and remaining input length. No native RegExp fallback.
|
|
30
|
+
*/
|
|
31
|
+
export function compileTextExcerpt(options: TextExcerptOptions = {}): (text: string) => string {
|
|
32
|
+
const limit = options.maxLength ?? 200;
|
|
33
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) return emptyExcerpt;
|
|
34
|
+
if (options.pattern === undefined) {
|
|
35
|
+
if (options.flags !== undefined || options.group !== undefined || options.match !== undefined) {
|
|
36
|
+
return emptyExcerpt;
|
|
37
|
+
}
|
|
38
|
+
return (text) => truncateText(text, limit);
|
|
39
|
+
}
|
|
40
|
+
const occurrence = options.match ?? 1;
|
|
41
|
+
const group = options.group ?? 0;
|
|
42
|
+
if (
|
|
43
|
+
options.pattern.length > MAX_PATTERN_LENGTH ||
|
|
44
|
+
!Number.isSafeInteger(occurrence) ||
|
|
45
|
+
occurrence < 1 ||
|
|
46
|
+
occurrence > MAX_MATCHES ||
|
|
47
|
+
(typeof group === "number" && (!Number.isSafeInteger(group) || group < 0))
|
|
48
|
+
)
|
|
49
|
+
return emptyExcerpt;
|
|
50
|
+
let flags = 0;
|
|
51
|
+
const seenFlags = new Set<string>();
|
|
52
|
+
for (const flag of options.flags ?? "") {
|
|
53
|
+
if (seenFlags.has(flag)) return emptyExcerpt;
|
|
54
|
+
seenFlags.add(flag);
|
|
55
|
+
switch (flag) {
|
|
56
|
+
case "i":
|
|
57
|
+
flags |= RE2JS.CASE_INSENSITIVE;
|
|
58
|
+
break;
|
|
59
|
+
case "m":
|
|
60
|
+
flags |= RE2JS.MULTILINE;
|
|
61
|
+
break;
|
|
62
|
+
case "s":
|
|
63
|
+
flags |= RE2JS.DOTALL;
|
|
64
|
+
break;
|
|
65
|
+
default:
|
|
66
|
+
return emptyExcerpt;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let compiled: RE2JS;
|
|
70
|
+
try {
|
|
71
|
+
compiled = RE2JS.compile(options.pattern, flags);
|
|
72
|
+
} catch {
|
|
73
|
+
return emptyExcerpt;
|
|
74
|
+
}
|
|
75
|
+
const programSize = compiled.programSize();
|
|
76
|
+
const groupCount = compiled.groupCount();
|
|
77
|
+
if (
|
|
78
|
+
programSize > MAX_PROGRAM_SIZE ||
|
|
79
|
+
(typeof group === "number" ? group > groupCount : !Object.hasOwn(compiled.namedGroups(), group))
|
|
80
|
+
)
|
|
81
|
+
return emptyExcerpt;
|
|
82
|
+
|
|
83
|
+
return (text) => {
|
|
84
|
+
if (text.length > MAX_INPUT_LENGTH) return "";
|
|
85
|
+
const matcher = compiled.matcher(text);
|
|
86
|
+
let budget = SEARCH_BUDGET;
|
|
87
|
+
let from = 0;
|
|
88
|
+
for (let index = 1; index <= occurrence; index++) {
|
|
89
|
+
// Repeated unanchored searches can revisit the remaining input. Charge every search.
|
|
90
|
+
budget -= programSize * (groupCount + 1) * (text.length - from + 1);
|
|
91
|
+
if (budget < 0 || !matcher.find()) return "";
|
|
92
|
+
from = matcher.end();
|
|
93
|
+
}
|
|
94
|
+
return truncateText(matcher.group(group) ?? "", limit);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Select a regex match/capture and truncate without splitting a grapheme cluster. */
|
|
99
|
+
export function excerptText(text: string, options: TextExcerptOptions = {}): string {
|
|
100
|
+
return compileTextExcerpt(options)(text);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function truncateText(text: string, limit: number): string {
|
|
104
|
+
let count = 0;
|
|
105
|
+
for (const segment of segmenter.segment(text)) {
|
|
106
|
+
if (count++ === limit) return text.slice(0, segment.index);
|
|
107
|
+
}
|
|
108
|
+
return text;
|
|
109
|
+
}
|