extwee 2.2.5 → 2.3.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.
Files changed (63) hide show
  1. package/.github/workflows/dependabot-automerge.yml +23 -0
  2. package/.github/workflows/nodejs.yml +4 -1
  3. package/README.md +29 -14
  4. package/SECURITY.md +1 -1
  5. package/build/extwee.web.min.js +2 -0
  6. package/build/extwee.web.min.js.LICENSE.txt +1 -0
  7. package/extwee.config.json +6 -0
  8. package/extwee.config.md +67 -0
  9. package/index.js +2 -0
  10. package/package.json +24 -23
  11. package/src/CLI/CommandLineProcessing.js +196 -0
  12. package/src/CLI/ProcessConfig/loadStoryFormat.js +102 -0
  13. package/src/CLI/ProcessConfig/readDirectories.js +46 -0
  14. package/src/CLI/ProcessConfig.js +175 -0
  15. package/src/CLI/isDirectory.js +27 -0
  16. package/src/CLI/isFile.js +28 -0
  17. package/src/Config/parser.js +30 -8
  18. package/src/Passage.js +17 -2
  19. package/src/Story.js +101 -1
  20. package/src/StoryFormat/compile.js +19 -0
  21. package/src/StoryFormat.js +51 -0
  22. package/src/extwee.js +20 -195
  23. package/test/Config/Config.test.js +40 -10
  24. package/test/Config/files/full.json +8 -0
  25. package/test/Config/files/valid.json +4 -3
  26. package/test/Config/isDirectory.test.js +44 -0
  27. package/test/Config/isFile.test.js +50 -0
  28. package/test/Config/loadStoryFormat.test.js +101 -0
  29. package/test/Config/readDirectories.test.js +68 -0
  30. package/test/Objects/Passage.test.js +5 -0
  31. package/test/Objects/Story.test.js +174 -0
  32. package/test/Objects/StoryFormat.test.js +60 -0
  33. package/test/TWS/Parse.test.js +0 -22
  34. package/test/Web/window.Extwee.test.js +85 -0
  35. package/types/Story.d.ts +26 -1
  36. package/types/StoryFormat/compile.d.ts +8 -0
  37. package/types/StoryFormat.d.ts +7 -0
  38. package/types/index.d.ts +4 -2
  39. package/types/src/CLI/CommandLineProcessing.d.ts +8 -0
  40. package/types/src/CLI/ProcessConfig/loadStoryFormat.d.ts +20 -0
  41. package/types/src/CLI/ProcessConfig/readDirectories.d.ts +9 -0
  42. package/types/src/CLI/ProcessConfig.d.ts +12 -0
  43. package/types/src/CLI/isDirectory.d.ts +1 -0
  44. package/types/src/CLI/isFile.d.ts +1 -0
  45. package/types/src/Config/parser.d.ts +6 -0
  46. package/types/src/Config/reader.d.ts +11 -0
  47. package/types/src/IFID/generate.d.ts +14 -0
  48. package/types/src/JSON/parse.d.ts +44 -1
  49. package/types/src/Passage.d.ts +49 -4
  50. package/types/src/Story.d.ts +110 -16
  51. package/types/src/StoryFormat/compile.d.ts +8 -0
  52. package/types/src/StoryFormat/parse.d.ts +46 -3
  53. package/types/src/StoryFormat.d.ts +69 -38
  54. package/types/src/TWS/parse.d.ts +3 -3
  55. package/types/src/Twee/parse.d.ts +3 -4
  56. package/types/src/Twine1HTML/compile.d.ts +3 -1
  57. package/types/src/Twine1HTML/parse.d.ts +3 -4
  58. package/types/src/Twine2ArchiveHTML/compile.d.ts +8 -0
  59. package/types/src/Twine2ArchiveHTML/parse.d.ts +31 -1
  60. package/types/src/Twine2HTML/compile.d.ts +7 -2
  61. package/types/src/Twine2HTML/parse.d.ts +12 -9
  62. package/index.html +0 -22
  63. package/test/TWS/TWSParser/Example1.tws +0 -150
package/src/extwee.js CHANGED
@@ -1,206 +1,31 @@
1
- #!/usr/bin/env node
1
+ #! /usr/bin/env node
2
2
 
