extwee 2.2.1 → 2.2.3

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 (68) hide show
  1. package/.eslintrc.json +7 -13
  2. package/.github/codeql-analysis.yml +51 -0
  3. package/README.md +9 -3
  4. package/build/extwee +0 -0
  5. package/build/extwee.web.min.js +1 -1
  6. package/docs/objects/story.md +1 -2
  7. package/index.js +2 -0
  8. package/jest.config.json +5 -0
  9. package/package.json +24 -21
  10. package/src/IFID/generate.js +20 -0
  11. package/src/JSON/parse.js +43 -0
  12. package/src/Passage.js +52 -3
  13. package/src/Story.js +266 -107
  14. package/src/StoryFormat/parse.js +190 -80
  15. package/src/StoryFormat.js +78 -88
  16. package/src/TWS/parse.js +2 -2
  17. package/src/Twee/parse.js +2 -3
  18. package/src/Twine1HTML/compile.js +2 -0
  19. package/src/Twine1HTML/parse.js +2 -3
  20. package/src/Twine2ArchiveHTML/compile.js +8 -0
  21. package/src/Twine2ArchiveHTML/parse.js +33 -3
  22. package/src/Twine2HTML/compile.js +31 -6
  23. package/src/Twine2HTML/parse.js +49 -54
  24. package/test/IFID/IFID.Generate.test.js +10 -0
  25. package/test/JSON/JSON.Parse.test.js +4 -4
  26. package/test/{Passage.test.js → Objects/Passage.test.js} +4 -4
  27. package/test/{Story.test.js → Objects/Story.test.js} +259 -50
  28. package/test/{StoryFormat.test.js → Objects/StoryFormat.test.js} +10 -3
  29. package/test/StoryFormat/StoryFormat.Parse.test.js +442 -55
  30. package/test/TWS/Parse.test.js +1 -1
  31. package/test/Twine2ArchiveHTML/Twine2ArchiveHTML.Parse.test.js +20 -4
  32. package/test/Twine2HTML/Twine2HTML.Compile.test.js +35 -120
  33. package/test/Twine2HTML/Twine2HTML.Parse.test.js +57 -38
  34. package/test/Twine2HTML/Twine2HTMLCompiler/format.js +9 -0
  35. package/test/Twine2HTML/Twine2HTMLParser/missingZoom.html +1 -1
  36. package/types/IFID/generate.d.ts +14 -0
  37. package/types/JSON/parse.d.ts +51 -0
  38. package/types/Passage.d.ts +117 -0
  39. package/types/Story.d.ts +230 -0
  40. package/types/StoryFormat/parse.d.ts +50 -0
  41. package/types/StoryFormat.d.ts +121 -0
  42. package/types/TWS/parse.d.ts +10 -0
  43. package/types/Twee/parse.d.ts +9 -0
  44. package/types/Twine1HTML/compile.d.ts +19 -0
  45. package/types/Twine1HTML/parse.d.ts +9 -0
  46. package/types/Twine2ArchiveHTML/compile.d.ts +14 -0
  47. package/types/Twine2ArchiveHTML/parse.d.ts +36 -0
  48. package/types/Twine2HTML/compile.d.ts +14 -0
  49. package/types/Twine2HTML/parse.d.ts +20 -0
  50. package/web-index.js +2 -0
  51. package/test/StoryFormat/StoryFormatParser/example.js +0 -3
  52. package/test/StoryFormat/StoryFormatParser/example2.js +0 -3
  53. package/test/StoryFormat/StoryFormatParser/format.js +0 -1
  54. package/test/StoryFormat/StoryFormatParser/format_doublename.js +0 -1
  55. package/test/StoryFormat/StoryFormatParser/harlowe.js +0 -3
  56. package/test/StoryFormat/StoryFormatParser/missingAuthor.js +0 -1
  57. package/test/StoryFormat/StoryFormatParser/missingDescription.js +0 -1
  58. package/test/StoryFormat/StoryFormatParser/missingImage.js +0 -1
  59. package/test/StoryFormat/StoryFormatParser/missingLicense.js +0 -1
  60. package/test/StoryFormat/StoryFormatParser/missingName.js +0 -1
  61. package/test/StoryFormat/StoryFormatParser/missingProofing.js +0 -1
  62. package/test/StoryFormat/StoryFormatParser/missingSource.js +0 -1
  63. package/test/StoryFormat/StoryFormatParser/missingURL.js +0 -1
  64. package/test/StoryFormat/StoryFormatParser/missingVersion.js +0 -1
  65. package/test/StoryFormat/StoryFormatParser/versionWrong.js +0 -1
  66. package/test/Twine2HTML/Twine2HTMLParser/missingName.html +0 -33
  67. package/test/Twine2HTML/Twine2HTMLParser/missingPID.html +0 -15
  68. package/test/Twine2HTML/Twine2HTMLParser/missingPassageName.html +0 -15
