@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.
@@ -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.2.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.2.0",
9
- sha: "b71b84c705e05717f5ea12fd89684a91b669dbff",
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
- /** `[[module Rate]]` — page rating widget */
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 a tooltip with the full date on hover */
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
+ }