@jsenv/terminal-recorder 1.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,375 @@
1
+ // inspired by https://github.com/F1LT3R/parse-ansi/blob/master/index.js
2
+
3
+ import ansiRegex from "ansi-regex";
4
+ import stripAnsi from "strip-ansi";
5
+
6
+ export const parseAnsi = (ansi) => {
7
+ ansi = ansi.replace(/\r\n/g, "\n"); // normalize windows line endings
8
+
9
+ const plainText = stripAnsi(ansi);
10
+ const lines = plainText.split("\n");
11
+ const rows = lines.length;
12
+ let columns = 0;
13
+ lines.forEach((line) => {
14
+ const len = line.length;
15
+ if (len > columns) {
16
+ columns = len;
17
+ }
18
+ });
19
+
20
+ const result = {
21
+ raw: ansi,
22
+ plainText,
23
+ rows,
24
+ columns,
25
+ chunks: [],
26
+ };
27
+
28
+ let words;
29
+ const delimiters = [];
30
+ {
31
+ const matches = ansi.match(ansiRegex()) || [];
32
+ for (const match of matches) {
33
+ if (!delimiters.includes(match)) {
34
+ delimiters.push(match);
35
+ }
36
+ }
37
+ delimiters.push("\n");
38
+
39
+ const splitString = (str, delimiter) => {
40
+ const result = [];
41
+ let index = 0;
42
+ const parts = str.split(delimiter);
43
+ for (const part of parts) {
44
+ result.push(part);
45
+ if (index < parts.length - 1) {
46
+ result.push(delimiter);
47
+ }
48
+ index++;
49
+ }
50
+ return result;
51
+ };
52
+ const splitArray = (array, delimiter) => {
53
+ let result = [];
54
+ for (const part of array) {
55
+ let subRes = splitString(part, delimiter);
56
+ subRes = subRes.filter((str) => {
57
+ return Boolean(str);
58
+ });
59
+ result = result.concat(subRes);
60
+ }
61
+ return result;
62
+ };
63
+ const superSplit = (splittable, delimiters) => {
64
+ if (delimiters.length === 0) {
65
+ return splittable;
66
+ }
67
+ if (typeof splittable === "string") {
68
+ const delimiter = delimiters[delimiters.length - 1];
69
+ const split = splitString(splittable, delimiter);
70
+ return superSplit(split, delimiters.slice(0, -1));
71
+ }
72
+ if (Array.isArray(splittable)) {
73
+ const delimiter = delimiters[delimiters.length - 1];
74
+ const split = splitArray(splittable, delimiter);
75
+ return superSplit(split, delimiters.slice(0, -1));
76
+ }
77
+ return false;
78
+ };
79
+ words = superSplit(ansi, delimiters);
80
+ }
81
+
82
+ const styleStack = {
83
+ foregroundColor: [],
84
+ backgroundColor: [],
85
+ boldDim: [],
86
+ };
87
+ const getForegroundColor = () => {
88
+ if (styleStack.foregroundColor.length > 0) {
89
+ return styleStack.foregroundColor[styleStack.foregroundColor.length - 1];
90
+ }
91
+ return false;
92
+ };
93
+ const getBackgroundColor = () => {
94
+ if (styleStack.backgroundColor.length > 0) {
95
+ return styleStack.backgroundColor[styleStack.backgroundColor.length - 1];
96
+ }
97
+ return false;
98
+ };
99
+ const getDim = () => {
100
+ return styleStack.boldDim.includes("dim");
101
+ };
102
+ const getBold = () => {
103
+ return styleStack.boldDim.includes("bold");
104
+ };
105
+ const styleState = {
106
+ italic: false,
107
+ underline: false,
108
+ inverse: false,
109
+ hidden: false,
110
+ strikethrough: false,
111
+ };
112
+ const decoratorEffects = {
113
+ foregroundColorOpen: (color) => styleStack.foregroundColor.push(color),
114
+ foregroundColorClose: () => styleStack.foregroundColor.pop(),
115
+ backgroundColorOpen: (color) => styleStack.backgroundColor.push(color),
116
+ backgroundColorClose: () => styleStack.backgroundColor.pop(),
117
+ boldOpen: () => styleStack.boldDim.push("bold"),
118
+ dimOpen: () => styleStack.boldDim.push("dim"),
119
+ boldDimClose: () => styleStack.boldDim.pop(),
120
+ italicOpen: () => {
121
+ styleState.italic = true;
122
+ },
123
+ italicClose: () => {
124
+ styleState.italic = false;
125
+ },
126
+ underlineOpen: () => {
127
+ styleState.underline = true;
128
+ },
129
+ underlineClose: () => {
130
+ styleState.underline = false;
131
+ },
132
+ inverseOpen: () => {
133
+ styleState.inverse = true;
134
+ },
135
+ inverseClose: () => {
136
+ styleState.inverse = false;
137
+ },
138
+ strikethroughOpen: () => {
139
+ styleState.strikethrough = true;
140
+ },
141
+ strikethroughClose: () => {
142
+ styleState.strikethrough = false;
143
+ },
144
+ reset: () => {
145
+ styleState.strikethrough = false;
146
+ styleState.inverse = false;
147
+ styleState.italic = false;
148
+ styleStack.boldDim = [];
149
+ styleStack.backgroundColor = [];
150
+ styleStack.foregroundColor = [];
151
+ },
152
+ };
153
+
154
+ let x = 0;
155
+ let y = 0;
156
+ let nAnsi = 0;
157
+ let nPlain = 0;
158
+
159
+ const bundle = (type, value) => {
160
+ const chunk = {
161
+ type,
162
+ value,
163
+ position: {
164
+ x,
165
+ y,
166
+ n: nPlain,
167
+ raw: nAnsi,
168
+ },
169
+ };
170
+
171
+ if (type === "text" || type === "ansi") {
172
+ const style = {};
173
+
174
+ const foregroundColor = getForegroundColor();
175
+ const backgroundColor = getBackgroundColor();
176
+ const dim = getDim();
177
+ const bold = getBold();
178
+
179
+ if (foregroundColor) {
180
+ style.foregroundColor = foregroundColor;
181
+ }
182
+
183
+ if (backgroundColor) {
184
+ style.backgroundColor = backgroundColor;
185
+ }
186
+
187
+ if (dim) {
188
+ style.dim = dim;
189
+ }
190
+
191
+ if (bold) {
192
+ style.bold = bold;
193
+ }
194
+
195
+ if (styleState.italic) {
196
+ style.italic = true;
197
+ }
198
+
199
+ if (styleState.underline) {
200
+ style.underline = true;
201
+ }
202
+
203
+ if (styleState.inverse) {
204
+ style.inverse = true;
205
+ }
206
+
207
+ if (styleState.strikethrough) {
208
+ style.strikethrough = true;
209
+ }
210
+
211
+ chunk.style = style;
212
+ }
213
+
214
+ return chunk;
215
+ };
216
+
217
+ for (const word of words) {
218
+ // Newline character
219
+ if (word === "\n") {
220
+ const chunk = bundle("newline", "\n");
221
+ result.chunks.push(chunk);
222
+
223
+ x = 0;
224
+ y += 1;
225
+ nAnsi += 1;
226
+ nPlain += 1;
227
+ continue;
228
+ }
229
+ // Text characters
230
+ if (delimiters.includes(word) === false) {
231
+ const chunk = bundle("text", word);
232
+ result.chunks.push(chunk);
233
+
234
+ x += word.length;
235
+ nAnsi += word.length;
236
+ nPlain += word.length;
237
+ continue;
238
+ }
239
+ // ANSI Escape characters
240
+ const ansiTag = ansiTags[word];
241
+ const decorator = decorators[ansiTag];
242
+ if (decorator) {
243
+ const decoratorEffect = decoratorEffects[decorator];
244
+ if (decoratorEffect) {
245
+ decoratorEffect(ansiTag);
246
+ }
247
+ }
248
+ const chunk = bundle("ansi", {
249
+ tag: ansiTag,
250
+ ansi: word,
251
+ decorator,
252
+ });
253
+ result.chunks.push(chunk);
254
+
255
+ nAnsi += word.length;
256
+ }
257
+
258
+ return result;
259
+ };
260
+
261
+ const ansiTags = {
262
+ "\u001B[30m": "black",
263
+ "\u001B[31m": "red",
264
+ "\u001B[32m": "green",
265
+ "\u001B[33m": "yellow",
266
+ "\u001B[34m": "blue",
267
+ "\u001B[35m": "magenta",
268
+ "\u001B[36m": "cyan",
269
+ "\u001B[37m": "white",
270
+
271
+ "\u001B[90m": "gray",
272
+ "\u001B[91m": "redBright",
273
+ "\u001B[92m": "greenBright",
274
+ "\u001B[93m": "yellowBright",
275
+ "\u001B[94m": "blueBright",
276
+ "\u001B[95m": "magentaBright",
277
+ "\u001B[96m": "cyanBright",
278
+ "\u001B[97m": "whiteBright",
279
+
280
+ "\u001B[39m": "foregroundColorClose",
281
+
282
+ "\u001B[40m": "bgBlack",
283
+ "\u001B[41m": "bgRed",
284
+ "\u001B[42m": "bgGreen",
285
+ "\u001B[43m": "bgYellow",
286
+ "\u001B[44m": "bgBlue",
287
+ "\u001B[45m": "bgMagenta",
288
+ "\u001B[46m": "bgCyan",
289
+ "\u001B[47m": "bgWhite",
290
+
291
+ "\u001B[100m": "bgGray",
292
+ "\u001B[101m": "bgRedBright",
293
+ "\u001B[102m": "bgGreenBright",
294
+ "\u001B[103m": "bgYellowBright",
295
+ "\u001B[104m": "bgBlueBright",
296
+ "\u001B[105m": "bgMagentaBright",
297
+ "\u001B[106m": "bgCyanBright",
298
+ "\u001B[107m": "bgWhiteBright",
299
+
300
+ "\u001B[49m": "backgroundColorClose",
301
+
302
+ "\u001B[1m": "boldOpen",
303
+ "\u001B[2m": "dimOpen",
304
+ "\u001B[3m": "italicOpen",
305
+ "\u001B[4m": "underlineOpen",
306
+ "\u001B[7m": "inverseOpen",
307
+ "\u001B[8m": "hiddenOpen",
308
+ "\u001B[9m": "strikethroughOpen",
309
+
310
+ "\u001B[22m": "boldDimClose",
311
+ "\u001B[23m": "italicClose",
312
+ "\u001B[24m": "underlineClose",
313
+ "\u001B[27m": "inverseClose",
314
+ "\u001B[28m": "hiddenClose",
315
+ "\u001B[29m": "strikethroughClose",
316
+
317
+ "\u001B[0m": "reset",
318
+ };
319
+ const decorators = {
320
+ black: "foregroundColorOpen",
321
+ red: "foregroundColorOpen",
322
+ green: "foregroundColorOpen",
323
+ yellow: "foregroundColorOpen",
324
+ blue: "foregroundColorOpen",
325
+ magenta: "foregroundColorOpen",
326
+ cyan: "foregroundColorOpen",
327
+ white: "foregroundColorOpen",
328
+
329
+ gray: "foregroundColorOpen",
330
+ redBright: "foregroundColorOpen",
331
+ greenBright: "foregroundColorOpen",
332
+ yellowBright: "foregroundColorOpen",
333
+ blueBright: "foregroundColorOpen",
334
+ magentaBright: "foregroundColorOpen",
335
+ cyanBright: "foregroundColorOpen",
336
+ whiteBright: "foregroundColorOpen",
337
+
338
+ bgBlack: "backgroundColorOpen",
339
+ bgRed: "backgroundColorOpen",
340
+ bgGreen: "backgroundColorOpen",
341
+ bgYellow: "backgroundColorOpen",
342
+ bgBlue: "backgroundColorOpen",
343
+ bgMagenta: "backgroundColorOpen",
344
+ bgCyan: "backgroundColorOpen",
345
+ bgWhite: "backgroundColorOpen",
346
+
347
+ bgGray: "backgroundColorOpen",
348
+ bgRedBright: "backgroundColorOpen",
349
+ bgGreenBright: "backgroundColorOpen",
350
+ bgYellowBright: "backgroundColorOpen",
351
+ bgBlueBright: "backgroundColorOpen",
352
+ bgMagentaBright: "backgroundColorOpen",
353
+ bgCyanBright: "backgroundColorOpen",
354
+ bgWhiteBright: "backgroundColorOpen",
355
+
356
+ foregroundColorClose: "foregroundColorClose",
357
+ backgroundColorClose: "backgroundColorClose",
358
+
359
+ boldOpen: "boldOpen",
360
+ dimOpen: "dimOpen",
361
+ italicOpen: "italicOpen",
362
+ underlineOpen: "underlineOpen",
363
+ inverseOpen: "inverseOpen",
364
+ hiddenOpen: "hiddenOpen",
365
+ strikethroughOpen: "strikethroughOpen",
366
+
367
+ boldDimClose: "boldDimClose",
368
+ italicClose: "italicClose",
369
+ underlineClose: "underlineClose",
370
+ inverseClose: "inverseClose",
371
+ hiddenClose: "hiddenClose",
372
+ strikethroughClose: "strikethroughClose",
373
+
374
+ reset: "reset",
375
+ };
@@ -0,0 +1,5 @@
1
+ export const renderTerminalHtml = () => {
2
+ // TODO
3
+ // would look like svg with small differences
4
+ // in the rendering technics
5
+ };
@@ -0,0 +1,300 @@
1
+ import prettier from "prettier";
2
+ import { parseAnsi } from "./parse_ansi.js";
3
+ import { createSvgRootNode } from "./xml_generator.js";
4
+
5
+ const colorsDefault = {
6
+ black: "#000000",
7
+ red: "#B22222",
8
+ green: "#32CD32",
9
+ yellow: "#DAA520",
10
+ blue: "#4169E1",
11
+ magenta: "#9932CC",
12
+ cyan: "#008B8B",
13
+ white: "#D3D3D3",
14
+ gray: "#A9A9A9",
15
+ redBright: "#FF4500",
16
+ greenBright: "#ADFF2F",
17
+ yellowBright: "#FFFF00",
18
+ blueBright: "#87CEEB",
19
+ magentaBright: "#FF00FF",
20
+ cyanBright: "#00FFFF",
21
+ whiteBright: "#FFFFFF",
22
+ bgBlack: "#000000",
23
+ bgRed: "#B22222",
24
+ bgGreen: "#32CD32",
25
+ bgYellow: "#DAA520",
26
+ bgBlue: "#4169E1",
27
+ bgMagenta: "#9932CC",
28
+ bgCyan: "#008B8B",
29
+ bgWhite: "#D3D3D3",
30
+ bgGray: "#A9A9A9",
31
+ bgRedBright: "#FF0000",
32
+ bgGreenBright: "#ADFF2F",
33
+ bgYellowBright: "#FFFF00",
34
+ bgBlueBright: "#87CEEB",
35
+ bgMagentaBright: "#FF00FF",
36
+ bgCyanBright: "#00FFFF",
37
+ bgWhiteBright: "#FFFFFF",
38
+ };
39
+
40
+ export const renderTerminalSvg = async (
41
+ ansi,
42
+ {
43
+ title = "ansi to terminal",
44
+ // Font: (use monospace fonts for best results)
45
+ fontFamily = "SauceCodePro Nerd Font, Source Code Pro, Courier",
46
+ fontFace,
47
+ // Pixel Values:
48
+ fontSize = 14,
49
+ lineHeight = 18,
50
+ paddingTop = 0,
51
+ paddingLeft = 10,
52
+ paddingBottom = 0,
53
+ paddingRight = 10,
54
+
55
+ backgroundColor = "#282c34",
56
+ foregroundColor = "#abb2bf",
57
+ colors = colorsDefault,
58
+
59
+ // by default: fixed width of 640 + fluid height
60
+ width = 640,
61
+ height,
62
+ maxWidth,
63
+ maxHeight,
64
+ } = {},
65
+ ) => {
66
+ const { rows, columns, chunks } = parseAnsi(ansi);
67
+ const font = {
68
+ size: fontSize,
69
+ width: 8.4013671875,
70
+ height: 14,
71
+ face: fontFace,
72
+ family: fontFamily,
73
+ lineHeight,
74
+ emHeightAscent: 10.5546875,
75
+ emHeightDescent: 3.4453125,
76
+ };
77
+
78
+ const headerHeight = 40;
79
+ const textWidth = columns * font.width;
80
+ const textHeight = rows * (font.lineHeight + 1) + font.emHeightDescent;
81
+ const bodyContentWidth = paddingLeft + textWidth + paddingRight;
82
+ const bodyContentHeight = paddingTop + textHeight + paddingBottom;
83
+ const contentWidth = bodyContentWidth;
84
+ const contentHeight = headerHeight + bodyContentHeight;
85
+
86
+ let computedWidth;
87
+ if (typeof width === "number") {
88
+ computedWidth = width;
89
+ } else {
90
+ computedWidth = contentWidth;
91
+ }
92
+ if (typeof maxWidth === "number" && computedWidth > maxWidth) {
93
+ computedWidth = maxWidth;
94
+ }
95
+ let computedHeight;
96
+ if (typeof height === "number") {
97
+ computedHeight = height;
98
+ } else {
99
+ computedHeight = contentHeight;
100
+ }
101
+ if (typeof maxHeight === "number" && computedHeight > maxHeight) {
102
+ computedHeight = maxHeight;
103
+ }
104
+
105
+ const svg = createSvgRootNode({
106
+ "xmlns": "http://www.w3.org/2000/svg",
107
+ "font-family": font.family,
108
+ "font-size": font.size,
109
+ "width": computedWidth,
110
+ "height": computedHeight,
111
+ backgroundColor,
112
+ });
113
+
114
+ background: {
115
+ const backgroundGroup = svg.createNode("g", {
116
+ id: "background",
117
+ });
118
+ const backgroundRect = svg.createNode("rect", {
119
+ "x": 1,
120
+ "y": 1,
121
+ "width": computedWidth - 2,
122
+ "height": computedHeight - 2,
123
+ "fill": backgroundColor,
124
+ "stroke": "rgba(255,255,255,0.35)",
125
+ "stroke-width": 1,
126
+ "rx": 8,
127
+ });
128
+ backgroundGroup.appendChild(backgroundRect);
129
+ svg.appendChild(backgroundGroup);
130
+ }
131
+
132
+ header: {
133
+ const headerGroup = svg.createNode("g", {
134
+ id: "header",
135
+ });
136
+ const iconsGroup = svg.createNode("g", {
137
+ transform: `translate(20,${headerHeight / 2})`,
138
+ });
139
+ const circleA = svg.createNode("circle", {
140
+ cx: 0,
141
+ cy: 0,
142
+ r: 6,
143
+ fill: "#ff5f57",
144
+ });
145
+ iconsGroup.appendChild(circleA);
146
+ const circleB = svg.createNode("circle", {
147
+ cx: 20,
148
+ cy: 0,
149
+ r: 6,
150
+ fill: "#febc2e",
151
+ });
152
+ iconsGroup.appendChild(circleB);
153
+ const circleC = svg.createNode("circle", {
154
+ cx: 40,
155
+ cy: 0,
156
+ r: 6,
157
+ fill: "#28c840",
158
+ });
159
+ iconsGroup.appendChild(circleC);
160
+ headerGroup.appendChild(iconsGroup);
161
+
162
+ const text = svg.createNode("text", {
163
+ "fill": "#abb2bf",
164
+ "text-anchor": "middle",
165
+ "x": computedWidth / 2,
166
+ "y": headerHeight / 2,
167
+ });
168
+ text.setContent(title);
169
+ headerGroup.appendChild(text);
170
+ svg.appendChild(headerGroup);
171
+ }
172
+
173
+ body: {
174
+ const bodyComputedHeight = computedHeight - headerHeight + paddingBottom;
175
+ const foreignObject = svg.createNode("foreignObject", {
176
+ id: "body",
177
+ y: headerHeight,
178
+ width: "100%",
179
+ height: bodyComputedHeight,
180
+ // we can't really know in advance the size of the scrollbar
181
+ // so putting overflow: "auto"
182
+ // is not that great as it would create a scrollbar
183
+ // in both axes when a single one is required
184
+ overflow: "hidden",
185
+ });
186
+
187
+ svg.appendChild(foreignObject);
188
+ const bodySvg = svg.createNode("svg", {
189
+ "width": bodyContentWidth,
190
+ "height": bodyContentHeight,
191
+ "font-family": "monospace",
192
+ "font-variant-east-asian": "full-width",
193
+ "fill": foregroundColor,
194
+ });
195
+ foreignObject.appendChild(bodySvg);
196
+
197
+ const offsetLeft = paddingLeft;
198
+ const offsetTop = paddingTop + font.lineHeight - font.emHeightDescent;
199
+ const textContainer = svg.createNode("g", {
200
+ // transform: `translate(${paddingLeft}, ${offsetTop})`,
201
+ });
202
+ bodySvg.appendChild(textContainer);
203
+
204
+ for (const chunk of chunks) {
205
+ const { type, value, style } = chunk;
206
+ if (type !== "text") {
207
+ continue;
208
+ }
209
+
210
+ const { position } = chunk;
211
+ const x =
212
+ offsetLeft + adjustXforWhitespace(value, position.x) * font.width;
213
+ const y = offsetTop + position.y + font.lineHeight * position.y;
214
+ const w = font.width * value.length;
215
+ const attrs = {};
216
+ if (style.bold) {
217
+ attrs["font-weight"] = "bold";
218
+ }
219
+ if (style.italic) {
220
+ attrs["font-style"] = "italic";
221
+ }
222
+
223
+ let opacity = 1;
224
+ if (style.dim) {
225
+ opacity = 0.5;
226
+ }
227
+ if (style.backgroundColor) {
228
+ const backgroundColor = colors[style.backgroundColor];
229
+ const rect = svg.createNode("rect", {
230
+ x,
231
+ y: y - font.lineHeight + font.emHeightDescent,
232
+ width: w,
233
+ height: font.lineHeight + 1,
234
+ fill: backgroundColor,
235
+ ...(opacity ? { opacity } : {}),
236
+ });
237
+ textContainer.appendChild(rect);
238
+ }
239
+
240
+ let textForegroundColor;
241
+ if (style.foregroundColor) {
242
+ textForegroundColor = colors[style.foregroundColor];
243
+ attrs["fill"] = textForegroundColor;
244
+ }
245
+
246
+ // Underline & Strikethrough:
247
+ // Some SVG implmentations do not support underline and
248
+ // strikethrough for <text> elements (see Sketch 49.2)
249
+ if (style.underline) {
250
+ const yOffset = font.height * 0.14;
251
+ const ys = y - -yOffset;
252
+ const xw = x + w;
253
+ const path = svg.createNode("path", {
254
+ d: `M${x},${ys} L${xw},${ys} Z`,
255
+ stroke: textForegroundColor || foregroundColor,
256
+ });
257
+ textContainer.appendChild(path);
258
+ }
259
+ if (style.strikethrough) {
260
+ const yOffset = font.height * 0.3;
261
+ const ys = y - yOffset;
262
+ const xw = x + w;
263
+ const path = svg.createNode("path", {
264
+ d: `M${x},${ys} L${xw},${ys} Z`,
265
+ stroke: textForegroundColor || foregroundColor,
266
+ });
267
+ textContainer.appendChild(path);
268
+ }
269
+
270
+ // Do not output elements containing whitespace with no style
271
+ if (
272
+ value.replace(/ /g, "").length === 0 &&
273
+ Object.keys(attrs).length === 0
274
+ ) {
275
+ continue;
276
+ }
277
+
278
+ const text = svg.createNode("text", {
279
+ x,
280
+ y,
281
+ ...attrs,
282
+ });
283
+ text.setContent(value);
284
+ textContainer.appendChild(text);
285
+ }
286
+ }
287
+
288
+ const svgString = svg.renderAsString();
289
+ const formatted = await prettier.format(svgString, {
290
+ parser: "html",
291
+ });
292
+ return formatted;
293
+ };
294
+
295
+ // Some SVG Implementations drop whitespaces
296
+ // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xml:space
297
+ const adjustXforWhitespace = (text, x) => {
298
+ const leadingSpace = text.match(/^\s*/g);
299
+ return x + leadingSpace[0].length;
300
+ };