extwee 2.3.3 → 2.3.5

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 (41) hide show
  1. package/build/extwee.core.min.js +1 -1
  2. package/build/extwee.twine1html.min.js +1 -1
  3. package/build/extwee.twine2archive.min.js +1 -1
  4. package/build/extwee.tws.min.js +1 -1
  5. package/docs/build/extwee.core.min.js +1 -0
  6. package/docs/build/extwee.twine1html.min.js +1 -0
  7. package/docs/build/extwee.twine2archive.min.js +1 -0
  8. package/docs/build/extwee.tws.min.js +1 -0
  9. package/docs/demos/compiler/extwee.core.min.js +1 -0
  10. package/docs/demos/compiler/index.css +105 -0
  11. package/docs/demos/compiler/index.html +359 -0
  12. package/package.json +19 -18
  13. package/src/CLI/CommandLineProcessing.js +148 -153
  14. package/src/Passage.js +6 -4
  15. package/src/Story.js +1 -1
  16. package/src/Twee/parse.js +117 -21
  17. package/src/Twine2HTML/parse-web.js +7 -1
  18. package/src/Web/web-core.js +22 -2
  19. package/src/Web/web-twine1html.js +25 -5
  20. package/src/Web/web-twine2archive.js +25 -5
  21. package/src/Web/web-tws.js +22 -4
  22. package/test/Objects/Passage.test.js +1 -1
  23. package/test/Twee/Twee.Escaping.test.js +200 -0
  24. package/test/Twine1HTML/Twine1HTML.Parse.Web.test.js +484 -0
  25. package/test/Twine2ArchiveHTML/Twine2ArchiveHTML.Parse.Web.test.js +293 -0
  26. package/test/Twine2HTML/Twine2HTML.Parse.Web.test.js +329 -0
  27. package/test/Web/web-core-coverage.test.js +175 -0
  28. package/test/Web/web-core-global.test.js +93 -0
  29. package/test/Web/web-core.test.js +156 -0
  30. package/test/Web/web-twine1html.test.js +105 -0
  31. package/test/Web/web-twine2archive.test.js +96 -0
  32. package/test/Web/web-tws.test.js +77 -0
  33. package/test/Web/window.Extwee.test.js +7 -2
  34. package/types/src/Story.d.ts +1 -1
  35. package/types/src/Twee/parse.d.ts +21 -0
  36. package/types/src/Web/web-core.d.ts +23 -1
  37. package/types/src/Web/web-twine1html.d.ts +7 -0
  38. package/types/src/Web/web-twine2archive.d.ts +7 -0
  39. package/types/src/Web/web-tws.d.ts +5 -0
  40. package/webpack.config.js +2 -1
  41. package/src/Web/web-index.js +0 -31
