@elicdavis/node-flow 0.1.15 → 0.1.17

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.
Files changed (43) hide show
  1. package/.mocharc.json +5 -0
  2. package/dist/index.d.mts +139 -4
  3. package/dist/index.d.ts +139 -4
  4. package/dist/index.js +3754 -3352
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +3741 -3352
  7. package/dist/index.mjs.map +1 -1
  8. package/index.html +0 -30
  9. package/package.json +4 -2
  10. package/src/graph.ts +234 -41
  11. package/src/index.ts +82 -40
  12. package/src/input.ts +22 -14
  13. package/src/markdown/entry.ts +26 -3
  14. package/src/markdown/lexicalParser.ts +22 -32
  15. package/src/markdown/syntaxParser.ts +93 -68
  16. package/src/minimap.ts +178 -0
  17. package/src/node.ts +114 -0
  18. package/src/nodes/factory.ts +9 -5
  19. package/src/nodes/publisher.ts +39 -4
  20. package/src/nodes/subsystem.ts +218 -8
  21. package/src/notes/subsystem.ts +3 -0
  22. package/src/organize.ts +276 -181
  23. package/src/port.ts +143 -7
  24. package/src/render_scheduler.ts +49 -0
  25. package/src/styles/text.ts +8 -0
  26. package/src/theme.ts +8 -0
  27. package/src/types/text.ts +8 -4
  28. package/src/utils/string.ts +7 -1
  29. package/src/widgets/color.ts +5 -6
  30. package/src/widgets/factory.ts +1 -1
  31. package/src/widgets/image.ts +2 -0
  32. package/src/widgets/number.ts +2 -2
  33. package/src/widgets/slider.ts +2 -2
  34. package/src/widgets/string.ts +2 -2
  35. package/src/widgets/toggle.ts +2 -2
  36. package/src/widgets/widget.ts +22 -0
  37. package/test/markdown.test.ts +165 -0
  38. package/test/minimap.test.ts +103 -0
  39. package/test/node_menu_filter.test.ts +119 -0
  40. package/test/organize.test.ts +156 -0
  41. package/test/port.test.ts +125 -0
  42. package/test/render_scheduler.test.ts +119 -0
  43. package/tsconfig.json +6 -2
package/src/port.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Camera } from "./camera";
2
2
  import { Connection } from './connection';
3
+ import { requestRender } from "./render_scheduler";
3
4
  import { RenderElementBase } from "./elements/base";
4
5
  import { ContainerRenderElement, AlignItems } from './elements/container';
5
6
  import { TextAlign, TextElement } from "./elements/text";
@@ -31,12 +32,62 @@ export interface PortConfig {
31
32
  type?: string;
32
33
  description?: string;
33
34
  array?: boolean;
35
+
36
+ /**
37
+ * Lets this port connect to a port of any type, instead of only to one
38
+ * declaring the same `type` string. For a port whose type is decided by
39
+ * whatever gets wired into it, where `type` is a placeholder shown to
40
+ * the user rather than a promise about the values passing through.
41
+ */
42
+ anyType?: boolean;
43
+
44
+ /**
45
+ * Every type this port takes, when it takes more than one. For a port
46
+ * that works on a single value or an array of them, where `type` is
47
+ * only the one it happens to be showing.
48
+ *
49
+ * An empty or absent list means the port takes exactly its `type`,
50
+ * unless `anyType` says otherwise.
51
+ */
52
+ acceptedTypes?: Array<string>;
53
+
34
54
  emptyStyle?: PortStyle;
35
55
  filledStyle?: PortStyle;
36
56
  onConnectionAdded?: ConnectionChangeCallback;
37
57
  onConnectionRemoved?: ConnectionChangeCallback;
38
58
  };
39
59
 