3
3
  /**
4
4
  * @file CLI for Extwee
5
5
  * @author Dan Cox
6
6
  */
7
7
 
8
- // Import functions we need.
9
- import {
10
- parseTwine2HTML,
11
- parseTwee,
12
- parseStoryFormat,
13
- parseTwine1HTML,
14
- compileTwine2HTML,
15
- compileTwine1HTML
16
- } from '../index.js';
8
+ import { CommandLineProcessing } from "./CLI/CommandLineProcessing.js";
9
+ import { ConfigFilePresent, ConfigFileProcessing } from "./CLI/ProcessConfig.js";
17
10
 
18
- // Import fs.
19
- import { readFileSync, writeFileSync, statSync } from 'node:fs';
20
-
21
- // Import Commander.
22
- import { Command, InvalidArgumentError } from 'commander';
23
-
24
- // Create a new Command.
25
- const program = new Command();
26
-
27
- /*
28
- * Check if a passed option is a valid file.
29
- * @function isFile
30
- * @description Check if a file exists.
31
- * @param {string} path - Path to file.
32
- * @returns {boolean} True if file exists, false if not.
33
- */
34
- const isFile = (path) => {
35
- // set default.
36
- let result = false;
37
-
38
- try {
39
- // Attempt tp get stats.
40
- const stats = statSync(path);
41
-
42
- // Return if path is a file.
43
- result = stats.isFile();
44
- } catch (e) {
45
- // There was an error, so return false.
46
- result = false;
47
- }
48
-
49
- // Return either the default (false) or the result (true).
50
- return result;
51
- };
52
-
53
- program
54
- .name('extwee')
55
- .description('CLI for Extwee')
56
- .option('-v, --version', '2.2.4')
57
- .option('-c, --compile', 'Compile input into output')
58
- .option('-d, --decompile', 'De-compile input into output')
59
- .option('--twine1', 'Enable Twine 1 processing')
60
- .option('--name <storyFormatName>', 'Name of the Twine 1 story format (needed for `code.js` inclusion)')
61
- .option('--codejs <codeJSFile>', 'Twine 1 code.js file for use with Twine 1 HTML', (value) => {
62
- // Does the input file exist?
63
- if (isFile(value) === false) {
64
- // We cannot do anything without valid input.
65
- throw new InvalidArgumentError(`Twine 1 code.js ${value} does not exist.`);
66
- }
67
-
68
- return value;
69
- })
70
- .option('--engine <engineFile>', 'Twine 1 engine.js file for use with Twine 1 HTML', (value) => {
71
- // Does the input file exist?
72
- if (isFile(value) === false) {
73
- // We cannot do anything without valid input.
74
- throw new InvalidArgumentError(`Twine 1 engine.js ${value} does not exist.`);
75
- }
76
-
77
- return value;
78
- })
79
- .option('--header <headerFile>', 'Twine 1 header.html file for use with Twine 1 HTML', (value) => {
80
- // Does the input file exist?
81
- if (isFile(value) === false) {
82
- // We cannot do anything without valid input.
83
- throw new InvalidArgumentError(`Twine 1 header.html ${value} does not exist.`);
84
- }
85
-
86
- return value;
87
- })
88
- .option('-s <storyformat>, --storyformat <storyformat>', 'Path to story format file for Twine 2', (value) => {
89
- // Does the input file exist?
90
- if (isFile(value) === false) {
91
- // We cannot do anything without valid input.
92
- throw new InvalidArgumentError(`Story format ${value} does not exist.`);
93
- }
94
-
95
- return value;
96
- })
97
- .option('-i <inputFile>, --input <inputFile>', 'Path to input file', (value) => {
98
- // Does the input file exist?
99
- if (isFile(value) === false) {
100
- // We cannot do anything without valid input.
101
- throw new InvalidArgumentError(`Input file ${value} does not exist.`);
102
- }
103
-
104
- return value;
105
- })
106
- .option('-o <outputFile>, --output <outputFile>', 'Path to output file');
107
-
108
- // Parse the passed arguments.
109
- program.parse(process.argv);
110
-
111
- // Create object of passed arguments parsed by Commander.
112
- const options = program.opts();
113
-
114
- /*
115
- * Prepare some (soon to be) global variables.
11
+ /**
12
+ * As a command-line tool, Extwee can be invoked multiple ways.
13
+ * (1) Via NPX with command-line arguments. (process.argv.length > 2)
14
+ * (2) Via NPX in the presence of a `extwee.config.json` file.
116
15
  */
