@matter/general 0.13.1-alpha.0-20250508-047aa0277 → 0.13.1-alpha.0-20250511-74ef153aa

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.
@@ -4,7 +4,7 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
- const LIST_INDENT = 2;
7
+ const INDENT = " ";
8
8
 
9
9
  export { camelize, describeList, serialize } from "./String.js";
10
10
 
@@ -12,14 +12,19 @@ export { camelize, describeList, serialize } from "./String.js";
12
12
  * Performs word wrap. Input is assumed to be a series of paragraphs separated by a newline. Output is an array of
13
13
  * formatted lines.
14
14
  *
15
- * Contains specialized support for lists, ESDoc directives ans ANSI escape codes.
15
+ * Contains specialized support for lists, ESDoc directives and ANSI escape codes.
16
16
  */
17
17
  export function FormattedText(text: string, width = 120) {
18
18
  const structure = detectStructure(text);
19
- return formatStructure(structure, width);
19
+ return formatBlock(structure, width);
20
20
  }
21
21
 
22
- enum ListType {
22
+ /**
23
+ * Types of things we consider "blocks". Most blocks are lists but we also support markdown-style quotes prefixed with
24
+ * ">".
25
+ */
26
+ export enum BlockKind {
27
+ Simple = "simple",
23
28
  Bullet1 = "•",
24
29
  Bullet2 = "◦",
25
30
  Bullet3 = "▪",
@@ -28,6 +33,7 @@ enum ListType {
28
33
  Bullet6 = "‣",
29
34
  Bullet7 = "⁃",
30
35
  Bullet8 = "◘",
36
+ Quote = ">",
31
37
  Number = "number",
32
38
  LowerAlpha = "alpha",
33
39
  UpperAlpha = "ALPHA",
@@ -35,115 +41,114 @@ enum ListType {
35
41
  UpperRoman = "ROMAN",
36
42
  }
37
43
 
38
- function detectList(text: string, listState: ListType[]) {
39
- function enterList(listType: ListType) {
40
- const existing = listState.indexOf(listType);
41
- if (existing == -1) {
42
- listState.push(listType);
43
- } else {
44
- listState.length = existing + 1;
45
- }
46
- }
47
-
48
- for (const value of Object.values(ListType)) {
49
- if (text[0] === value && text[1] === " ") {
50
- enterList(text[0] as ListType);
51
- return;
52
- }
53
- }
54
-
55
- function detectEnumeration(test: RegExp, listType: ListType, first: string) {
56
- if (!text.match(test)) {
57
- return false;
58
- }
59
-
60
- if (listState.indexOf(listType) != -1 || text.startsWith(`${first}.`)) {
61
- enterList(listType);
62
- return true;
63
- }
64
-
65
- return false;
66
- }
44
+ export const Bullets = Object.entries(BlockKind)
45
+ .filter(([key]) => key.startsWith("Bullet"))
46
+ .map(([, value]) => value);
67
47
 
68
- if (detectEnumeration(/^\d+\./, ListType.Number, "1")) return;
69
- if (detectEnumeration(/^[ivx]+\./, ListType.LowerRoman, "i")) return;
70
- if (detectEnumeration(/^[IVX]+\./, ListType.UpperRoman, "I")) return;
71
- if (detectEnumeration(/^[a-z]+\./, ListType.LowerAlpha, "a")) return;
72
- if (detectEnumeration(/^[A-Z]+\./, ListType.UpperAlpha, "A")) return;
48
+ const enumTest = "(?:\\d+|[ivx]+|[a-z])\\.";
49
+ const listItemTest = new RegExp(`^(?:[${Bullets.join("")}]|${enumTest})\\s`, "i");
73
50
 
74
- listState.length = 0;
51
+ export function looksLikeListItem(text: string) {
52
+ return !!listItemTest.exec(text);
75
53
  }
76
54
 
77
- type TextStructure = {
78
- prefixWidth: number;
79
- entries: (string | TextStructure)[];
55
+ type Block = {
56
+ kind: BlockKind;
57
+ indentWidth: number;
58
+ entries: (string | Block)[];
80
59
  };
81
60
 
82
- function extractPrefix(text: string) {
83
- const match = text.match(/^(\S+)\s+($|\S.*$)/);
84
- if (match) {
85
- return { prefix: match[1], text: match[2] };
86
- }
87
- return { prefix: text, text: "" };
88
- }
61
+ const Empty: Block = {
62
+ kind: BlockKind.Simple,
63
+ indentWidth: 0,
64
+ entries: [],
65
+ };
89
66
 
90
- function detectStructure(text: string): TextStructure {
91
- if (text == "") {
92
- return { prefixWidth: 0, entries: [] };
67
+ /**
68
+ * Detect block prefixes. This is designed to handle scavenged, poorly formatted text so does not use indentation. It
69
+ * just focus on the prefix characters of the paragraph/line (which are the same thing as paragraphs do not include
70
+ * newlines).
71
+ */
72
+ function detectBlock(text: string, breadcrumb: Block[]) {
73
+ const match = text.match(/^\s*(\S+)/);
74
+ if (!match) {
75
+ return;
93
76
  }
94
- const paragraphs = text.split(/\n+/).map(paragraph => paragraph.trim().replace(/\s+/g, " "));
95
- if (!paragraphs.length) {
96
- return { prefixWidth: 0, entries: [] };
77
+
78
+ const [, marker] = match;
79
+
80
+ if (Bullets.includes(marker as BlockKind) || marker === BlockKind.Quote) {
81
+ enterBlock(marker as BlockKind);
82
+ return;
97
83
  }
98
84
 
99
- const listState = Array<ListType>();
100
- let index = 0;
85
+ if (detectEnumeration(/^\d+\.$/, "1", BlockKind.Number)) return;
86
+ if (detectEnumeration(/^[ivx]+\.$/, "i", BlockKind.LowerRoman)) return;
87
+ if (detectEnumeration(/^[IVX]+\.$/, "I", BlockKind.UpperRoman)) return;
88
+ if (detectEnumeration(/^[a-z]+\.$/, "a", BlockKind.LowerAlpha)) return;
89
+ if (detectEnumeration(/^[A-Z]+\.$/, "A", BlockKind.UpperAlpha)) return;
90
+
91
+ // Not in a block
92
+ breadcrumb.length = 1;
93
+
94
+ function enterBlock(kind: BlockKind) {
95
+ // If we are already in block of this kind, ensure it is the deepest level
96
+ const level = breadcrumb.findIndex(entry => entry.kind === kind);
97
+ if (level !== -1) {
98
+ breadcrumb.length = level + 1;
99
+ return;
100
+ }
101
101
 
102
- function processLevel() {
103
- const level = listState.length;
104
- const structure = {
105
- prefixWidth: 0,
102
+ // Need to start a new block
103
+ const block = {
104
+ kind,
105
+ indentWidth: (breadcrumb[breadcrumb.length - 1]?.indentWidth ?? 0) + kind === BlockKind.Quote ? 0 : 2,
106
106
  entries: [],
107
- } as TextStructure;
107
+ };
108
108
 
109
- while (index < paragraphs.length) {
110
- detectList(paragraphs[index], listState);
109
+ breadcrumb[breadcrumb.length - 1].entries.push(block);
110
+ breadcrumb.push(block);
111
+ }
111
112
 
112
- // If we've moved to a higher list, we're done with this level
113
- if (listState.length < level) {
114
- break;
115
- }
113
+ function detectEnumeration(test: RegExp, startsWith: string, kind: BlockKind) {
114
+ if (!marker.match(test)) {
115
+ return false;
116
+ }
116
117
 
117
- // If we've moved to a deeper list, process the new level before continuing
118
- if (listState.length > level) {
119
- structure.entries.push(processLevel());
120
- if (listState.length < level || index >= paragraphs.length) {
121
- break;
122
- }
118
+ // Only consider enumeration if a.) we are already in same type of enumeration, or b.) the marker is the first
119
+ // element of the enumeration (e.g. "1." or "i.")
120
+ if (!breadcrumb.find(block => block.kind === kind)) {
121
+ if (marker !== `${startsWith}.`) {
122
+ return false;
123
123
  }
124
+ }
124
125
 
125
- // This paragraph is in this level
126
- structure.entries.push(paragraphs[index]);
126
+ enterBlock(kind);
127
+ return true;
128
+ }
129
+ }
127
130
 
128
- // In lists, update the prefix width so we know how far out to pad when formatting
129
- if (level) {
130
- const { prefix } = extractPrefix(paragraphs[index]);
131
- if (prefix.length > structure.prefixWidth) {
132
- structure.prefixWidth = prefix.length;
133
- }
134
- }
131
+ /**
132
+ * Builds a block structure by detecting lists and/or quoted sections.
133
+ */
134
+ function detectStructure(text: string): Block {
135
+ const lines = text.split(/\n+/).map(line => line.trimEnd());
136
+ if (!lines.some(p => p)) {
137
+ return Empty;
138
+ }
135
139
 
136
- // Move to next line
137
- index++;
138
- }
140
+ const breadcrumb: Block[] = [{ ...Empty, entries: [] }];
139
141
 
140
- return structure;
142
+ for (const line of lines) {
143
+ detectBlock(line, breadcrumb);
144
+ breadcrumb[breadcrumb.length - 1].entries.push(line.trim().replace(/\s+/g, " "));
141
145
  }
142
146
 
143
- return processLevel();
147
+ return breadcrumb[0];
144
148
  }
145
149
 
146
- function wrapParagraph(input: string, into: string[], wrapWidth: number, padding: number, prefixWidth: number) {
150
+ function wrapParagraph(input: string, into: string[], wrapWidth: number, initialPrefix: string, wrapPrefix: string) {
151
+ const prefixWidth = visibleWidthOf(initialPrefix);
147
152
  const segments = input.split(/\s+/);
148
153
  if (!segments) {
149
154
  return;
@@ -163,50 +168,32 @@ function wrapParagraph(input: string, into: string[], wrapWidth: number, padding
163
168
  }
164
169
  }
165
170
 
166
- // Configure for list prefix formatting
167
- let wrapPrefix: string;
168
- if (prefixWidth) {
169
- // After wrapping this prefix will pad out subsequent entries
170
- wrapPrefix = "".padStart(prefixWidth + 1, " ");
171
- } else {
172
- // No prefix
173
- wrapPrefix = "";
174
- }
175
-
176
171
  // Wrapping setup. Track the portions of the line and current length
177
- const line = Array<string>();
178
- let length = 0;
172
+ const line = [initialPrefix];
173
+ let width = prefixWidth;
179
174
 
180
175
  // Perform actual wrapping
181
176
  let pushedOne = false;
182
- let needWrapPrefix = false;
183
177
  for (const s of segments) {
184
- const segmentLength = visibleLengthOf(s);
178
+ const segmentWidth = visibleWidthOf(s);
185
179
 
186
180
  // If we'll extend too far, start on a new line
187
- if (length && length + segmentLength > wrapWidth) {
181
+ if (width && width + segmentWidth > wrapWidth) {
188
182
  addLine();
189
- line.length = length = 0;
190
- needWrapPrefix = true;
183
+ line.length = 0;
184
+ width = prefixWidth;
191
185
  }
192
186
 
193
- // Add padding if this is a new line
194
- if (!line.length && padding) {
195
- line.push("".padStart(padding, " "));
196
- length += padding;
197
- }
198
-
199
- // Add wrap prefix if this is a new line in a list
200
- if (needWrapPrefix) {
201
- needWrapPrefix = false;
187
+ // Add wrap prefix if this is a new line
188
+ if (!line.length) {
202
189
  line.push(wrapPrefix);
203
- length += wrapPrefix.length;
190
+ width = prefixWidth;
204
191
  }
205
192
 
206
193
  // Add to the line
207
194
  line.push(s);
208
195
  line.push(" ");
209
- length += segmentLength + 1;
196
+ width += segmentWidth + 1;
210
197
  }
211
198
 
212
199
  // If there is a remaining line, add it
@@ -227,25 +214,51 @@ function wrapParagraph(input: string, into: string[], wrapWidth: number, padding
227
214
  }
228
215
  }
229
216
 
230
- function formatStructure(structure: TextStructure, width: number) {
217
+ function separatePrefixFromContent(text: string) {
218
+ const match = text.match(/^(\S+\s)\s*(\S.*$)/);
219
+ if (match) {
220
+ return { prefix: match[1], text: match[2] };
221
+ }
222
+ return { prefix: "", text };
223
+ }
224
+
225
+ function formatBlock(block: Block, width: number) {
231
226
  const lines = Array<string>();
232
227
 
233
- function formatLevel(structure: TextStructure, padding: number) {
234
- for (const entry of structure.entries) {
228
+ function formatLevel(block: Block, parentPrefix: string) {
229
+ for (const entry of block.entries) {
235
230
  if (typeof entry == "string") {
236
- wrapParagraph(entry, lines, width, padding, structure.prefixWidth);
231
+ let prefix, text;
232
+ if (block.kind === BlockKind.Simple) {
233
+ prefix = "";
234
+ text = entry;
235
+ } else {
236
+ ({ prefix, text } = separatePrefixFromContent(entry));
237
+ }
238
+
239
+ wrapParagraph(
240
+ text,
241
+ lines,
242
+ width,
243
+ parentPrefix + prefix,
244
+ parentPrefix + " ".repeat(visibleWidthOf(prefix)),
245
+ );
237
246
  } else {
238
- formatLevel(entry, padding + LIST_INDENT);
247
+ let childPrefix = parentPrefix;
248
+ if (entry.kind !== BlockKind.Quote || parentPrefix !== "") {
249
+ childPrefix += INDENT;
250
+ }
251
+ formatLevel(entry, childPrefix);
239
252
  }
240
253
  }
241
254
  }
242
255
 
243
- formatLevel(structure, 0);
256
+ formatLevel(block, "");
244
257
 
245
258
  return lines;
246
259
  }
247
260
 
248
- function visibleLengthOf(text: string) {
261
+ function visibleWidthOf(text: string) {
249
262
  let length = 0;
250
263
  for (let i = 0; i < text.length; ) {
251
264
  switch (text[i]) {