@wdprlib/ast 4.2.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 +576 -251
- package/dist/index.d.cts +183 -72
- package/dist/index.d.ts +183 -72
- package/dist/index.js +576 -251
- package/licenses/re2js-MIT.txt +21 -0
- package/package.json +5 -1
- package/src/build-info.generated.ts +2 -2
- package/src/element.ts +78 -5
- package/src/index.ts +16 -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
|
|
@@ -122,6 +123,7 @@ export type StringContainerType =
|
|
|
122
123
|
| "monospace"
|
|
123
124
|
| "span"
|
|
124
125
|
| "div"
|
|
126
|
+
| "note"
|
|
125
127
|
| "blockquote"
|
|
126
128
|
| "size"
|
|
127
129
|
| "paragraph"
|
|
@@ -498,10 +500,8 @@ export type Module =
|
|
|
498
500
|
/** Max depth, or null for unlimited */
|
|
499
501
|
depth: number | null;
|
|
500
502
|
}
|
|
501
|
-
|
|
|
502
|
-
|
|
503
|
-
module: "rate";
|
|
504
|
-
}
|
|
503
|
+
| RateModuleData
|
|
504
|
+
| CustomRateModuleData
|
|
505
505
|
| {
|
|
506
506
|
/** `[[module TagCloud]]` — weighted cloud of page tags */
|
|
507
507
|
module: "tag-cloud";
|
|
@@ -546,6 +546,7 @@ export type Module =
|
|
|
546
546
|
"created-at"?: string;
|
|
547
547
|
"updated-at"?: string;
|
|
548
548
|
rating?: string;
|
|
549
|
+
"rating-axis"?: string;
|
|
549
550
|
votes?: string;
|
|
550
551
|
name?: string;
|
|
551
552
|
fullname?: string;
|
|
@@ -814,10 +815,50 @@ export interface DateData {
|
|
|
814
815
|
value: DateItem;
|
|
815
816
|
/** strftime-style format string, or null for default */
|
|
816
817
|
format: string | null;
|
|
817
|
-
/** Whether to show
|
|
818
|
+
/** Whether to show elapsed time in a tooltip on hover */
|
|
818
819
|
hover: boolean;
|
|
819
820
|
}
|
|
820
821
|
|
|
822
|
+
/** Standalone page-option action, used by [[button action]].
|
|
823
|
+
* @group Element Data
|
|
824
|
+
*/
|
|
825
|
+
export type PageButtonAction =
|
|
826
|
+
| "edit"
|
|
827
|
+
| "edit-append"
|
|
828
|
+
| "edit-sections"
|
|
829
|
+
| "history"
|
|
830
|
+
| "print"
|
|
831
|
+
| "files"
|
|
832
|
+
| "tags"
|
|
833
|
+
| "source"
|
|
834
|
+
| "backlinks"
|
|
835
|
+
| "talk"
|
|
836
|
+
| "delete"
|
|
837
|
+
| "rename"
|
|
838
|
+
| "site-tools"
|
|
839
|
+
| "edit-meta"
|
|
840
|
+
| "watchers"
|
|
841
|
+
| "parent"
|
|
842
|
+
| "lock-page";
|
|
843
|
+
|
|
844
|
+
/** Data for a standalone [[button]] element.
|
|
845
|
+
* @group Element Data
|
|
846
|
+
*/
|
|
847
|
+
export interface PageButtonData {
|
|
848
|
+
/** Unknown actions remain representable so the renderer can display the error. */
|
|
849
|
+
action: string;
|
|
850
|
+
text: string | null;
|
|
851
|
+
attributes: AttributeMap;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Data for [[social]], with null selecting the default services.
|
|
855
|
+
* @group Element Data
|
|
856
|
+
*/
|
|
857
|
+
export interface SocialData {
|
|
858
|
+
/** Unsupported names remain representable, but produce no share link. */
|
|
859
|
+
sites: string[] | null;
|
|
860
|
+
}
|
|
861
|
+
|
|
821
862
|
/**
|
|
822
863
|
* Data for `##color|text##` inline color syntax.
|
|
823
864
|
*
|
|
@@ -989,6 +1030,8 @@ export type ElementDataMap = {
|
|
|
989
1030
|
"bibliography-block": BibliographyBlockData;
|
|
990
1031
|
user: UserData;
|
|
991
1032
|
date: DateData;
|
|
1033
|
+
button: PageButtonData;
|
|
1034
|
+
social: SocialData;
|
|
992
1035
|
color: ColorData;
|
|
993
1036
|
code: CodeBlockData;
|
|
994
1037
|
math: MathData;
|
|
@@ -1282,6 +1325,7 @@ export function isContainerTypeParagraphSafe(type: ContainerType): boolean {
|
|
|
1282
1325
|
case "size":
|
|
1283
1326
|
return true;
|
|
1284
1327
|
case "div":
|
|
1328
|
+
case "note":
|
|
1285
1329
|
case "blockquote":
|
|
1286
1330
|
case "paragraph":
|
|
1287
1331
|
case "heading":
|
|
@@ -1354,7 +1398,10 @@ export function isParagraphSafe(element: Element): boolean {
|
|
|
1354
1398
|
case "user":
|
|
1355
1399
|
return true;
|
|
1356
1400
|
case "date":
|
|
1401
|
+
case "social":
|
|
1357
1402
|
return true;
|
|
1403
|
+
case "button":
|
|
1404
|
+
return isPageButtonAction(element.data.action);
|
|
1358
1405
|
case "color":
|
|
1359
1406
|
return true;
|
|
1360
1407
|
case "code":
|
|
@@ -1395,3 +1442,29 @@ export function isParagraphSafe(element: Element): boolean {
|
|
|
1395
1442
|
return false;
|
|
1396
1443
|
}
|
|
1397
1444
|
}
|
|
1445
|
+
|
|
1446
|
+
/** Whether a standalone button names a supported page action. */
|
|
1447
|
+
export function isPageButtonAction(action: string): action is PageButtonAction {
|
|
1448
|
+
switch (action) {
|
|
1449
|
+
case "edit":
|
|
1450
|
+
case "edit-append":
|
|
1451
|
+
case "edit-sections":
|
|
1452
|
+
case "history":
|
|
1453
|
+
case "print":
|
|
1454
|
+
case "files":
|
|
1455
|
+
case "tags":
|
|
1456
|
+
case "source":
|
|
1457
|
+
case "backlinks":
|
|
1458
|
+
case "talk":
|
|
1459
|
+
case "delete":
|
|
1460
|
+
case "rename":
|
|
1461
|
+
case "site-tools":
|
|
1462
|
+
case "edit-meta":
|
|
1463
|
+
case "watchers":
|
|
1464
|
+
case "parent":
|
|
1465
|
+
case "lock-page":
|
|
1466
|
+
return true;
|
|
1467
|
+
default:
|
|
1468
|
+
return false;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
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";
|
|
@@ -81,6 +93,9 @@ export type {
|
|
|
81
93
|
BibliographyBlockData,
|
|
82
94
|
UserData,
|
|
83
95
|
DateData,
|
|
96
|
+
PageButtonAction,
|
|
97
|
+
PageButtonData,
|
|
98
|
+
SocialData,
|
|
84
99
|
ColorData,
|
|
85
100
|
MathData,
|
|
86
101
|
MathInlineData,
|
|
@@ -110,6 +125,7 @@ export {
|
|
|
110
125
|
isAlignType,
|
|
111
126
|
isContainerTypeParagraphSafe,
|
|
112
127
|
isParagraphSafe,
|
|
128
|
+
isPageButtonAction,
|
|
113
129
|
} from "./element";
|
|
114
130
|
|
|
115
131
|
// Diagnostics
|
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
|
+
}
|