extwee 2.3.4 → 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.
- package/build/extwee.core.min.js +1 -1
- package/build/extwee.twine1html.min.js +1 -1
- package/build/extwee.twine2archive.min.js +1 -1
- package/build/extwee.tws.min.js +1 -1
- package/docs/build/extwee.core.min.js +1 -1
- package/docs/build/extwee.twine1html.min.js +1 -1
- package/docs/build/extwee.twine2archive.min.js +1 -1
- package/docs/build/extwee.tws.min.js +1 -1
- package/package.json +10 -10
- package/src/CLI/CommandLineProcessing.js +148 -153
- package/src/Passage.js +6 -4
- package/src/Story.js +1 -1
- package/src/Twee/parse.js +117 -21
- package/test/Objects/Passage.test.js +1 -1
- package/test/Twee/Twee.Escaping.test.js +200 -0
- package/test/Web/web-twine1html.test.js +105 -0
- package/test/Web/web-twine2archive.test.js +96 -0
- package/test/Web/web-tws.test.js +77 -0
- package/types/src/Story.d.ts +1 -1
- package/types/src/Twee/parse.d.ts +21 -0
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
13
13
|
|
|
14
14
|
// Import Commander.
|
|
15
|
-
import { Command
|
|
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
|
-
.
|
|
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'
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
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
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 };
|
|
@@ -219,7 +219,7 @@ describe('Passage', () => {
|
|
|
219
219
|
|
|
220
220
|
it('Should escape meta-characters safely in Twee header', function () {
|
|
221
221
|
const p = new Passage('Where do tags begin? [well', '', ['hmm']);
|
|
222
|
-
expect(p.toTwee().includes('Where do tags begin? [well [hmm]')).toBe(true);
|
|
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 () {
|