@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.
@@ -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": "4.3.0",
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: "4.3.0",
9
- sha: "5e221ddeccc585e64dd1a2eb49b14817c3c1be5d",
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
- /** `[[module Rate]]` — page rating widget */
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
+ }