@@ -2,8 +2,16 @@ import { Story } from '../Story.js';
2
2
 
3
3
  /**
4
4
  * Write array of Story objects into Twine 2 Archive HTML.
5
+ * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md Twine 2 Archive Specification}
6
+ * @function compile
5
7
  * @param {Array} stories - Array of Story objects.
6
8
  * @returns {string} Twine 2 Archive HTML.
9
+ * @example
10
+ * const story1 = new Story();
11
+ * const story2 = new Story();
12
+ * const stories = [story1, story2];
13
+ * console.log(compile(stories));
14
+ * // => '<tw-storydata name="Untitled" startnode="1" creator="Twine" creator-version="2.3.9" ifid="A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6" zoom="1" format="Harlowe" format-version="3.1.0" options="" hidden><style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css"></style><script role="script" id="twine-user-script" type="text/twine-javascript"></script><tw-passagedata pid="1" name="Untitled Passage" tags="" position="0,0" size="100,100"></tw-passagedata></tw-storydata>\n\n<tw-storydata name="Untitled" startnode="1" creator="Twine" creator-version="2.3.9" ifid="A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6" zoom="1" format="Harlowe" format-version="3.1.0" options="" hidden><style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css"></style><script role="script" id="twine-user-script" type="text/twine-javascript"></script><tw-passagedata pid="1" name="Untitled Passage" tags="" position="0,0" size="100,100"></tw-passagedata></tw-storydata>\n\n'
7
15
  */