117
- // Check if Twine 1 is enabled.
118
- const isTwine1Mode = (options.twine1 === true);
119
-
120
- // Check if Twine 2 is enabled.
121
- const isTwine2Mode = (isTwine1Mode === false);
122
-
123
- // Check if de-compile mode is enabled.
124
- const isDecompileMode = (options.decompile === true);
125
-
126
- // Check if compile mode is enabled.
127
- const isCompileMode = (options.compile === true);
128
-
129
- // De-compile Twine 2 HTML into Twee 3 branch.
130
- // If -d is passed, -i and -o are required.
131
- if (isTwine2Mode === true && isDecompileMode === true) {
132
- // Read the input HTML file.
133
- const inputHTML = readFileSync(options.i, 'utf-8');
134
16
 
135
- // Parse the input HTML file into Story object.
136
- const storyObject = parseTwine2HTML(inputHTML);
137
-
138
- // Write the output file from Story as Twee 3.
139
- writeFileSync(options.o, storyObject.toTwee());
140
-
141
- // Exit the process.
142
- process.exit();
143
- }
144
-
145
- // Compile Twee 3 into Twine 2 HTML branch.
146
- // If -c is passed, -i, -o, and -s are required.
147
- if (isTwine2Mode === true && isCompileMode === true) {
148
- // Read the input file.
149
- const inputTwee = readFileSync(options.i, 'utf-8');
150
-
151
- // Parse the input file.
152
- const story = parseTwee(inputTwee);
153
-
154
- // Read the story format file.
155
- const inputStoryFormat = readFileSync(options.s, 'utf-8');
156
-
157
- // Parse the story format file.
158
- const parsedStoryFormat = parseStoryFormat(inputStoryFormat);
159
-
160
- // Compile the story.
161
- const Twine2HTML = compileTwine2HTML(story, parsedStoryFormat);
162
-
163
- // Write the output file.
164
- writeFileSync(options.o, Twine2HTML);
165
-
166
- // Exit the process.
167
- process.exit();
17
+ // Check if the command line arguments are present.
18
+ if(process.argv.length > 2) {
19
+ // Process the command line arguments.
20
+ CommandLineProcessing(process.argv);
21
+ // Exit the process.
22
+ process.exit(0);
168
23
  }
169
24
 
170
- // Compile Twee 3 into Twine 1 HTML branch.
171
- // Twine 1 compilation is complicated, so we have to check for all the required options.
172
- // * options.engine (from Twine 1 itself)
173
- // * options.header (from Twine 1 story format)
174
- // * options.name (from Twine 1 story format)
175
- // * options.codejs (from Twine 1 story format)
176
- if (isTwine1Mode === true && isCompileMode === true) {
177
- // Read the input file.
178
- const inputTwee = readFileSync(options.i, 'utf-8');
179
-
180
- // Parse the input file.
181
- const story = parseTwee(inputTwee);
182
-
183
- // Does the engine file exist?
184
- const Twine1HTML = compileTwine1HTML(story, options.engine, options.header, options.name, options.codejs);
185
-
186
- // Write the output file.
187
- writeFileSync(options.o, Twine1HTML);
188
-
189
- // Exit the process.
190
- process.exit();
191
- }
192
-
193
- // De-compile Twine 1 HTML into Twee 3 branch.
194
- if (isTwine1Mode === true && isDecompileMode === true) {
195
- // Read the input HTML file.
196
- const inputHTML = readFileSync(options.i, 'utf-8');
197
-
198
- // Parse the input HTML file into Story object.
199
- const storyObject = parseTwine1HTML(inputHTML);
200
-
201
- // Write the output file from Story as Twee 3.
202
- writeFileSync(options.o, storyObject.toTwee());
203
-
204
- // Exit the process.
205
- process.exit();
206
- }
25
+ // Check if the config file exists.
26
+ if(ConfigFilePresent()) {
27
+ // Process the config file.
28
+ ConfigFileProcessing();
29
+ // Exit the process.
30
+ process.exit(0);
31
+ }
@@ -14,9 +14,10 @@ describe('src/Config/reader.js', () => {
14
14
  it('should return the parsed JSON contents of the file', () => {
15
15
  const contents = ConfigReader('test/Config/files/valid.json');
16
16
  expect(contents).toEqual({
17
- "story-format": 'Harlowe',
18
- "story-title": "My Story",
19
- "story-version": "2.0.1"
17
+ "story-format": 'harlowe',
18
+ "mode": "decompile",
19
+ "input": "index.html",
20
+ "output": "index.twee"
20
21
  });
21
22
  });
22
23
  });
@@ -27,20 +28,49 @@ describe('src/Config/reader.js', () => {
27
28
  expect(() => ConfigParser('{')).toThrow();
28
29
  });
29
30
 
30
- it('should extract the StoryFormat, StoryTitle, and StoryVersion from the JSON object', () => {
31
+ it('should extract the StoryFormat and StoryFormatVersion from the JSON object', () => {
31
32
  const jsonObject = ConfigReader('test/Config/files/valid.json');
32
33
  const contents = ConfigParser(jsonObject);
33
- expect(contents.StoryFormat).toEqual('Harlowe');
34
- expect(contents.StoryTitle).toEqual('My Story');
35
- expect(contents.StoryVersion).toEqual('2.0.1');
34
+ expect(contents.StoryFormat).toEqual('harlowe');
35
+ expect(contents.StoryFormatVersion).toEqual('latest');
36
+ expect(contents.Input).toEqual('index.html');
37
+ expect(contents.Output).toEqual('index.twee');
38
+ expect(contents.Mode).toEqual('decompile');
39
+ expect(contents.Twine1Project).toEqual(false);
36
40
  });
37
41
 
38
- it('should not extract the StoryFormat, StoryTitle, and StoryVersion if they do not exist in the JSON object', () => {
42
+ it('should not extract options if they do not exist in the JSON object', () => {
39
43
  const jsonObject = ConfigReader('test/Config/files/empty.json');
40
44
  const contents = ConfigParser(jsonObject);
41
45
  expect(contents.StoryFormat).toBeNull();
42
- expect(contents.StoryTitle).toBeNull();
43
- expect(contents.StoryVersion).toBeNull();
46
+ expect(contents.StoryFormatVersion).toBe('latest');
47
+ expect(contents.Input).toBeNull();
48
+ expect(contents.Output).toBeNull();
49
+ expect(contents.Mode).toBeNull();
50
+ expect(contents.Twine1Project).toBe(false);
51
+ });
52
+
53
+ it('should set StoryFormatVersion to "latest" if it is not present in the JSON object', () => {
54
+ const jsonObject = ConfigReader('test/Config/files/valid.json');
55
+ const contents = ConfigParser(jsonObject);
56
+ expect(contents.StoryFormatVersion).toEqual('latest');
57
+ });
58
+
59
+ it('should set Twine1Project to false if it is not present in the JSON object', () => {
60
+ const jsonObject = ConfigReader('test/Config/files/valid.json');
61
+ const contents = ConfigParser(jsonObject);
62
+ expect(contents.Twine1Project).toEqual(false);
63
+ });
64
+
65
+ it('Should read story-format, story-format-version, and twine1-project if present', () => {
66
+ const jsonObject = ConfigReader('test/Config/files/full.json');
67
+ const contents = ConfigParser(jsonObject);
68
+ expect(contents.StoryFormat).toEqual('harlowe');
69
+ expect(contents.StoryFormatVersion).toEqual('3.2.0');
70
+ expect(contents.Input).toEqual('index.twee');
71
+ expect(contents.Output).toEqual('index.html');
72
+ expect(contents.Mode).toEqual('compile');
73
+ expect(contents.Twine1Project).toEqual(false);
44
74
  });
45
75
  });
46
76
  });
@@ -0,0 +1,8 @@
1
+ {
2
+ "story-format": "harlowe",
3
+ "mode": "compile",
4
+ "input": "index.twee",
5
+ "output": "index.html",
6
+ "story-format-version": "3.2.0",
7
+ "twine1-project": false
8
+ }
@@ -1,5 +1,6 @@
1
1
  {
2
- "story-format": "Harlowe",
3
- "story-title": "My Story",
4
- "story-version": "2.0.1"
2
+ "story-format": "harlowe",
3
+ "mode": "decompile",
4
+ "input": "index.html",
5
+ "output": "index.twee"
5
6
  }
@@ -0,0 +1,44 @@
1
+ import { isDirectory } from '../../src/CLI/isDirectory';
2
+ import { statSync } from 'node:fs';
3
+
4
+ jest.mock('node:fs');
5
+
6
+ describe('isDirectory', () => {
7
+ afterEach(() => {
8
+ jest.clearAllMocks();
9
+ });
10
+
11
+ it('should return true if the path is a directory', () => {
12
+ const mockStats = { isDirectory: jest.fn(() => true) };
13
+ statSync.mockReturnValue(mockStats);
14
+
15
+ const result = isDirectory('/valid/directory/path');
16
+ expect(statSync).toHaveBeenCalledWith('/valid/directory/path');
17
+ expect(mockStats.isDirectory).toHaveBeenCalled();
18
+ expect(result).toBe(true);
19
+ });
20
+
21
+ it('should return false if the path is not a directory', () => {
22
+ const mockStats = { isDirectory: jest.fn(() => false) };
23
+ statSync.mockReturnValue(mockStats);
24
+
25
+ const result = isDirectory('/valid/file/path');
26
+ expect(statSync).toHaveBeenCalledWith('/valid/file/path');
27
+ expect(mockStats.isDirectory).toHaveBeenCalled();
28
+ expect(result).toBe(false);
29
+ });
30
+
31
+ it('should return false and log an error if statSync throws an error', () => {
32
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
33
+ statSync.mockImplementation(() => {
34
+ throw new Error('Test error');
35
+ });
36
+
37
+ const result = isDirectory('/invalid/path');
38
+ expect(statSync).toHaveBeenCalledWith('/invalid/path');
39
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Error: Test error'));
40
+ expect(result).toBe(false);
41
+
42
+ consoleErrorSpy.mockRestore();
43
+ });
44
+ });
@@ -0,0 +1,50 @@
1
+ import { isFile } from '../../src/CLI/isFile.js';
2
+ import { statSync } from 'node:fs';
3
+
4
+ // Mock the statSync function from 'fs'.
5
+ jest.mock('node:fs', () => ({
6
+ statSync: jest.fn(),
7
+ }));
8
+
9
+ describe('isFile', () => {
10
+ afterEach(() => {
11
+ jest.clearAllMocks();
12
+ });
13
+
14
+ it('should return true if the path is a valid file', () => {
15
+ // Mock statSync to return an object with isFile() returning true.
16
+ statSync.mockReturnValue({
17
+ isFile: jest.fn(() => true),
18
+ });
19
+
20
+ const result = isFile('/path/to/file');
21
+ expect(result).toBe(true);
22
+ expect(statSync).toHaveBeenCalledWith('/path/to/file');
23
+ });
24
+
25
+ it('should return false if the path is not a valid file', () => {
26
+ // Mock statSync to return an object with isFile() returning false.
27
+ statSync.mockReturnValue({
28
+ isFile: jest.fn(() => false),
29
+ });
30
+
31
+ const result = isFile('/path/to/directory');
32
+ expect(result).toBe(false);
33
+ expect(statSync).toHaveBeenCalledWith('/path/to/directory');
34
+ });
35
+
36
+ it('should return false and log an error if statSync throws an error', () => {
37
+ // Mock statSync to throw an error.
38
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
39
+ statSync.mockImplementation(() => {
40
+ throw new Error('File not found');
41
+ });
42
+
43
+ const result = isFile('/invalid/path');
44
+ expect(result).toBe(false);
45
+ expect(statSync).toHaveBeenCalledWith('/invalid/path');
46
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Error: Error: File not found'));
47
+
48
+ consoleErrorSpy.mockRestore();
49
+ });
50
+ });
@@ -0,0 +1,101 @@
1
+ import { loadStoryFormat } from "../../src/CLI/ProcessConfig/loadStoryFormat.js";
2
+ import { isDirectory } from "../../src/CLI/isDirectory.js";
3
+ import { isFile } from "../../src/CLI/isFile.js";
4
+ import { readDirectories } from "../../src/CLI/ProcessConfig/readDirectories.js";
5
+ import { readFileSync } from "node:fs";
6
+
7
+ jest.mock("../../src/CLI/isDirectory.js");
8
+ jest.mock("../../src/CLI/isFile.js");
9
+ jest.mock("../../src/CLI/ProcessConfig/readDirectories.js");
10
+ jest.mock("node:fs");
11
+
12
+ describe("loadStoryFormat", () => {
13
+ afterEach(() => {
14
+ jest.clearAllMocks();
15
+ });
16
+
17
+ it("should throw an error if the story-formats directory does not exist", () => {
18
+ isDirectory.mockReturnValueOnce(false);
19
+
20
+ expect(() => loadStoryFormat("Harlowe", "3.2.0")).toThrow(
21
+ "Error: story-formats directory does not exist. Consider running 'npx sfa-get' to download the latest story formats."
22
+ );
23
+ });
24
+
25
+ it("should throw an error if the named story format directory does not exist", () => {
26
+ isDirectory.mockReturnValueOnce(true).mockReturnValueOnce(false);
27
+
28
+ expect(() => loadStoryFormat("Harlowe", "3.2.0")).toThrow(
29
+ "Error: story format Harlowe does not exist in the story-formats directory."
30
+ );
31
+ });
32
+
33
+ it("should throw an error if the version directory does not exist", () => {
34
+ isDirectory.mockReturnValueOnce(true).mockReturnValueOnce(true).mockReturnValueOnce(false);
35
+
36
+ expect(() => loadStoryFormat("Harlowe", "3.2.0")).toThrow(
37
+ "Error: story format Harlowe version 3.2.0 does not exist in the story-formats directory."
38
+ );
39
+ });
40
+
41
+ it("should throw an error if the format.js file does not exist in the version directory", () => {
42
+ isDirectory.mockReturnValueOnce(true).mockReturnValueOnce(true).mockReturnValueOnce(true);
43
+ isFile.mockReturnValueOnce(false);
44
+
45
+ expect(() => loadStoryFormat("Harlowe", "3.2.0")).toThrow(
46
+ "Error: story format Harlowe version 3.2.0 does not have a format.js file."
47
+ );
48
+ });
49
+
50
+ it("should return the contents of the format.js file if all checks pass", () => {
51
+ isDirectory.mockReturnValueOnce(true).mockReturnValueOnce(true).mockReturnValueOnce(true);
52
+ isFile.mockReturnValueOnce(true);
53
+ readFileSync.mockReturnValueOnce("format.js content");
54
+
55
+ const result = loadStoryFormat("Harlowe", "3.2.0");
56
+ expect(result).toBe("format.js content");
57
+ });
58
+
59
+ it("should handle 'latest' version and return the contents of the format.js file", () => {
60
+ isDirectory.mockReturnValueOnce(true).mockReturnValueOnce(true);
61
+ isFile.mockReturnValueOnce(false).mockReturnValueOnce(true);
62
+ readDirectories.mockReturnValueOnce(["3.2.0", "3.1.0"]);
63
+ readFileSync.mockReturnValueOnce("latest format.js content");
64
+
65
+ const result = loadStoryFormat("Harlowe", "latest");
66
+ expect(result).toBe("latest format.js content");
67
+ expect(readDirectories).toHaveBeenCalledWith("story-formats/Harlowe");
68
+ });
69
+
70
+ it("should throw an error if 'latest' version has no format.js file", () => {
71
+ isDirectory.mockReturnValueOnce(true).mockReturnValueOnce(true);
72
+ isFile.mockReturnValueOnce(false).mockReturnValueOnce(false);
73
+ readDirectories.mockReturnValueOnce(["3.2.0", "3.1.0"]);
74
+
75
+ expect(() => loadStoryFormat("Harlowe", "latest")).toThrow(
76
+ "Error: story format Harlowe version latest does not have a format.js file."
77
+ );
78
+ });
79
+
80
+ it("should read format.js file from the story format directory if it exists", () => {
81
+ isDirectory.mockReturnValueOnce(true).mockReturnValueOnce(true);
82
+ isFile.mockReturnValueOnce(true);
83
+ readDirectories.mockReturnValueOnce([]);
84
+ readFileSync.mockReturnValueOnce("latest format.js content");
85
+
86
+ const result = loadStoryFormat("Harlowe", "latest");
87
+ expect(result).toBe("latest format.js content");
88
+ expect(readFileSync).toHaveBeenCalledWith("story-formats/Harlowe/format.js", "utf-8");
89
+ });
90
+
91
+ it("should throw an error if the story format version is not 'latest' and version directories do not exist", () => {
92
+ isDirectory.mockReturnValueOnce(true).mockReturnValueOnce(true);
93
+ isFile.mockReturnValueOnce(false);
94
+ readDirectories.mockReturnValueOnce([]);
95
+
96
+ expect(() => loadStoryFormat("Harlowe", "latest")).toThrow(
97
+ `Error: story format Harlowe does not have any version directories.`
98
+ );
99
+ }
100
+ );
101
+ });
@@ -0,0 +1,68 @@
1
+ import { readDirectories } from '../../src/CLI/ProcessConfig/readDirectories.js';
2
+ import { readdirSync } from 'node:fs';
3
+ import { isDirectory } from '../../src/CLI/isDirectory.js';
4
+
5
+ jest.mock('node:fs');
6
+ jest.mock('../../src/CLI/isDirectory.js');
7
+
8
+ describe('readDirectories', () => {
9
+ afterEach(() => {
10
+ jest.clearAllMocks();
11
+ });
12
+
13
+ it('should return an empty array and log an error if the directory does not exist', () => {
14
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
15
+ isDirectory.mockReturnValue(false);
16
+
17
+ const result = readDirectories('/nonexistent');
18
+
19
+ expect(result).toEqual([]);
20
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Directory /nonexistent does not exist.');
21
+ consoleErrorSpy.mockRestore();
22
+ });
23
+
24
+ it('should return an empty array and log an error if readdirSync throws an error', () => {
25
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
26
+ isDirectory.mockReturnValue(true);
27
+ readdirSync.mockImplementation(() => {
28
+ throw new Error('Permission denied');
29
+ });
30
+
31
+ const result = readDirectories('/restricted');
32
+
33
+ expect(result).toEqual([]);
34
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Error reading directory /restricted:', expect.any(Error));
35
+ consoleErrorSpy.mockRestore();
36
+ });
37
+
38
+ it('should return an empty array if the directory is empty', () => {
39
+ isDirectory.mockReturnValue(true);
40
+ readdirSync.mockReturnValue([]);
41
+
42
+ const result = readDirectories('/empty');
43
+
44
+ expect(result).toEqual([]);
45
+ });
46
+
47
+ it('should return an array of directories', () => {
48
+ isDirectory.mockReturnValue(true);
49
+ readdirSync.mockReturnValue(['dir1', 'file1', 'dir2']);
50
+
51
+ isDirectory.mockImplementation((path) => {
52
+ return path === '/test/dir1' || path === '/test/dir2';
53
+ });
54
+
55
+ const result = readDirectories('/test');
56
+
57
+ expect(result).toEqual(['dir1', 'dir2']);
58
+ });
59
+
60
+ it('should return an empty array if the result is not an array', () => {
61
+ isDirectory.mockReturnValue(true);
62
+ readdirSync.mockReturnValue('not an array');
63
+
64
+ const result = readDirectories('/test');
65
+
66
+ expect(result).toEqual([]);
67
+ });
68
+ });
@@ -240,5 +240,10 @@ describe('Passage', () => {
240
240
  expect(t.querySelector('tw-passagedata').getAttribute('tags')).toBe('&tag "bad"');
241
241
  expect(t.querySelector('tw-passagedata').getAttribute('position')).toBe('100,100');
242
242
  });
243
+
244
+ it('Should escape double-colon at start of text when exporting to Twee', function () {
245
+ const p = new Passage('test', ':: nefarious');
246
+ expect(p.toTwee()).toBe(':: test\n\\:: nefarious\n\n');
247
+ });
243
248
  });
244
249
  });