@@ -12,7 +12,7 @@ import {
12
12
  import { readFileSync, writeFileSync } from 'node:fs';
13
13
 
14
14
  // Import Commander.
15
- import { Command, InvalidArgumentError } from 'commander';
15
+ import { Command } from 'commander';
16
16
 
17
17
  // Import package.json.
18
18
  import Package from '../../package.json' with { type: 'json' };
@@ -20,177 +20,172 @@ import Package from '../../package.json' with { type: 'json' };
20
20
  // Import isFile function.
21
21
  import { isFile } from './isFile.js';
22
22
 
23
+
24
+
23
25
  /**
24
26
  * Process command line arguments.
25
- * @function CommandLineProcessing
27
+ * @function CommandLineProcessing
26
28
  * @description This function processes the command line arguments passed to the Extwee CLI.
27
29
  * @module CLI/commandLineProcessing
28
30
  * @param {Array} argv - The command line arguments passed to the CLI.
29
31
  */
30
32
  export function CommandLineProcessing(argv) {
31
- // This is the main function for processing the command line arguments.
32
- // It uses the Commander library to parse the arguments and then calls the appropriate functions.
33
- // The function is called when the script is run from the command line.
34
-
35
-
36
33
  // Create a new Command.
37
34
  const program = new Command();
38
35
 
39
36
  program
40
37
  .name('extwee')
41
- .description('CLI for Extwee')
42
- .option('-v, --version', 'Show version number', () => {
43
- // Show the version number.
44
- console.log(`Extwee v${Package.version}`);
45
- // Exit the process.
46
- process.exit(0);
47
- })
38
+ .description('CLI for Extwee - A tool for compiling and decompiling Twine stories')
39
+ .version(Package.version, '-v, --version', 'Show version number')
48
40
  .option('-c, --compile', 'Compile input into output')
49
41
  .option('-d, --decompile', 'De-compile input into output')
50
42
  .option('--twine1', 'Enable Twine 1 processing')
51
43
  .option('--name <storyFormatName>', 'Name of the Twine 1 story format (needed for `code.js` inclusion)')
52
- .option('--codejs <codeJSFile>', 'Twine 1 code.js file for use with Twine 1 HTML', (value) => {
53
- // Does the input file exist?
54
- if (isFile(value) === false) {
55
- // We cannot do anything without valid input.
56
- throw new InvalidArgumentError(`Twine 1 code.js ${value} does not exist.`);
57
- }
58
-
59
- return value;
60
- })
61
- .option('--engine <engineFile>', 'Twine 1 engine.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 engine.js ${value} does not exist.`);
66
- }
67
-
68
- return value;
69
- })
70
- .option('--header <headerFile>', 'Twine 1 header.html 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 header.html ${value} does not exist.`);
75
- }
44
+ .option('--codejs <codeJSFile>', 'Twine 1 code.js file for use with Twine 1 HTML')
45
+ .option('--engine <engineFile>', 'Twine 1 engine.js file for use with Twine 1 HTML')
46
+ .option('--header <headerFile>', 'Twine 1 header.html file for use with Twine 1 HTML')
47
+ .option('-s <storyformat>, --storyformat <storyformat>', 'Path to story format file for Twine 2')
48
+ .option('-i <inputFile>, --input <inputFile>', 'Path to input file')
49
+ .option('-o <outputFile>, --output <outputFile>', 'Path to output file')
50
+ .addHelpText('after', `
51
+ Examples:
52
+ Compile Twee to Twine 2 HTML:
53
+ extwee -c -i story.twee -o story.html -s format.js
54
+
55
+ Compile Twee to Twine 1 HTML:
56
+ extwee --twine1 -c -i story.twee -o story.html --name "Sugarcane" --engine engine.js --header header.html --codejs code.js
57
+
58
+ Decompile Twine 2 HTML to Twee:
59
+ extwee -d -i story.html -o story.twee
60
+
61
+ Decompile Twine 1 HTML to Twee:
62
+ extwee --twine1 -d -i story.html -o story.twee
63
+ `);
64
+
65
+ // Parse the passed arguments with improved error handling
66
+ try {
67
+ program.parse(argv);
68
+
69
+ // Get parsed options
70
+ const options = program.opts();
71
+
72
+ // Validate and execute based on options
73
+ handleCommand(options);
74
+ } catch (error) {
75
+ if (error.code === 'commander.invalidArgument') {
76
+ console.error(`❌ ${error.message}`);
77
+ } else {
78
+ console.error(`❌ Error: ${error.message}`);
79
+ }
80
+ process.exit(1);
81
+ }
82
+ }
76
83
 