8
16
  function compile (stories) {
9
17
  // Can only accept array.
@@ -2,9 +2,39 @@ import { parse as HtmlParser } from 'node-html-parser';
2
2
  import { parse as parseTwine2HTML } from '../Twine2HTML/parse.js';
3
3
 
4
4
  /**
5
- * Parse HTML for one or more Twine 2 HTML elements and return array of story objects.
5
+ * Parse Twine 2 Archive HTML and returns an array of story objects.
6
+ * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md Twine 2 Archive Specification}
7
+ * @function parse
6
8
  * @param {string} content - Content to parse for Twine 2 HTML elements.
9
+ * @throws {TypeError} - Content is not a string!
7
10
  * @returns {Array} Array of stories found in content.
11
+ * @example
12
+ * const content = '<tw-storydata name="Untitled" startnode="1" creator="Twine" creator-version="2.3.9" ifid="A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6" zoom="1" format="Harlowe" format-version="3.1.0" options="" hidden><style role="stylesheet" id="twine-user-stylesheet" type="text/twine-css"></style><script role="script" id="twine-user-script" type="text/twine-javascript"></script><tw-passagedata pid="1" name="Untitled Passage" tags="" position="0,0" size="100,100"></tw-passagedata></tw-storydata>';
13
+ * console.log(parse(content));
14
+ * // => [
15
+ * // Story {
16
+ * // name: 'Untitled',
17
+ * // startnode: '1',
18
+ * // creator: 'Twine',
19
+ * // creatorVersion: '2.3.9',
20
+ * // ifid: 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6',
21
+ * // zoom: '1',
22
+ * // format: 'Harlowe',
23
+ * // formatVersion: '3.1.0',
24
+ * // options: '',
25
+ * // hidden: '',
26
+ * // passages: [
27
+ * // Passage {
28
+ * // pid: '1',
29
+ * // name: 'Untitled Passage',
30
+ * // tags: '',
31
+ * // position: '0,0',
32
+ * // size: '100,100',
33
+ * // text: ''
34
+ * // }
35
+ * // ]
36
+ * // }
37
+ * // ]
8
38
  */
9
39
  function parse (content) {
10
40
  // Can only parse string values.
@@ -32,8 +62,8 @@ function parse (content) {
32
62
 
33
63
  // Did we find any elements?
34
64
  if (storyDataElements.length === 0) {
35
- // If there is not a single `<tw-storydata>` element, this is not a Twine 2 story!
36
- throw new Error('Not Twine 2 HTML content!');
65
+ // Produce a warning if no Twine 2 HTML content is found.
66
+ console.warn('Warning: No Twine 2 HTML content found!');
37
67
  }
38
68
 
39
69
  // Iterate through all `<tw-storydata>` elements.
@@ -3,20 +3,48 @@ import StoryFormat from '../StoryFormat.js';
3
3
 
4
4
  /**
5
5
  * Write a combination of Story + StoryFormat into Twine 2 HTML file.
6
+ * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md Twine 2 HTML Output Specification}
7
+ * @function compile
6
8
  * @param {Story} story - Story object to write.
7
9
  * @param {StoryFormat} storyFormat - StoryFormat to write.
8
- * @returns {string} Twine 2 HTML.
10
+ * @returns {string} Twine 2 HTML based on StoryFormat and Story.
11
+ * @throws {Error} If story is not instance of Story.
12
+ * @throws {Error} If storyFormat is not instance of StoryFormat.
13
+ * @throws {Error} If storyFormat.source is empty string.
9
14
  */
10
15
  function compile (story, storyFormat) {
16
+ // Check if story is instanceof Story.
11
17
  if (!(story instanceof Story)) {
12
18
  throw new Error('Error: story must be a Story object!');
13
19
  }
14
20
 
21
+ // Check if storyFormat is instanceof StoryFormat.
15
22
  if (!(storyFormat instanceof StoryFormat)) {
16
23
  throw new Error('storyFormat must be a StoryFormat object!');
17
24
  }
18
25
 
19
- let outputContents = '';
26
+ // Check if storyFormat.source is empty string.
27
+ if (storyFormat.source === '') {
28
+ throw new Error('StoryFormat source empty string!');
29
+ }
30
+
31
+ /**
32
+ * There are two required attributes:
33
+ * - story.IFID: UUIDv4
34
+ * - story.name: string (non-empty)
35
+ */
36
+
37
+ // Check if story.IFID is UUIDv4 formatted.
38
+ if (story.IFID.match(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[89ABab][0-9A-F]{3}-[0-9A-F]{12}$/) === null) {
39
+ throw new Error('Story IFID is invalid!');
40
+ }
41
+
42
+ // Check if story.name is empty string.
43
+ if (story.name === '') {
44
+ throw new Error('Story name empty string!');
45
+ }
46
+
47
+ // Translate story to Twine 2 HTML.
20
48
  const storyData = story.toTwine2HTML();
21
49
 
22
50
  // Replace the story name in the source file.
@@ -25,11 +53,8 @@ function compile (story, storyFormat) {
25
53
  // Replace the story data.
26
54
  storyFormat.source = storyFormat.source.replaceAll(/{{STORY_DATA}}/gm, storyData);
27
55
 
28
- // Combine everything together.
29
- outputContents += storyFormat.source;
30
-
31
56
  // Return content.
32
- return outputContents;
57
+ return storyFormat.source;
33
58
  }
34
59
 
35
60
  export { compile };
@@ -8,8 +8,18 @@ import { decode } from 'html-entities';
8
8
  *
9
9
  * See: Twine 2 HTML Output Specification
10
10
  * (https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md)
11
+ *
12
+ * Produces warnings for:
13
+ * - Missing name attribute on `<tw-storydata>` element.
14
+ * - Missing IFID attribute on `<tw-storydata>` element.
15
+ * - Malformed IFID attribute on `<tw-storydata>` element.
16
+ * @function parse
11
17
  * @param {string} content - Twine 2 HTML content to parse.
12
- * @returns {Story} Story
18
+ * @returns {Story} Story object based on Twine 2 HTML content.
19
+ * @throws {TypeError} Content is not a string.
20
+ * @throws {Error} Not Twine 2 HTML content!
21
+ * @throws {Error} Cannot parse passage data without name!
22
+ * @throws {Error} Passages are required to have PID!
13
23
  */
14
24
  function parse (content) {
15
25
  // Create new story.
@@ -17,7 +27,7 @@ function parse (content) {
17
27
 
18
28
  // Can only parse string values.
19
29
  if (typeof content !== 'string') {
20
- throw new TypeError('Content is not a string!');
30
+ throw new TypeError('TypeError: Content is not a string!');
21
31
  }
22
32
 
23
33
  // Set default start node.
@@ -41,7 +51,7 @@ function parse (content) {
41
51
  // Did we find any elements?
42
52
  if (storyDataElements.length === 0) {
43
53
  // If there is not a single `<tw-storydata>` element, this is not a Twine 2 story!
44
- throw new Error('Not Twine 2 HTML content!');
54
+ throw new TypeError('TypeError: Not Twine 2 HTML content!');
45
55
  }
46
56
 
47
57
  // We only parse the first element found.
@@ -56,7 +66,7 @@ function parse (content) {
56
66
  story.name = storyData.attributes.name;
57
67
  } else {
58
68
  // Name is a required field. Warn user.
59
- console.warn('Twine 2 HTML must have a name!');
69
+ console.warn('Warning: The name attribute is missing from tw-storydata!');
60
70
  }
61
71
 
62
72
  /**
@@ -70,7 +80,13 @@ function parse (content) {
70
80
  story.IFID = storyData.attributes.ifid;
71
81
  } else {
72
82
  // Name is a required filed. Warn user.
73
- console.warn('Twine 2 HTML must have an IFID!');
83
+ console.warn('Warning: The ifid attribute is missing from tw-storydata!');
84
+ }
85
+
86
+ // Check if the IFID has valid formatting.
87
+ if (story.IFID.match(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/) === null) {
88
+ // IFID is not valid.
89
+ console.warn('Warning: The IFID is not in valid UUIDv4 formatting on tw-storydata!');
74
90
  }
75
91
 
76
92
  /**
@@ -125,9 +141,6 @@ function parse (content) {
125
141
  if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'startnode')) {
126
142
  // Take string value and convert to Int
127
143
  startNode = Number.parseInt(storyData.attributes.startnode, 10);
128
- } else {
129
- // Throw error without start node.
130
- throw new Error('Missing startnode in <tw-storydata>!');
131
144
  }
132
145
 
133
146
  // Pull out the `<tw-passagedata>` element.
@@ -173,15 +186,21 @@ function parse (content) {
173
186
  * https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md#passages
174
187
  */
175
188
  // Create a default value
176
- let name = null;
189
+ let name = 'Untitled Passage';
177
190
  // Does name exist?
178
191
  if (Object.prototype.hasOwnProperty.call(attr, 'name')) {
179
192
  // Escape the name
180
- name = escapeMetacharacters(attr.name);
193
+ name = attr.name;
181
194
  } else {
182
- throw new Error('Cannot parse passage data without name!');
195
+ console.warn('Warning: name attribute is missing! Default passage name will be used.');
183
196
  }
184
197
 
198
+ /**
199
+ * tags: (string) Optional.
200
+ * A space-separated list of tags for the passage.
201
+ *
202
+ * https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md#passages
203
+ */
185
204
  // Create empty tag array.
186
205
  let tags = [];
187
206
  // Does the tags attribute exist?
@@ -190,7 +209,7 @@ function parse (content) {
190
209
  // (Attributes can, themselves, be empty strings.)
191
210
  if (attr.tags.length > 0 && attr.tags !== '""') {
192
211
  // Escape the tags
193
- tags = escapeMetacharacters(attr.tags);
212
+ tags = attr.tags;
194
213
  // Split by spaces into an array
195
214
  tags = tags.split(' ');
196
215
  }
@@ -199,6 +218,12 @@ function parse (content) {
199
218
  tags = tags.filter(tag => tag !== '');
200
219
  }
201
220
 
221
+ /**
222
+ * metadata: (object) Optional.
223
+ * An object containing additional metadata about the passage.
224
+ *
225
+ * Twine 2 HTML does not support metadata, but other formats do.
226
+ */
202
227
  // Create metadata for passage.
203
228
  const metadata = {};
204
229
 
@@ -222,14 +247,14 @@ function parse (content) {
222
247
  * https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md#passages
223
248
  */
224
249
  // Create a default PID
225
- let pid = -1;
250
+ let pid = 1;
226
251
  // Does pid exist?
227
252
  if (Object.prototype.hasOwnProperty.call(attr, 'pid')) {
228
253
  // Parse string into int
229
254
  // Update PID
230
255
  pid = Number.parseInt(attr.pid, 10);
231
256
  } else {
232
- console.warn('Passages are required to have PID. Will not add!');
257
+ console.warn('Warning: pid attribute is missing! Default PID will be used.');
233
258
  }
234
259
 
235
260
  // Check the current PID against startNode number.
@@ -239,24 +264,15 @@ function parse (content) {
239
264
  story.start = name;
240
265
  }
241
266
 
242
- // If passage is missing name and PID (required attributes),
243
- // they are not added.
244
- if (name !== null && pid !== -1) {
245
- // Add a new Passage into an array
246
- story.addPassage(
247
- new Passage(
248
- decode(name),
249
- decode(text),
250
- tags.map(tag => decode(tag)),
251
- metadata
252
- )
253
- );
254
- }
255
- }
256
-
257
- // There was an invalid startNode.
258
- if (story.start === '') {
259
- throw new Error('startNode does not exist within passages!');
267
+ // Add a new Passage into an array
268
+ story.addPassage(
269
+ new Passage(
270
+ decode(name),
271
+ decode(text),
272
+ tags.map(tag => decode(tag)),
273
+ metadata
274
+ )
275
+ );
260
276
  }
261
277
 
262
278
  // Look for the style element
@@ -325,25 +341,4 @@ function parse (content) {
325
341
  return story;
326
342
  }
327
343
 
328
- /**
329
- * Try to escape Twine 2 meta-characters.
330
- * @param {string} result - Text to parse.
331
- * @returns {string} Escaped characters.
332
- */
333
- function escapeMetacharacters (result) {
334
- // Replace any single backslash, \, with two of them, \\.
335
- result = result.replace(/\\/g, '\\');
336
- // Double-escape escaped {
337
- result = result.replace(/\\\{/g, '\\\\{');
338
- // Double-escape escaped }
339
- result = result.replace(/\\\}/g, '\\\\}');
340
- // Double-escape escaped [
341
- result = result.replace(/\\\[/g, '\\\\[');
342
- // Double-escape escaped ]
343
- result = result.replace(/\\\]/g, '\\\\]');
344
-
345
- return result;
346
- }
347
-
348
- export { parse, escapeMetacharacters };
349
- export default parse;
344
+ export { parse };
@@ -0,0 +1,10 @@
1
+ import { generate } from '../../src/IFID/generate.js';
2
+
3
+ describe('src/IFID/generate.js', () => {
4
+ describe('generate()', () => {
5
+ it('should generate a valid IFID', () => {
6
+ const ifid = generate();
7
+ expect(ifid).toMatch(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/);
8
+ });
9
+ });
10
+ });
@@ -16,7 +16,7 @@ describe('JSON', () => {
16
16
  const s = parseJSON(r.toJSON());
17
17
 
18
18
  // Check all properties.
19
- expect(s.name).toBe('');
19
+ expect(s.name).toBe('Untitled Story');
20
20
  expect(Object.keys(s.tagColors).length).toBe(0);
21
21
  expect(s.IFID).toBe('');
22
22
  expect(s.start).toBe('');
@@ -24,7 +24,7 @@ describe('JSON', () => {
24
24
  expect(s.format).toBe('');
25
25
  expect(s.creator).toBe(creatorName);
26
26
  expect(s.creatorVersion).toBe(creatorVersion);
27
- expect(s.zoom).toBe(0);
27
+ expect(s.zoom).toBe(1);
28
28
  expect(Object.keys(s.metadata).length).toBe(0);
29
29
  });
30
30
 
@@ -52,7 +52,7 @@ describe('JSON', () => {
52
52
  it('Should parse everything but name', function () {
53
53
  const s = '{"tagColors":{"r":"red"},"ifid":"dd","start":"Start","formatVersion":"1.0","metadata":{"some":"thing"},"format":"Snowman","creator":"extwee","creatorVersion":"2.2.0","zoom":1,"passages":[{"name":"Start","tags":["tag1"],"metadata":{},"text":"Word"}]}';
54
54
  const r = parseJSON(s);
55
- expect(r.name).toBe('');
55
+ expect(r.name).toBe('Untitled Story');
56
56
  expect(Object.keys(r.tagColors).length).toBe(1);
57
57
  expect(r.IFID).toBe('DD');
58
58
  expect(r.start).toBe('Start');
@@ -189,7 +189,7 @@ describe('JSON', () => {
189
189
  expect(r.format).toBe('Snowman');
190
190
  expect(r.creator).toBe(creatorName);
191
191
  expect(r.creatorVersion).toBe('2.2.0');
192
- expect(r.zoom).toBe(0);
192
+ expect(r.zoom).toBe(1);
193
193
  expect(r.size()).toBe(1);
194
194
  });
195
195
 
@@ -1,4 +1,4 @@
1
- import Passage from '../src/Passage.js';
1
+ import Passage from '../../src/Passage.js';
2
2
  import { parse as HTMLParser } from 'node-html-parser';
3
3
 
4
4
  describe('Passage', () => {
@@ -218,8 +218,8 @@ describe('Passage', () => {
218
218
  });
219
219
 
220
220
  it('Should escape meta-characters safely in Twee header', function () {
221
- const p = new Passage('Where do tags begin? [well', '', ['hmm']);
222
- expect(p.toTwee().includes('Where do tags begin? \[well [hmm]')).toBe(true);
221
+ const p = new Passage('Where do tags begin? [well', '', ['hmm']);
222
+ expect(p.toTwee().includes('Where do tags begin? [well [hmm]')).toBe(true);
223
223
  });
224
224
 
225
225
  it('Should produce valid HTML attributes', function () {
@@ -232,7 +232,7 @@ describe('Passage', () => {
232
232
  expect(d.querySelector('tw-passagedata').getAttribute('tags')).toBe('&tag "bad"');
233
233
  expect(d.querySelector('tw-passagedata').getAttribute('position')).toBe('100,100');
234
234
  // Use Twine 2 result.
235
- const s = `<tw-passagedata pid="1" name="&quot;Test&quot;" tags="&amp;tag &quot;bad&quot;" position="100,100" size="100,100"></tw-passagedata>`;
235
+ const s = '<tw-passagedata pid="1" name="&quot;Test&quot;" tags="&amp;tag &quot;bad&quot;" position="100,100" size="100,100"></tw-passagedata>';
236
236
  // Parse HTML.
237
237
  const t = new HTMLParser(s);
238
238
  // Test Twine 2 attributes.