60
+ /**
61
+ * Whether two ports may be connected, going by their types alone. Each port
62
+ * offers the set of types it takes - one entry for an ordinary port, several
63
+ * for a port that takes more than one, and none at all for a port that takes
64
+ * anything. They connect when those sets overlap.
65
+ */
66
+ export function portsCompatible(a: Port | undefined | null, b: Port | undefined | null): boolean {
67
+ if (!a || !b) {
68
+ return false;
69
+ }
70
+
71
+ return typeSetsCompatible(a.acceptedTypes(), b.acceptedTypes());
72
+ }
73
+
74
+ export function typeSetsCompatible(a: Array<string>, b: Array<string>): boolean {
75
+ if (a.length === 0 || b.length === 0) {
76
+ return true;
77
+ }
78
+ return a.some((type) => b.includes(type));
79
+ }
80
+
81
+ export function portConfigTypes(config: PortConfig): Array<string> {
82
+ if (config.anyType === true) {
83
+ return [];
84
+ }
85
+ if (config.acceptedTypes !== undefined && config.acceptedTypes.length > 0) {
86
+ return config.acceptedTypes;
87
+ }
88
+ return [config.type === undefined ? "" : config.type];
89
+ }
90
+
40
91
  // Calculate a color hash for an arbirary type
41
92
  function fallbackColor(type: string, s: number): string {
42
93
  let value = 0;
@@ -69,6 +120,18 @@ export class Port {
69
120
 
70
121
  #dataType: string;
71
122
 
123
+ #anyType: boolean;
124
+
125
+ #acceptedTypes: Array<string>;
126
+
127
+ #description: string;
128
+
129
+ #array: boolean;
130
+
131
+ #configuredEmptyFill: string | undefined;
132
+
133
+ #configuredFilledFill: string | undefined;
134
+
72
135
  #dataTypePopupElement: RenderElementBase;
73
136
 
74
137
  #onConnectionAdded: Array<ConnectionChangeCallback>;
@@ -81,17 +144,23 @@ export class Port {
81
144
  this.#portType = portType;
82
145
  this.#displayName = config?.name === undefined ? "Port" : config?.name;
83
146
  this.#dataType = config?.type === undefined ? "" : config?.type;
147
+ this.#anyType = config?.anyType === true;
148
+ this.#acceptedTypes = config?.acceptedTypes === undefined ? [] : config.acceptedTypes;
149
+ this.#description = config?.description === undefined ? "" : config.description;
150
+ this.#array = config?.array === true;
151
+ this.#configuredEmptyFill = config?.emptyStyle?.fillColor;
152
+ this.#configuredFilledFill = config?.filledStyle?.fillColor;
84
153
 
85
154
  this.#emptyStyle = {
86
155
  borderColor: config?.emptyStyle?.borderColor === undefined ? "#1c1c1c" : config.emptyStyle?.borderColor,
87
- fillColor: config?.emptyStyle?.fillColor === undefined ? fallbackColor(this.#dataType, 0.3) : config.emptyStyle?.fillColor,
156
+ fillColor: this.#configuredEmptyFill === undefined ? fallbackColor(this.#dataType, 0.3) : this.#configuredEmptyFill,
88
157
  borderSize: config?.emptyStyle?.borderSize === undefined ? 1 : config.emptyStyle?.borderSize,
89
158
  size: config?.emptyStyle?.size === undefined ? 4 : config.emptyStyle?.size
90
159
  }
91
160
 
92
161
  this.#filledStyle = {
93
162
  borderColor: config?.filledStyle?.borderColor === undefined ? "#1c1c1c" : config.filledStyle?.borderColor,
94
- fillColor: config?.filledStyle?.fillColor === undefined ? fallbackColor(this.#dataType, 1) : config.filledStyle?.fillColor,
163
+ fillColor: this.#configuredFilledFill === undefined ? fallbackColor(this.#dataType, 1) : this.#configuredFilledFill,
95
164
  borderSize: config?.filledStyle?.borderSize === undefined ? 1 : config.filledStyle?.borderSize,
96
165
  size: config?.filledStyle?.size === undefined ? 5 : config.filledStyle?.size
97
166
  }
@@ -106,9 +175,20 @@ export class Port {
106
175
  this.#onConnectionRemoved.push(config?.onConnectionRemoved);
107
176
  }
108
177
 
178
+ this.#dataTypePopupElement = this.#buildTooltip();
179
+ }
180
+
181
+ #buildTooltip(): RenderElementBase {
109
182
  const containerElements = new Array<RenderElementBase>();
183
+
110
184
  let dataTypeDisplay = this.#dataType;
111
- if (config?.array === true) {
185
+ if (this.#acceptedTypes.length > 1) {
186
+ dataTypeDisplay = this.#acceptedTypes.join(" or ");
187
+ }
188
+ if (dataTypeDisplay === "" && this.#anyType) {
189
+ dataTypeDisplay = "any";
190
+ }
191
+ if (this.#array) {
112
192
  dataTypeDisplay = "Array<" + dataTypeDisplay + ">"
113
193
  }
114
194
  containerElements.push(new TextElement(
@@ -120,10 +200,9 @@ export class Port {
120
200
  }
121
201
  ));
122
202
 
123
- const description = config?.description;
124
- if (description && description !== "") {
203
+ if (this.#description !== "") {
125
204
  containerElements.push(new TextElement(
126
- new Text(description, { color: "white", style: FontStyle.Italic }),
205
+ new Text(this.#description, { color: "white", style: FontStyle.Italic }),
127
206
  {
128
207
  Align: TextAlign.Center,
129
208
  Padding: { Top: 16 },
@@ -133,7 +212,7 @@ export class Port {
133
212
  ));
134
213
  }
135
214
 
136
- this.#dataTypePopupElement = new ContainerRenderElement(
215
+ return new ContainerRenderElement(
137
216
  containerElements,
138
217
  {
139
218
  BackgroundColor: "rgba(0, 0, 0, 0.85)",
@@ -145,6 +224,42 @@ export class Port {
145
224
  );
146
225
  }
147
226
 
227
+ #refreshDataType(): void {
228
+ if (this.#configuredEmptyFill === undefined) {
229
+ this.#emptyStyle.fillColor = fallbackColor(this.#dataType, 0.3);
230
+ }
231
+ if (this.#configuredFilledFill === undefined) {
232
+ this.#filledStyle.fillColor = fallbackColor(this.#dataType, 1);
233
+ }
234
+ this.#dataTypePopupElement = this.#buildTooltip();
235
+ requestRender();
236
+ }
237
+
238
+ setDataType(type: string): void {
239
+ if (this.#dataType === type) {
240
+ return;
241
+ }
242
+ this.#dataType = type;
243
+ this.#refreshDataType();
244
+ }
245
+
246
+ setAnyType(anyType: boolean): void {
247
+ if (this.#anyType === anyType) {
248
+ return;
249
+ }
250
+ this.#anyType = anyType;
251
+ this.#refreshDataType();
252
+ }
253
+
254
+ /**
255
+ * Scope accepted types to singular type
256
+ */
257
+ settleOn(type: string): void {
258
+ this.#acceptedTypes = [type];
259
+ this.setDataType(type);
260
+ this.#refreshDataType();
261
+ }
262
+
148
263
  addConnection(connection: Connection): void {
149
264
  const c = this.#connections.length;
150
265
  this.#connections.push(connection);
@@ -195,6 +310,27 @@ export class Port {
195
310
  return this.#dataType;
196
311
  }
197
312
 
313
+ acceptsAnyType(): boolean {
314
+ return this.#anyType;
315
+ }
316
+
317
+ /**
318
+ * Every type this port takes. Empty means it takes anything.
319
+ */
320
+ acceptedTypes(): Array<string> {
321
+ if (this.#anyType) {
322
+ return [];
323
+ }
324
+ if (this.#acceptedTypes.length > 0) {
325
+ return this.#acceptedTypes;
326
+ }
327
+ return [this.#dataType];
328
+ }
329
+
330
+ setAcceptedTypes(types: Array<string>): void {
331
+ this.#acceptedTypes = types;
332
+ }
333
+
198
334
  getPortType(): PortType {
199
335
  return this.#portType;
200
336
  }
@@ -0,0 +1,49 @@
1
+ type Redraw = () => void;
2
+
3
+ const subscribers = new Set<Redraw>();
4
+ let frameQueued = false;
5
+ let continuous = 0;
6
+
7
+ const canSchedule = (): boolean =>
8
+ typeof requestAnimationFrame === "function";
9
+
10
+ export function onRenderRequested(redraw: Redraw): () => void {
11
+ subscribers.add(redraw);
12
+ return () => {
13
+ subscribers.delete(redraw);
14
+ };
15
+ }
16
+
17
+
18
+ export function requestRender(): void {
19
+ if (frameQueued || !canSchedule()) {
20
+ return;
21
+ }
22
+
23
+ frameQueued = true;
24
+ requestAnimationFrame(() => {
25
+ frameQueued = false;
26
+ subscribers.forEach((redraw) => redraw());
27
+
28
+ // Something asked to be redrawn every frame, so keep the loop
29
+ // alive by asking again on its behalf.
30
+ if (continuous > 0) {
31
+ requestRender();
32
+ }
33
+ });
34
+ }
35
+
36
+
37
+ export function renderContinuously(): () => void {
38
+ continuous++;
39
+ requestRender();
40
+
41
+ let released = false;
42
+ return () => {
43
+ if (released) {
44
+ return;
45
+ }
46
+ released = true;
47
+ continuous--;
48
+ };
49
+ }
@@ -65,6 +65,14 @@ export class TextStyle {
65
65
  return this.#font;
66
66
  }
67
67
 
68
+ getWeight(): FontWeight {
69
+ return this.#weight;
70
+ }
71
+
72
+ getStyle(): FontStyle {
73
+ return this.#fontStyle;
74
+ }
75
+
68
76
  measure(ctx: CanvasRenderingContext2D, scale: number, text: string, out: Vector2): void {
69
77
  this.setupStyle(ctx, scale);
70
78
  const measurements = ctx.measureText(text)
package/src/theme.ts CHANGED
@@ -59,6 +59,14 @@ export const Theme = {
59
59
  FontColor: "#afb9bb",
60
60
  DisabledFontColor: "#4a585c",
61
61
  },
62
+ Minimap: {
63
+ BackgroundColor: "rgba(7, 33, 42, 0.85)",
64
+ BorderColor: "#1c1c1c",
65
+ BorderRadius: 4,
66
+ Padding: 6,
67
+ NodeColor: "#6b7f85",
68
+ ViewportColor: "rgba(175, 185, 187, 0.25)",
69
+ },
62
70
  Note: {
63
71
  FontColor: "#afb9bb",
64
72
  FontSize: 16,
package/src/types/text.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { requestRender } from "../render_scheduler";
1
2
  import { FontWeight, TextStyle, TextStyleConfig } from "../styles/text";
2
3
  import { splitString, splitStringIntoLines } from "../utils/string";
3
4
  import { CopyVector2, ScaleVector, Vector2, Zero } from "./vector2";
@@ -32,10 +33,13 @@ export class Text {
32
33
  this.#lineSpacing = config?.LineSpacing ? config?.LineSpacing : 5;
33
34
  this.#textToRender = [value];
34
35
 
35
- if (!document.fonts.check(`16px "${this.#style.getFont()}"`)) {
36
- document.fonts.addEventListener("loadingdone", (event) => {
37
- this.#measured = false;
38
- });
36
+ if (typeof document !== "undefined" && document.fonts !== undefined) {
37
+ if (!document.fonts.check(`16px "${this.#style.getFont()}"`)) {
38
+ document.fonts.addEventListener("loadingdone", () => {
39
+ this.#measured = false;
40
+ requestRender();
41
+ });
42
+ }
39
43
  }
40
44
  }
41
45
 
@@ -1,4 +1,10 @@
1
- const binarySearch = ({ max, getValue, match }) => {
1
+ interface BinarySearch {
2
+ max: number;
3
+ getValue: (guess: number) => number;
4
+ match: number;
5
+ }
6
+
7
+ const binarySearch = ({ max, getValue, match }: BinarySearch): number => {
2
8
  let min = 0;
3
9
 
4
10
  while (min <= max) {
@@ -2,7 +2,7 @@ import { HexToColor } from "../utils/color";
2
2
  import { TextStyleConfig } from "../styles/text";
3
3
  import { Box } from "../types/box";
4
4
  import { CopyVector2, Vector2 } from "../types/vector2";
5
- import { height, width } from "./widget";
5
+ import { height, startingWidgetValue, width } from "./widget";
6
6
  import { Popup } from "../popup";
7
7
  import { TextBoxStyle } from '../styles/textBox';
8
8
  import { Theme } from "../theme";
@@ -46,12 +46,12 @@ export class ColorWidget {
46
46
  #callback?: (newColor: string) => void;
47
47
 
48
48
  constructor(node: FlowNode, config?: ColorWidgetConfig) {
49
- this.#value = "#000000"
50
- this.#contrast = contrastColor(this.#value);
51
- this.Set(config?.value === undefined ? "#000000" : config?.value);
52
-
53
49
  this.#node = node;
54
50
  this.#nodeProperty = config?.property;
51
+ this.#callback = config?.callback;
52
+
53
+ this.#value = startingWidgetValue(node, this.#nodeProperty, config?.value, "#000000");
54
+ this.#contrast = contrastColor(this.#value);
55
55
  this.#textBoxStyle = new TextBoxStyle({
56
56
  box: {
57
57
  color: this.#value,
@@ -62,7 +62,6 @@ export class ColorWidget {
62
62
  },
63
63
  text: config?.textStyle
64
64
  });
65
- this.#callback = config?.callback;
66
65
 
67
66
  if (this.#nodeProperty !== undefined) {
68
67
  this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
@@ -11,7 +11,7 @@ import { TextWidget, TextWidgetConfig } from "./text";
11
11
 
12
12
  export type WidgetBuilder = (node: FlowNode, confg?: any) => Widget;
13
13
 
14
- class WidgetFactory {
14
+ export class WidgetFactory {
15
15
 
16
16
  #registeredWidgets: Map<string, WidgetBuilder>
17
17
 
@@ -1,3 +1,4 @@
1
+ import { requestRender } from "../render_scheduler";
1
2
  import { Box } from "../types/box";
2
3
  import { CopyVector2, Vector2 } from "../types/vector2";
3
4
 
@@ -101,6 +102,7 @@ export class ImageWidget {
101
102
  img.src = url;
102
103
  img.onload = () => {
103
104
  this.#image = img;
105
+ requestRender();
104
106
  };
105
107
  img.onerror = (event) => {
106
108
  console.log("error loading image:", url, event);
@@ -3,7 +3,7 @@ import { Popup } from "../popup";
3
3
  import { TextBoxStyle, TextBoxStyleConfig, TextBoxStyleWithFallback } from "../styles/textBox";
4
4
  import { Box, InBox } from '../types/box';
5
5
  import { CopyVector2, Vector2 } from "../types/vector2";
6
- import { height, width } from "./widget";
6
+ import { height, startingWidgetValue, width } from "./widget";
7
7
  import { FlowNode } from "../node";
8
8
 
9
9
  export interface NumberWidgetConfig {
@@ -62,8 +62,8 @@ export class NumberWidget {
62
62
  },
63
63
  text: { color: Theme.Widget.FontColor },
64
64
  }));
65
- this.Set(config?.value === undefined ? 0 : config?.value);
66
65
  this.#callback = config?.callback;
66
+ this.Set(startingWidgetValue(node, this.#nodeProperty, config?.value, 0));
67
67
 
68
68
  if (this.#nodeProperty !== undefined) {
69
69
  this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
@@ -4,7 +4,7 @@ import { TextStyle, TextStyleConfig, TextStyleFallback } from "../styles/text";
4
4
  import { Box } from '../types/box';
5
5
  import { CopyVector2, Vector2, Zero } from "../types/vector2";
6
6
  import { Clamp, Clamp01 } from "../utils/math";
7
- import { height, width } from "./widget";
7
+ import { height, startingWidgetValue, width } from "./widget";
8
8
  import { TextBaseline } from "../styles/canvasTextBaseline";
9
9
  import { FlowNode } from "../node";
10
10
 
@@ -104,7 +104,7 @@ export class SliderWidget {
104
104
  });
105
105
  }
106
106
 
107
- this.SetValue(config?.value === undefined ? 0 : config?.value);
107
+ this.SetValue(startingWidgetValue(node, this.#nodeProperty, config?.value, 0));
108
108
 
109
109
  // Setup change callback after we set the initial value to prevent the callback from being
110
110
  this.#change = config?.change;
@@ -3,7 +3,7 @@ import { TextBoxStyle, TextBoxStyleConfig, TextBoxStyleWithFallback } from "../s
3
3
  import { Box, InBox } from "../types/box";
4
4
  import { CopyVector2, Vector2 } from "../types/vector2";
5
5
  import { fitString } from "../utils/string";
6
- import { height, width } from "./widget";
6
+ import { height, startingWidgetValue, width } from "./widget";
7
7
  import { FlowNode } from '../node';
8
8
  import { SetStringPopup } from "../popups/string";
9
9
 
@@ -60,8 +60,8 @@ export class StringWidget {
60
60
  text: { color: Theme.Widget.FontColor },
61
61
  }));
62
62
 
63
- this.Set(config?.value === undefined ? "" : config?.value);
64
63
  this.#callback = config?.callback;
64
+ this.Set(startingWidgetValue(node, this.#nodeProperty, config?.value, ""));
65
65
  if (this.#nodeProperty !== undefined) {
66
66
  this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
67
67
  this.Set(newVal);
@@ -1,7 +1,7 @@
1
1
  import { Theme } from "../theme";
2
2
  import { Box, InBox } from "../types/box";
3
3
  import { CopyVector2, Vector2 } from "../types/vector2";
4
- import { height, width } from "./widget";
4
+ import { height, startingWidgetValue, width } from "./widget";
5
5
  import { TextBoxStyle, TextBoxStyleConfig, TextBoxStyleWithFallback } from "../styles/textBox";
6
6
  import { FlowNode } from "../node";
7
7
 
@@ -159,8 +159,8 @@ export class ToggleWidget {
159
159
  lightColor: config?.disabledStyle?.lightColor === undefined ? "#004400" : config?.enabledStyle?.lightColor,
160
160
  });
161
161
 
162
- this.Set(config?.value === undefined ? false : config?.value);
163
162
  this.#callback = config?.callback;
163
+ this.Set(startingWidgetValue(node, this.#nodeProperty, config?.value, false));
164
164
  if (this.#nodeProperty !== undefined) {
165
165
  this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
166
166
  this.Set(newVal);
@@ -9,4 +9,26 @@ export interface Widget {
9
9
  Draw(ctx: CanvasRenderingContext2D, position: Vector2, scale: number, mousePosition: Vector2 | undefined): Box
10
10
  ClickStart(): void
11
11
  ClickEnd(): void
12
+ }
13
+
14
+ interface PropertyReader {
15
+ getProperty(name: string): any;
16
+ }
17
+
18
+ export function startingWidgetValue<T>(
19
+ node: PropertyReader,
20
+ property: string | undefined,
21
+ configured: T | undefined,
22
+ fallback: T,
23
+ ): T {
24
+ if (configured !== undefined) {
25
+ return configured;
26
+ }
27
+ if (property !== undefined && property !== null) {
28
+ const existing = node.getProperty(property);
29
+ if (existing !== undefined && existing !== null) {
30
+ return existing as T;
31
+ }
32
+ }
33
+ return fallback;
12
34
  }
@@ -0,0 +1,165 @@
1
+ import * as assert from 'assert';
2
+ import { BuildMarkdown } from '../src/markdown/markdown';
3
+ import { BasicMarkdownEntry, CodeBlockEntry, UnorderedListMarkdownEntry } from '../src/markdown/entry';
4
+ import { FontStyle, FontWeight } from '../src/styles/text';
5
+ import { Text } from '../src/types/text';
6
+
7
+ /** The runs of text in an entry, as [content, weight, style] triples. */
8
+ function runs(entry: any): Array<[string, FontWeight, FontStyle]> {
9
+ let lines: ReadonlyArray<Text>;
10
+ if (entry.entries) {
11
+ lines = entry.entries().flatMap((item: any) => item.lines());
12
+ } else if (entry.lines) {
13
+ lines = entry.lines();
14
+ } else {
15
+ lines = [entry.text()];
16
+ }
17
+ return lines.map(line => [
18
+ line.get(),
19
+ line.style().getWeight(),
20
+ line.style().getStyle(),
21
+ ] as [string, FontWeight, FontStyle]);
22
+ }
23
+
24
+ function contentOf(entry: any): string {
25
+ return runs(entry).map(r => r[0]).join('');
26
+ }
27
+
28
+ describe('markdown: paragraphs and headers', () => {
29
+ it('reads a plain line', () => {
30
+ const entries = BuildMarkdown('hello world');
31
+ assert.strictEqual(entries.length, 1);
32
+ assert.strictEqual(contentOf(entries[0]), 'hello world');
33
+ });
34
+
35
+ it('reads each header level', () => {
36
+ const entries = BuildMarkdown('# one\n## two\n### three');
37
+ assert.strictEqual(entries.length, 3);
38
+ assert.strictEqual(contentOf(entries[0]), 'one');
39
+ assert.strictEqual(contentOf(entries[1]), 'two');
40
+ assert.strictEqual(contentOf(entries[2]), 'three');
41
+ });
42
+
43
+ it('keeps a # that is not at the start of a line as text', () => {
44
+ const entries = BuildMarkdown('issue #42 filed');
45
+ assert.strictEqual(contentOf(entries[0]), 'issue #42 filed');
46
+ });
47
+ });
48
+
49
+ describe('markdown: emphasis', () => {
50
+ it('italicises a single-starred run', () => {
51
+ const entries = BuildMarkdown('*hello*');
52
+ assert.deepStrictEqual(runs(entries[0]), [['hello', FontWeight.Normal, FontStyle.Italic]]);
53
+ });
54
+
55
+ it('bolds a double-starred run', () => {
56
+ const entries = BuildMarkdown('**hello**');
57
+ assert.deepStrictEqual(runs(entries[0]), [['hello', FontWeight.Bold, FontStyle.Normal]]);
58
+ });
59
+
60
+ it('emphasises a run in the middle of a line', () => {
61
+ const entries = BuildMarkdown('a *b* c');
62
+ assert.deepStrictEqual(runs(entries[0]), [
63
+ ['a ', FontWeight.Normal, FontStyle.Normal],
64
+ ['b', FontWeight.Normal, FontStyle.Italic],
65
+ [' c', FontWeight.Normal, FontStyle.Normal],
66
+ ]);
67
+ });
68
+
69
+ it('leaves a star surrounded by spaces alone', () => {
70
+ const entries = BuildMarkdown('2 * 3 * 4');
71
+ assert.strictEqual(contentOf(entries[0]), '2 * 3 * 4');
72
+ });
73
+
74
+ it('keeps stars that never matched as text', () => {
75
+ assert.strictEqual(contentOf(BuildMarkdown('*unclosed')[0]), '*unclosed');
76
+ assert.strictEqual(contentOf(BuildMarkdown('a * b')[0]), 'a * b');
77
+ });
78
+
79
+ it('keeps the stars left over when the runs are uneven', () => {
80
+ // Two opened, one closed: the extra opener is just a character.
81
+ assert.deepStrictEqual(runs(BuildMarkdown('**a*')[0]), [
82
+ ['*', FontWeight.Normal, FontStyle.Normal],
83
+ ['a', FontWeight.Normal, FontStyle.Italic],
84
+ ]);
85
+ assert.deepStrictEqual(runs(BuildMarkdown('*a**')[0]), [
86
+ ['a', FontWeight.Normal, FontStyle.Italic],
87
+ ['*', FontWeight.Normal, FontStyle.Normal],
88
+ ]);
89
+ });
90
+
91
+ it('does not emphasise across a space held against the stars', () => {
92
+ assert.strictEqual(contentOf(BuildMarkdown('** a **')[0]), '** a **');
93
+ assert.strictEqual(contentOf(BuildMarkdown('**a **')[0]), '**a **');
94
+ });
95
+
96
+ it('emphasises a run holding a space in the middle', () => {
97
+ assert.deepStrictEqual(runs(BuildMarkdown('**a f**')[0]), [
98
+ ['a f', FontWeight.Bold, FontStyle.Normal],
99
+ ]);
100
+ });
101
+
102
+ it('never produces an empty run', () => {
103
+ for (const source of ['*a*', '**a**', '*a**', '**a*', '* a *', 'a `b` c']) {
104
+ for (const [content] of runs(BuildMarkdown(source)[0])) {
105
+ assert.notStrictEqual(content, '', `${source} produced an empty run`);
106
+ }
107
+ }
108
+ });
109
+
110
+ it('emphasises more than one run on a line', () => {
111
+ const entries = BuildMarkdown('*a* and *b*');
112
+ assert.deepStrictEqual(runs(entries[0]), [
113
+ ['a', FontWeight.Normal, FontStyle.Italic],
114
+ [' and ', FontWeight.Normal, FontStyle.Normal],
115
+ ['b', FontWeight.Normal, FontStyle.Italic],
116
+ ]);
117
+ });
118
+ });
119
+
120
+ describe('markdown: lists', () => {
121
+ it('collects consecutive star lines into one list', () => {
122
+ const entries = BuildMarkdown('* one\n* two\n* three');
123
+ assert.strictEqual(entries.length, 1);
124
+ assert.ok(entries[0] instanceof UnorderedListMarkdownEntry);
125
+ assert.strictEqual((entries[0] as any).entries().length, 3);
126
+ });
127
+
128
+ it('ends the list at the first line that is not one', () => {
129
+ const entries = BuildMarkdown('* one\n* two\nafter');
130
+ assert.strictEqual(entries.length, 2);
131
+ assert.ok(entries[0] instanceof UnorderedListMarkdownEntry);
132
+ assert.strictEqual(contentOf(entries[1]), 'after');
133
+ });
134
+ });
135
+
136
+ describe('markdown: code', () => {
137
+ it('sets inline code apart without keeping the backticks', () => {
138
+ const entry = BuildMarkdown('a `b` c')[0] as any;
139
+ assert.deepStrictEqual(runs(entry).map(r => r[0]), ['a ', 'b', ' c']);
140
+ assert.strictEqual(entry.lines()[1].style().getFont(), 'monospace');
141
+ assert.notStrictEqual(entry.lines()[0].style().getFont(), 'monospace');
142
+ });
143
+
144
+ it('keeps a lone backtick as text', () => {
145
+ assert.strictEqual(contentOf(BuildMarkdown('a ` b')[0]), 'a ` b');
146
+ });
147
+
148
+ it('keeps a fenced block verbatim', () => {
149
+ const entries = BuildMarkdown('```\nlet a = *b*;\n```');
150
+ assert.strictEqual(entries.length, 1);
151
+ assert.ok(entries[0] instanceof CodeBlockEntry, `got ${entries[0].constructor.name}`);
152
+ assert.strictEqual(contentOf(entries[0]), 'let a = *b*;\n');
153
+ });
154
+
155
+ it('keeps more than one line of a fenced block', () => {
156
+ const entries = BuildMarkdown('```\none\ntwo\n```');
157
+ assert.strictEqual(contentOf(entries[0]), 'one\ntwo\n');
158
+ });
159
+
160
+ it('carries on after a fenced block', () => {
161
+ const entries = BuildMarkdown('```\ncode\n```\nafter');
162
+ assert.strictEqual(entries.length, 2);
163
+ assert.strictEqual(contentOf(entries[1]), 'after');
164
+ });
165
+ });