77
- return value;
78
- })
79
- .option('-s <storyformat>, --storyformat <storyformat>', 'Path to story format file for Twine 2', (value) => {
80
- // Does the input file exist?
81
- if (isFile(value) === false) {
82
- // We cannot do anything without valid input.
83
- throw new InvalidArgumentError(`Story format ${value} does not exist.`);
84
+ /**
85
+ * Handle the parsed command with improved validation and error messages
86
+ * @param {object} options - Parsed command options
87
+ */
88
+ function handleCommand(options) {
89
+ try {
90
+ // Commander uses the first option name as the property, so -i becomes 'i' and --input becomes 'input'
91
+ const inputFile = options.input || options.i;
92
+ const outputFile = options.output || options.o;
93
+ const storyFormatFile = options.storyformat || options.s;
94
+
95
+ // Validate required options
96
+ if (!inputFile) {
97
+ throw new Error('Input file (-i, --input) is required');
98
+ }
99
+ if (!outputFile) {
100
+ throw new Error('Output file (-o, --output) is required');
101
+ }
102
+
103
+ if (!options.compile && !options.decompile) {
104
+ throw new Error('Either --compile (-c) or --decompile (-d) must be specified');
105
+ }
106
+
107
+ if (options.compile && options.decompile) {
108
+ throw new Error('Cannot specify both --compile and --decompile');
109
+ }
110
+
111
+ // Validate input file exists
112
+ if (!isFile(inputFile)) {
113
+ throw new Error(`Input file '${inputFile}' does not exist`);
114
+ }
115
+
116
+ const isTwine1Mode = options.twine1 === true;
117
+ const isDecompileMode = options.decompile === true;
118
+ const isCompileMode = options.compile === true;
119
+
120
+ if (isDecompileMode) {
121
+ // Decompile HTML to Twee
122
+ console.log(`🔄 Decompiling ${isTwine1Mode ? 'Twine 1' : 'Twine 2'} HTML to Twee...`);
123
+
124
+ const inputHTML = readFileSync(inputFile, 'utf-8');
125
+ let storyObject;
126
+
127
+ if (isTwine1Mode) {
128
+ storyObject = parseTwine1HTML(inputHTML);
129
+ } else {
130
+ storyObject = parseTwine2HTML(inputHTML);
84
131
  }
85
-
86
- return value;
87
- })
88
- .option('-i <inputFile>, --input <inputFile>', 'Path to input file', (value) => {
89
- // Does the input file exist?
90
- if (isFile(value) === false) {
91
- // We cannot do anything without valid input.
92
- throw new InvalidArgumentError(`Input file ${value} does not exist.`);
132
+
133
+ writeFileSync(outputFile, storyObject.toTwee());
134
+ console.log(`✅ Successfully decompiled '${inputFile}' to '${outputFile}'`);
135
+
136
+ } else if (isCompileMode) {
137
+ // Compile Twee to HTML
138
+ console.log(`🔄 Compiling Twee to ${isTwine1Mode ? 'Twine 1' : 'Twine 2'} HTML...`);
139
+
140
+ const inputTwee = readFileSync(inputFile, 'utf-8');
141
+ const story = parseTwee(inputTwee);
142
+
143
+ if (isTwine1Mode) {
144
+ // Validate Twine 1 required options and files
145
+ const requiredOptions = [
146
+ { opt: 'engine', desc: 'engine.js file' },
147
+ { opt: 'header', desc: 'header.html file' },
148
+ { opt: 'name', desc: 'story format name' },
149
+ { opt: 'codejs', desc: 'code.js file' }
150
+ ];
151
+
152
+ const missingOptions = requiredOptions.filter(({ opt }) => !options[opt]);
153
+ if (missingOptions.length > 0) {
154
+ throw new Error(`Twine 1 compilation requires the following options: ${missingOptions.map(({ opt }) => `--${opt}`).join(', ')}`);
155
+ }
156
+
157
+ // Validate required files exist
158
+ const requiredFiles = ['engine', 'header', 'codejs'];
159
+ for (const fileOpt of requiredFiles) {
160
+ if (!isFile(options[fileOpt])) {
161
+ throw new Error(`Twine 1 ${fileOpt} file '${options[fileOpt]}' does not exist`);
162
+ }
163
+ }
164
+
165
+ const Twine1HTML = compileTwine1HTML(story, options.engine, options.header, options.name, options.codejs);
166
+ writeFileSync(outputFile, Twine1HTML);
167
+ } else {
168
+ // Validate Twine 2 required options
169
+ if (!storyFormatFile) {
170
+ throw new Error('Twine 2 compilation requires --storyformat option');
171
+ }
172
+
173
+ if (!isFile(storyFormatFile)) {
174
+ throw new Error(`Story format file '${storyFormatFile}' does not exist`);
175
+ }
176
+
177
+ const inputStoryFormat = readFileSync(storyFormatFile, 'utf-8');
178
+ const parsedStoryFormat = parseStoryFormat(inputStoryFormat);
179
+ const Twine2HTML = compileTwine2HTML(story, parsedStoryFormat);
180
+ writeFileSync(outputFile, Twine2HTML);
93
181
  }
94
-
95
- return value;
96
- })
97
- .option('-o <outputFile>, --output <outputFile>', 'Path to output file');
98
-
99
- // Parse the passed arguments.
100
- program.parse(argv);
101
-
102
- // Create object of passed arguments parsed by Commander.
103
- const options = program.opts();
104
-
105
- /*
106
- * Prepare some (soon to be) global variables.
107
- */
108
- // Check if Twine 1 is enabled.
109
- const isTwine1Mode = (options.twine1 === true);
110
-
111
- // Check if Twine 2 is enabled.
112
- const isTwine2Mode = (isTwine1Mode === false);
113
-
114
- // Check if de-compile mode is enabled.
115
- const isDecompileMode = (options.decompile === true);
116
-
117
- // Check if compile mode is enabled.
118
- const isCompileMode = (options.compile === true);
119
-
120
- // De-compile Twine 2 HTML into Twee 3 branch.
121
- // If -d is passed, -i and -o are required.
122
- if (isTwine2Mode === true && isDecompileMode === true) {
123
- // Read the input HTML file.
124
- const inputHTML = readFileSync(options.i, 'utf-8');
125
-
126
- // Parse the input HTML file into Story object.
127
- const storyObject = parseTwine2HTML(inputHTML);
128
-
129
- // Write the output file from Story as Twee 3.
130
- writeFileSync(options.o, storyObject.toTwee());
131
-
132
- return;
133
- }
134
-
135
- // Compile Twee 3 into Twine 2 HTML branch.
136
- // If -c is passed, -i, -o, and -s are required.
137
- if (isTwine2Mode === true && isCompileMode === true) {
138
- // Read the input file.
139
- const inputTwee = readFileSync(options.i, 'utf-8');
140
-
141
- // Parse the input file.
142
- const story = parseTwee(inputTwee);
143
-
144
- // Read the story format file.
145
- const inputStoryFormat = readFileSync(options.s, 'utf-8');
146
-
147
- // Parse the story format file.
148
- const parsedStoryFormat = parseStoryFormat(inputStoryFormat);
149
-
150
- // Compile the story.
151
- const Twine2HTML = compileTwine2HTML(story, parsedStoryFormat);
152
-
153
- // Write the output file.
154
- writeFileSync(options.o, Twine2HTML);
155
-
156
- // Exit the process.
157
- return;
182
+
183
+ console.log(`✅ Successfully compiled '${inputFile}' to '${outputFile}'`);
184
+ }
185
+ } catch (error) {
186
+ console.error(`❌ Operation failed: ${error.message}`);
187
+ process.exit(1);
158
188
  }
189
+ }
159
190
 
160
- // Compile Twee 3 into Twine 1 HTML branch.
161
- // Twine 1 compilation is complicated, so we have to check for all the required options.
162
- // * options.engine (from Twine 1 itself)
163
- // * options.header (from Twine 1 story format)
164
- // * options.name (from Twine 1 story format)
165
- // * options.codejs (from Twine 1 story format)
166
- if (isTwine1Mode === true && isCompileMode === true) {
167
- // Read the input file.
168
- const inputTwee = readFileSync(options.i, 'utf-8');
169
-
170
- // Parse the input file.
171
- const story = parseTwee(inputTwee);
172
-
173
- // Does the engine file exist?
174
- const Twine1HTML = compileTwine1HTML(story, options.engine, options.header, options.name, options.codejs);
175
-
176
- // Write the output file.
177
- writeFileSync(options.o, Twine1HTML);
178
-
179
- // Exit the process.
180
- return;
181
- }
182
-
183
- // De-compile Twine 1 HTML into Twee 3 branch.
184
- if (isTwine1Mode === true && isDecompileMode === true) {
185
- // Read the input HTML file.
186
- const inputHTML = readFileSync(options.i, 'utf-8');
187
-
188
- // Parse the input HTML file into Story object.
189
- const storyObject = parseTwine1HTML(inputHTML);
190
-
191
- // Write the output file from Story as Twee 3.
192
- writeFileSync(options.o, storyObject.toTwee());
193
191
 
194
- return;
195
- }
196
- }
package/src/Passage.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { encode } from 'html-entities';
2
+ import { escapeTweeMetacharacters } from './Twee/parse.js';
2
3
 
3
4
  /**
4
5
  * Passage class.
@@ -175,13 +176,14 @@ export default class Passage {
175
176
  // Start empty string.
176
177
  let content = '';
177
178
 
178
- // Write the name.
179
- content += `:: ${this.name}`;
179
+ // Write the name with proper escaping for metacharacters.
180
+ content += `:: ${escapeTweeMetacharacters(this.name)}`;
180
181
 
181
182
  // Test if it has any tags.
182
183
  if (this.tags.length > 0) {
183
- // Write output of tags.
184
- content += ` [${this.tags.join(' ')}]`;
184
+ // Write output of tags with proper escaping.
185
+ const escapedTags = this.tags.map(tag => escapeTweeMetacharacters(tag));
186
+ content += ` [${escapedTags.join(' ')}]`;
185
187
  }
186
188
 
187
189
  // Check if any properties exist.
package/src/Story.js CHANGED
@@ -7,7 +7,7 @@ import { encode } from 'html-entities';
7
7
  const creatorName = 'extwee';
8
8
 
9
9
  // Set the creator version.
10
- const creatorVersion = '2.3.3';
10
+ const creatorVersion = '2.3.5';
11
11
 
12
12
  /**
13
13
  * Story class.
package/src/Twee/parse.js CHANGED
@@ -1,6 +1,108 @@
1
1
  import Passage from '../Passage.js';
2
2
  import { Story } from '../Story.js';
3
3
 
4
+ /**
5
+ * Unescapes Twee 3 metacharacters according to the specification.
6
+ *
7
+ * From the Twee 3 specification:
8
+ * - Encoding: To avoid ambiguity, non-escape backslashes must also be escaped via
9
+ * the same mechanism (i.e. `foo\bar` must become `foo\\bar`).
10
+ * - Decoding: To make decoding more robust, any escaped character within a chunk of
11
+ * encoded text must yield the character minus the backslash (i.e. `\q` must yield `q`).
12
+ * @function unescapeTweeMetacharacters
13
+ * @param {string} text - Text to unescape
14
+ * @returns {string} Unescaped text
15
+ */
16
+ function unescapeTweeMetacharacters(text) {
17
+ if (typeof text !== 'string') {
18
+ return text;
19
+ }
20
+
21
+ // Replace any escaped character with the character minus the backslash
22
+ // This implements the robust decoding rule from the specification
23
+ return text.replace(/\\(.)/g, '$1');
24
+ }
25
+
26
+ /**
27
+ * Escapes Twee 3 metacharacters according to the specification.
28
+ * This is used when writing Twee files to ensure special characters are properly escaped.
29
+ * @function escapeTweeMetacharacters
30
+ * @param {string} text - Text to escape
31
+ * @returns {string} Escaped text
32
+ */
33
+ function escapeTweeMetacharacters(text) {
34
+ if (typeof text !== 'string') {
35
+ return text;
36
+ }
37
+
38
+ // First escape backslashes, then escape the metacharacters
39
+ return text
40
+ .replace(/\\/g, '\\\\') // Escape backslashes first
41
+ .replace(/\[/g, '\\[') // Escape opening square brackets
42
+ .replace(/\]/g, '\\]') // Escape closing square brackets
43
+ .replace(/\{/g, '\\{') // Escape opening curly braces
44
+ .replace(/\}/g, '\\}'); // Escape closing curly braces
45
+ }
46
+
47
+ /**
48
+ * Finds the last unescaped occurrence of a character in a string.
49
+ * @param {string} str - String to search in
50
+ * @param {string} char - Character to find
51
+ * @returns {number} Position of last unescaped occurrence, or -1 if not found
52
+ */
53
+ function findLastUnescaped(str, char) {
54
+ for (let i = str.length - 1; i >= 0; i--) {
55
+ if (str[i] === char) {
56
+ // Count consecutive backslashes before this character
57
+ let backslashCount = 0;
58
+ for (let j = i - 1; j >= 0 && str[j] === '\\'; j--) {
59
+ backslashCount++;
60
+ }
61
+ // If even number of backslashes (including 0), the character is not escaped
62
+ if (backslashCount % 2 === 0) {
63
+ return i;
64
+ }
65
+ }
66
+ }
67
+ return -1;
68
+ }
69
+
70
+ /**
71
+ * Parses metadata from a header string, respecting escaped characters.
72
+ * @param {string} header - Header string to parse
73
+ * @returns {object | null} Object with {metadata, remainingHeader} or null if no metadata
74
+ */
75
+ function parseMetadataFromHeader(header) {
76
+ const openingPos = findLastUnescaped(header, '{');
77
+ const closingPos = findLastUnescaped(header, '}');
78
+
79
+ if (openingPos !== -1 && closingPos !== -1 && closingPos > openingPos) {
80
+ const metadata = header.slice(openingPos, closingPos + 1);
81
+ const remainingHeader = header.substring(0, openingPos) + header.substring(closingPos + 1);
82
+ return { metadata, remainingHeader };
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Parses tags from a header string, respecting escaped characters.
90
+ * @param {string} header - Header string to parse
91
+ * @returns {object | null} Object with {tags, remainingHeader} or null if no tags
92
+ */
93
+ function parseTagsFromHeader(header) {
94
+ const openingPos = findLastUnescaped(header, '[');
95
+ const closingPos = findLastUnescaped(header, ']');
96
+
97
+ if (openingPos !== -1 && closingPos !== -1 && closingPos > openingPos) {
98
+ const tags = header.slice(openingPos, closingPos + 1);
99
+ const remainingHeader = header.substring(0, openingPos) + header.substring(closingPos + 1);
100
+ return { tags, remainingHeader };
101
+ }
102
+
103
+ return null;
104
+ }
105
+
4
106
  /**
5
107
  * Parses Twee 3 text into a Story object.
6
108
  * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twee-3-specification.md Twee 3 Specification}
@@ -51,16 +153,11 @@ function parse (fileContents) {
51
153
  // (And trim any remaining whitespace.)
52
154
  text = passage.substring(header.length + 1, passage.length).trim();
53
155
 
54
- // Test for metadata
55
- const openingCurlyBracketPosition = header.lastIndexOf('{');
56
- const closingCurlyBracketPosition = header.lastIndexOf('}');
57
-
58
- if (openingCurlyBracketPosition !== -1 && closingCurlyBracketPosition !== -1) {
59
- // Save the text metadata
60
- metadata = header.slice(openingCurlyBracketPosition, closingCurlyBracketPosition + 1);
61
-
62
- // Remove the metadata from the header
63
- header = header.substring(0, openingCurlyBracketPosition) + header.substring(closingCurlyBracketPosition + 1);
156
+ // Parse metadata using escape-aware logic
157
+ const metadataMatch = parseMetadataFromHeader(header);
158
+ if (metadataMatch) {
159
+ metadata = metadataMatch.metadata;
160
+ header = metadataMatch.remainingHeader;
64
161
  }
65
162
 
66
163
  // There was passage metadata
@@ -77,15 +174,11 @@ function parse (fileContents) {
77
174
  metadata = {};
78
175
  }
79
176
 
80
- // Test for tags
81
- const openingSquareBracketPosition = header.lastIndexOf('[');
82
- const closingSquareBracketPosition = header.lastIndexOf(']');
83
-
84
- if (openingSquareBracketPosition !== -1 && closingSquareBracketPosition !== -1) {
85
- tags = header.slice(openingSquareBracketPosition, closingSquareBracketPosition + 1);
86
-
87
- // Remove the tags from the header
88
- header = header.substring(0, openingSquareBracketPosition) + header.substring(closingSquareBracketPosition + 1);
177
+ // Parse tags using escape-aware logic
178
+ const tagsMatch = parseTagsFromHeader(header);
179
+ if (tagsMatch) {
180
+ tags = tagsMatch.tags;
181
+ header = tagsMatch.remainingHeader;
89
182
  }
90
183
 
91
184
  // Parse tags
@@ -138,12 +231,15 @@ function parse (fileContents) {
138
231
 
139
232
  // Check if there is a name left
140
233
  if (header.length > 0) {
141
- name = header;
234
+ name = unescapeTweeMetacharacters(header);
142
235
  } else {
143
236
  // No name left. Something went wrong. Blame user.
144
237
  throw new Error('Malformed passage header!');
145
238
  }
146
239
 
240
+ // Unescape tag names according to Twee 3 specification
241
+ tags = tags.map(tag => unescapeTweeMetacharacters(tag));
242
+
147
243
  // addPassage() method does all the work.
148
244
  story.addPassage(new Passage(name, text, tags, metadata, pid));
149
245
 
@@ -155,4 +251,4 @@ function parse (fileContents) {
155
251
  return story;
156
252
  }
157
253
 
158
- export { parse };
254
+ export { parse, escapeTweeMetacharacters, unescapeTweeMetacharacters };
@@ -101,11 +101,17 @@ class LightweightTwine2Parser {
101
101
  parseAttributes(elementHtml) {
102
102
  const attributes = {};
103
103
 
104
+ // Extract just the opening tag to avoid getting attributes from nested elements
105
+ const openingTagMatch = elementHtml.match(/^<[^>]*>/);
106
+ if (!openingTagMatch) return attributes;
107
+
108
+ const openingTag = openingTagMatch[0];
109
+
104
110
  // Common attribute patterns
105
111
  const attributeRegex = /(\w+(?:-\w+)*)=["']([^"']*)["']/g;
106
112
  let match;
107
113
 
108
- while ((match = attributeRegex.exec(elementHtml)) !== null) {
114
+ while ((match = attributeRegex.exec(openingTag)) !== null) {
109
115
  attributes[match[1]] = match[2];
110
116
  }
111
117
 
@@ -10,7 +10,7 @@ import Passage from '../Passage.js';
10
10
  import StoryFormat from '../StoryFormat.js';
11
11
 
12
12
  // Core functionality - most commonly used
13
- window.Extwee = {
13
+ const Extwee = {
14
14
  // Core parsers (immediately available)
15
15
  parseTwee,
16
16
  parseJSON,
@@ -27,5 +27,25 @@ window.Extwee = {
27
27
  StoryFormat,
28
28
 
29
29
  // Version info
30
- version: '2.3.2'
30
+ version: '2.3.3'
31
31
  };
32
+
33
+ // Export individual functions for ES6 module usage
34
+ export { parseTwee, parseJSON, parseStoryFormat, parseTwine2HTML, compileTwine2HTML, generateIFID, Story, Passage, StoryFormat };
35
+
36
+ // Export default for webpack UMD build
37
+ export default Extwee;
38
+
39
+ // For direct ES6 module usage, also assign to global object
40
+ // Use globalThis for cross-environment compatibility (browser, Node.js, Web Workers)
41
+ const globalObject = (function() {
42
+ if (typeof globalThis !== 'undefined') return globalThis;
43
+ if (typeof window !== 'undefined') return window;
44
+ if (typeof global !== 'undefined') return global;
45
+ if (typeof self !== 'undefined') return self;
46
+ return null;
47
+ })();
48
+
49
+ if (globalObject) {
50
+ globalObject.Extwee = Extwee;
51
+ }
@@ -2,14 +2,34 @@
2
2
  import { parse as parseTwine1HTML } from '../Twine1HTML/parse-web.js';
3
3
  import { compile as compileTwine1HTML } from '../Twine1HTML/compile.js';
4
4
 
5
- // Export for use as a separate module
5
+ // Create UMD-compatible export object
6
+ const Extwee = {
7
+ parseTwine1HTML,
8
+ compileTwine1HTML,
9
+ parse: parseTwine1HTML, // For module consistency
10
+ compile: compileTwine1HTML // For module consistency
11
+ };
12
+
13
+ // Export for webpack UMD build
14
+ export default Extwee;
15
+
16
+ // Also export individual functions for ES6 module usage
6
17
  export {
7
18
  parseTwine1HTML as parse,
8
19
  compileTwine1HTML as compile
9
20
  };
10
21
 
11
- // Also add to global Extwee if it exists
12
- if (typeof window !== 'undefined' && window.Extwee) {
13
- window.Extwee.parseTwine1HTML = parseTwine1HTML;
14
- window.Extwee.compileTwine1HTML = compileTwine1HTML;
22
+ // Add to global Extwee object for direct usage
23
+ const globalObject = (function() {
24
+ if (typeof globalThis !== 'undefined') return globalThis;
25
+ if (typeof window !== 'undefined') return window;
26
+ if (typeof global !== 'undefined') return global;
27
+ if (typeof self !== 'undefined') return self;
28
+ return null;
29
+ })();
30
+
31
+ if (globalObject) {
32
+ globalObject.Extwee = globalObject.Extwee || {};
33
+ globalObject.Extwee.parseTwine1HTML = parseTwine1HTML;
34
+ globalObject.Extwee.compileTwine1HTML = compileTwine1HTML;
15
35
  }