extwee 2.3.6 → 2.3.8
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/docs/demos/decompile/extwee.core.min.js +1 -0
- package/docs/demos/decompile/index.css +584 -0
- package/docs/demos/decompile/index.html +468 -0
- package/package.json +11 -13
- package/src/IFID/generate-web.js +20 -0
- package/src/IFID/generate.js +5 -4
- package/src/Story.js +1 -1
- package/src/Twine1HTML/parse-web.js +47 -8
- package/src/Twine2ArchiveHTML/parse-web.js +33 -7
- package/src/Twine2HTML/parse-web.js +105 -17
- package/test/Twine1HTML/Twine1HTML.Parse.Web.test.js +1 -1
- package/test/Twine2HTML/Twine2HTML.Parse.Web.test.js +2 -2
- package/types/src/IFID/generate-web.d.ts +14 -0
- package/types/src/IFID/generate.d.ts +2 -2
- package/types/src/Story.d.ts +1 -1
- package/webpack.config.js +14 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Extwee Decompiler Demo</title>
|
|
7
|
+
<link rel="stylesheet" href="index.css">
|
|
8
|
+
<script src="./extwee.core.min.js"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div class="container">
|
|
12
|
+
<header>
|
|
13
|
+
<h1>Extwee Decompiler Demo</h1>
|
|
14
|
+
<p>Upload a Twine 2 HTML file to analyze its structure and extract statistics.</p>
|
|
15
|
+
</header>
|
|
16
|
+
|
|
17
|
+
<main>
|
|
18
|
+
<section class="upload-section">
|
|
19
|
+
<div class="file-input-container">
|
|
20
|
+
<input type="file" id="fileInput" accept=".html,.htm" />
|
|
21
|
+
<label for="fileInput" class="file-input-label">
|
|
22
|
+
<span class="file-input-text">Choose Twine 2 HTML File</span>
|
|
23
|
+
<span class="file-input-icon">📁</span>
|
|
24
|
+
</label>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="file-info" id="fileInfo"></div>
|
|
27
|
+
</section>
|
|
28
|
+
|
|
29
|
+
<section class="results-section" id="resultsSection" style="display: none;">
|
|
30
|
+
<div class="tabs">
|
|
31
|
+
<button class="tab-button active" data-tab="overview">Overview</button>
|
|
32
|
+
<button class="tab-button" data-tab="passages">Passages</button>
|
|
33
|
+
<button class="tab-button" data-tab="tags">Tags</button>
|
|
34
|
+
<button class="tab-button" data-tab="metadata">Metadata</button>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="tab-content active" id="overview">
|
|
38
|
+
<div class="stats-grid">
|
|
39
|
+
<div class="stat-card">
|
|
40
|
+
<div class="stat-number" id="passageCount">0</div>
|
|
41
|
+
<div class="stat-label">Passages</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="stat-card">
|
|
44
|
+
<div class="stat-number" id="wordCount">0</div>
|
|
45
|
+
<div class="stat-label">Total Words</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="stat-card">
|
|
48
|
+
<div class="stat-number" id="characterCount">0</div>
|
|
49
|
+
<div class="stat-label">Characters</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="stat-card">
|
|
52
|
+
<div class="stat-number" id="tagCount">0</div>
|
|
53
|
+
<div class="stat-label">Unique Tags</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="story-info">
|
|
58
|
+
<h3>Story Information</h3>
|
|
59
|
+
<div class="info-grid">
|
|
60
|
+
<div class="info-item">
|
|
61
|
+
<label>Title:</label>
|
|
62
|
+
<span id="storyTitle">-</span>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="info-item">
|
|
65
|
+
<label>IFID:</label>
|
|
66
|
+
<span id="storyIFID">-</span>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="info-item">
|
|
69
|
+
<label>Format:</label>
|
|
70
|
+
<span id="storyFormat">-</span>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="info-item">
|
|
73
|
+
<label>Format Version:</label>
|
|
74
|
+
<span id="storyFormatVersion">-</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="info-item">
|
|
77
|
+
<label>Start Passage:</label>
|
|
78
|
+
<span id="startPassage">-</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="info-item">
|
|
81
|
+
<label>Creator:</label>
|
|
82
|
+
<span id="storyCreator">-</span>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div class="tab-content" id="passages">
|
|
89
|
+
<div class="passages-header">
|
|
90
|
+
<h3>Passages (<span id="passageListCount">0</span>)</h3>
|
|
91
|
+
<div class="search-container">
|
|
92
|
+
<input type="text" id="passageSearch" placeholder="Search passages...">
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="passages-list" id="passagesList"></div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div class="tab-content" id="tags">
|
|
99
|
+
<div class="tags-header">
|
|
100
|
+
<h3>Tags Overview</h3>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="tags-container" id="tagsContainer"></div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="tab-content" id="metadata">
|
|
106
|
+
<div class="metadata-container">
|
|
107
|
+
<h3>Technical Details</h3>
|
|
108
|
+
<div class="metadata-content" id="metadataContent"></div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</section>
|
|
112
|
+
|
|
113
|
+
<section class="error-section" id="errorSection" style="display: none;">
|
|
114
|
+
<div class="error-message">
|
|
115
|
+
<h3>Error</h3>
|
|
116
|
+
<p id="errorText"></p>
|
|
117
|
+
</div>
|
|
118
|
+
</section>
|
|
119
|
+
</main>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<script>
|
|
123
|
+
class TwineDecompiler {
|
|
124
|
+
constructor() {
|
|
125
|
+
this.story = null;
|
|
126
|
+
this.initializeEventListeners();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
initializeEventListeners() {
|
|
130
|
+
const fileInput = document.getElementById('fileInput');
|
|
131
|
+
const tabButtons = document.querySelectorAll('.tab-button');
|
|
132
|
+
const passageSearch = document.getElementById('passageSearch');
|
|
133
|
+
|
|
134
|
+
fileInput.addEventListener('change', (e) => this.handleFileUpload(e));
|
|
135
|
+
|
|
136
|
+
tabButtons.forEach(button => {
|
|
137
|
+
button.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
passageSearch.addEventListener('input', (e) => this.filterPassages(e.target.value));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async handleFileUpload(event) {
|
|
144
|
+
const file = event.target.files[0];
|
|
145
|
+
if (!file) return;
|
|
146
|
+
|
|
147
|
+
this.showFileInfo(file);
|
|
148
|
+
this.hideError();
|
|
149
|
+
this.hideResults();
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const content = await this.readFile(file);
|
|
153
|
+
|
|
154
|
+
// First, let's validate that this looks like Twine 2 HTML
|
|
155
|
+
if (!content.includes('<tw-storydata')) {
|
|
156
|
+
throw new Error('Not Twine 2 HTML content');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.story = Extwee.parseTwine2HTML(content);
|
|
160
|
+
this.displayResults();
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error('Parsing error:', error);
|
|
163
|
+
|
|
164
|
+
// Provide more helpful error messages for common issues
|
|
165
|
+
let errorMessage = error.message;
|
|
166
|
+
if (error.message.includes('Story name must be a string')) {
|
|
167
|
+
errorMessage = 'The Twine 2 HTML file has an invalid story name attribute. This usually means the file is corrupted or not a standard Twine 2 HTML export.';
|
|
168
|
+
} else if (error.message.includes('Not Twine 2 HTML content')) {
|
|
169
|
+
errorMessage = 'This file does not appear to be a valid Twine 2 HTML export. Please make sure you uploaded a file exported from Twine 2 using "Publish to File".';
|
|
170
|
+
} else if (error.message.includes('Content is not a string')) {
|
|
171
|
+
errorMessage = 'Unable to read the file content. Please make sure the file is not corrupted and is a text-based HTML file.';
|
|
172
|
+
} else if (error.message.includes('Passages are required to have PID')) {
|
|
173
|
+
errorMessage = 'The Twine 2 HTML file contains passages without proper IDs. This suggests the file may be corrupted or manually edited.';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.showError(errorMessage);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
readFile(file) {
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const reader = new FileReader();
|
|
183
|
+
reader.onload = (e) => resolve(e.target.result);
|
|
184
|
+
reader.onerror = (e) => reject(new Error('Failed to read file'));
|
|
185
|
+
reader.readAsText(file);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
showFileInfo(file) {
|
|
190
|
+
const fileInfo = document.getElementById('fileInfo');
|
|
191
|
+
const fileSize = (file.size / 1024).toFixed(2);
|
|
192
|
+
const lastModified = new Date(file.lastModified).toLocaleString();
|
|
193
|
+
fileInfo.innerHTML = `
|
|
194
|
+
<strong>File:</strong> ${this.escapeHtml(file.name)} (${fileSize} KB)<br>
|
|
195
|
+
<strong>Last Modified:</strong> ${this.escapeHtml(lastModified)}
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
displayResults() {
|
|
200
|
+
this.showResults();
|
|
201
|
+
this.populateOverview();
|
|
202
|
+
this.populatePassages();
|
|
203
|
+
this.populateTags();
|
|
204
|
+
this.populateMetadata();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
populateOverview() {
|
|
208
|
+
const passages = this.story.passages;
|
|
209
|
+
const allText = passages.map(p => p.text).join(' ');
|
|
210
|
+
const wordCount = this.countWords(allText);
|
|
211
|
+
const characterCount = allText.length;
|
|
212
|
+
const uniqueTags = this.getUniqueTags(passages);
|
|
213
|
+
|
|
214
|
+
document.getElementById('passageCount').textContent = passages.length;
|
|
215
|
+
document.getElementById('wordCount').textContent = wordCount.toLocaleString();
|
|
216
|
+
document.getElementById('characterCount').textContent = characterCount.toLocaleString();
|
|
217
|
+
document.getElementById('tagCount').textContent = uniqueTags.length;
|
|
218
|
+
|
|
219
|
+
document.getElementById('storyTitle').textContent = this.story.name || 'Untitled';
|
|
220
|
+
document.getElementById('storyIFID').textContent = this.story.IFID || 'None';
|
|
221
|
+
document.getElementById('storyFormat').textContent = this.story.format || 'Unknown';
|
|
222
|
+
document.getElementById('storyFormatVersion').textContent = this.story.formatVersion || 'Unknown';
|
|
223
|
+
document.getElementById('startPassage').textContent = this.story.start || 'None';
|
|
224
|
+
document.getElementById('storyCreator').textContent = this.story.creator || 'Unknown';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
populatePassages() {
|
|
228
|
+
const passagesList = document.getElementById('passagesList');
|
|
229
|
+
const passageListCount = document.getElementById('passageListCount');
|
|
230
|
+
|
|
231
|
+
passageListCount.textContent = this.story.passages.length;
|
|
232
|
+
passagesList.innerHTML = '';
|
|
233
|
+
|
|
234
|
+
this.story.passages.forEach((passage, index) => {
|
|
235
|
+
const passageElement = this.createPassageElement(passage, index);
|
|
236
|
+
passagesList.appendChild(passageElement);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
createPassageElement(passage, index) {
|
|
241
|
+
const div = document.createElement('div');
|
|
242
|
+
div.className = 'passage-item';
|
|
243
|
+
|
|
244
|
+
const wordCount = this.countWords(passage.text);
|
|
245
|
+
const characterCount = passage.text.length;
|
|
246
|
+
const tagsHtml = passage.tags.length > 0
|
|
247
|
+
? passage.tags.map(tag => `<span class="tag">${tag}</span>`).join('')
|
|
248
|
+
: '<span class="no-tags">No tags</span>';
|
|
249
|
+
|
|
250
|
+
div.innerHTML = `
|
|
251
|
+
<div class="passage-header">
|
|
252
|
+
<h4 class="passage-name">${this.escapeHtml(passage.name)}</h4>
|
|
253
|
+
<div class="passage-stats">
|
|
254
|
+
<span class="stat">${wordCount} words</span>
|
|
255
|
+
<span class="stat">${characterCount} chars</span>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="passage-tags">${tagsHtml}</div>
|
|
259
|
+
<div class="passage-preview">
|
|
260
|
+
${this.createTextPreview(passage.text)}
|
|
261
|
+
</div>
|
|
262
|
+
`;
|
|
263
|
+
|
|
264
|
+
return div;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
populateTags() {
|
|
268
|
+
const tagsContainer = document.getElementById('tagsContainer');
|
|
269
|
+
const tagStats = this.getTagStatistics();
|
|
270
|
+
|
|
271
|
+
if (tagStats.length === 0) {
|
|
272
|
+
tagsContainer.innerHTML = '<p class="no-data">No tags found in this story.</p>';
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
tagsContainer.innerHTML = '';
|
|
277
|
+
|
|
278
|
+
tagStats.forEach(tagStat => {
|
|
279
|
+
const tagElement = document.createElement('div');
|
|
280
|
+
tagElement.className = 'tag-stat';
|
|
281
|
+
|
|
282
|
+
const color = this.story.tagColors[tagStat.name] || '#666';
|
|
283
|
+
|
|
284
|
+
tagElement.innerHTML = `
|
|
285
|
+
<div class="tag-stat-header">
|
|
286
|
+
<span class="tag-name" style="border-left: 4px solid ${color};">${tagStat.name}</span>
|
|
287
|
+
<span class="tag-count">${tagStat.count} passage${tagStat.count !== 1 ? 's' : ''}</span>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="tag-passages">
|
|
290
|
+
${tagStat.passages.map(p => `<span class="passage-link">${this.escapeHtml(p)}</span>`).join(', ')}
|
|
291
|
+
</div>
|
|
292
|
+
`;
|
|
293
|
+
|
|
294
|
+
tagsContainer.appendChild(tagElement);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
populateMetadata() {
|
|
299
|
+
const metadataContent = document.getElementById('metadataContent');
|
|
300
|
+
|
|
301
|
+
const metadata = {
|
|
302
|
+
'Story Properties': {
|
|
303
|
+
'Name': this.story.name,
|
|
304
|
+
'IFID': this.story.IFID,
|
|
305
|
+
'Start Passage': this.story.start,
|
|
306
|
+
'Format': this.story.format,
|
|
307
|
+
'Format Version': this.story.formatVersion,
|
|
308
|
+
'Creator': this.story.creator,
|
|
309
|
+
'Creator Version': this.story.creatorVersion,
|
|
310
|
+
'Zoom Level': this.story.zoom
|
|
311
|
+
},
|
|
312
|
+
'Statistics': {
|
|
313
|
+
'Total Passages': this.story.passages.length,
|
|
314
|
+
'Total Words': this.countWords(this.story.passages.map(p => p.text).join(' ')),
|
|
315
|
+
'Total Characters': this.story.passages.map(p => p.text).join('').length,
|
|
316
|
+
'Unique Tags': new Set(this.story.passages.flatMap(p => p.tags)).size,
|
|
317
|
+
'Average Words per Passage': Math.round(this.countWords(this.story.passages.map(p => p.text).join(' ')) / this.story.passages.length)
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
if (Object.keys(this.story.metadata).length > 0) {
|
|
322
|
+
metadata['Additional Metadata'] = this.story.metadata;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
metadataContent.innerHTML = '';
|
|
326
|
+
|
|
327
|
+
Object.entries(metadata).forEach(([section, data]) => {
|
|
328
|
+
const sectionDiv = document.createElement('div');
|
|
329
|
+
sectionDiv.className = 'metadata-section';
|
|
330
|
+
|
|
331
|
+
const sectionTitle = document.createElement('h4');
|
|
332
|
+
sectionTitle.textContent = section;
|
|
333
|
+
sectionDiv.appendChild(sectionTitle);
|
|
334
|
+
|
|
335
|
+
const dataList = document.createElement('dl');
|
|
336
|
+
dataList.className = 'metadata-list';
|
|
337
|
+
|
|
338
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
339
|
+
const dt = document.createElement('dt');
|
|
340
|
+
dt.textContent = key;
|
|
341
|
+
const dd = document.createElement('dd');
|
|
342
|
+
dd.textContent = value || 'N/A';
|
|
343
|
+
|
|
344
|
+
dataList.appendChild(dt);
|
|
345
|
+
dataList.appendChild(dd);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
sectionDiv.appendChild(dataList);
|
|
349
|
+
metadataContent.appendChild(sectionDiv);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
getTagStatistics() {
|
|
354
|
+
const tagMap = new Map();
|
|
355
|
+
|
|
356
|
+
this.story.passages.forEach(passage => {
|
|
357
|
+
passage.tags.forEach(tag => {
|
|
358
|
+
if (!tagMap.has(tag)) {
|
|
359
|
+
tagMap.set(tag, []);
|
|
360
|
+
}
|
|
361
|
+
tagMap.get(tag).push(passage.name);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return Array.from(tagMap.entries())
|
|
366
|
+
.map(([name, passages]) => ({
|
|
367
|
+
name,
|
|
368
|
+
count: passages.length,
|
|
369
|
+
passages: passages.sort()
|
|
370
|
+
}))
|
|
371
|
+
.sort((a, b) => b.count - a.count);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
countWords(text) {
|
|
375
|
+
if (!text) return 0;
|
|
376
|
+
// Safely remove HTML tags using DOM parsing for security
|
|
377
|
+
const tempDiv = document.createElement('div');
|
|
378
|
+
tempDiv.innerHTML = text;
|
|
379
|
+
const cleanText = (tempDiv.textContent || tempDiv.innerText || '').trim();
|
|
380
|
+
return cleanText ? cleanText.split(/\s+/).length : 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
getUniqueTags(passages) {
|
|
384
|
+
const tags = new Set();
|
|
385
|
+
passages.forEach(passage => {
|
|
386
|
+
passage.tags.forEach(tag => tags.add(tag));
|
|
387
|
+
});
|
|
388
|
+
return Array.from(tags);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
createTextPreview(text, maxLength = 200) {
|
|
392
|
+
if (!text) return '<em>Empty passage</em>';
|
|
393
|
+
|
|
394
|
+
// Safely remove HTML tags using DOM parsing for security
|
|
395
|
+
const tempDiv = document.createElement('div');
|
|
396
|
+
tempDiv.innerHTML = text;
|
|
397
|
+
const cleanText = tempDiv.textContent || tempDiv.innerText || '';
|
|
398
|
+
const trimmedText = cleanText.trim();
|
|
399
|
+
|
|
400
|
+
if (trimmedText.length <= maxLength) {
|
|
401
|
+
return this.escapeHtml(trimmedText);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return this.escapeHtml(trimmedText.substring(0, maxLength)) + '...';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
filterPassages(searchTerm) {
|
|
408
|
+
const passageItems = document.querySelectorAll('.passage-item');
|
|
409
|
+
const term = searchTerm.toLowerCase();
|
|
410
|
+
|
|
411
|
+
passageItems.forEach(item => {
|
|
412
|
+
const passageName = item.querySelector('.passage-name').textContent.toLowerCase();
|
|
413
|
+
const passageText = item.querySelector('.passage-preview').textContent.toLowerCase();
|
|
414
|
+
const passageTags = Array.from(item.querySelectorAll('.tag')).map(tag => tag.textContent.toLowerCase());
|
|
415
|
+
|
|
416
|
+
const matches = passageName.includes(term) ||
|
|
417
|
+
passageText.includes(term) ||
|
|
418
|
+
passageTags.some(tag => tag.includes(term));
|
|
419
|
+
|
|
420
|
+
item.style.display = matches ? 'block' : 'none';
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
switchTab(tabName) {
|
|
425
|
+
// Update tab buttons
|
|
426
|
+
document.querySelectorAll('.tab-button').forEach(btn => {
|
|
427
|
+
btn.classList.remove('active');
|
|
428
|
+
});
|
|
429
|
+
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
|
430
|
+
|
|
431
|
+
// Update tab content
|
|
432
|
+
document.querySelectorAll('.tab-content').forEach(content => {
|
|
433
|
+
content.classList.remove('active');
|
|
434
|
+
});
|
|
435
|
+
document.getElementById(tabName).classList.add('active');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
showResults() {
|
|
439
|
+
document.getElementById('resultsSection').style.display = 'block';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
hideResults() {
|
|
443
|
+
document.getElementById('resultsSection').style.display = 'none';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
showError(message) {
|
|
447
|
+
document.getElementById('errorText').textContent = message;
|
|
448
|
+
document.getElementById('errorSection').style.display = 'block';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
hideError() {
|
|
452
|
+
document.getElementById('errorSection').style.display = 'none';
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
escapeHtml(text) {
|
|
456
|
+
const div = document.createElement('div');
|
|
457
|
+
div.textContent = text;
|
|
458
|
+
return div.innerHTML;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Initialize the decompiler when the page loads
|
|
463
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
464
|
+
new TwineDecompiler();
|
|
465
|
+
});
|
|
466
|
+
</script>
|
|
467
|
+
</body>
|
|
468
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "extwee",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.8",
|
|
4
4
|
"description": "A story compiler tool using Twine-compatible formats",
|
|
5
5
|
"author": "Dan Cox",
|
|
6
6
|
"main": "index.js",
|
|
@@ -33,36 +33,34 @@
|
|
|
33
33
|
],
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"commander": "^14.0.
|
|
36
|
+
"commander": "^14.0.2",
|
|
37
37
|
"graphemer": "^1.4.0",
|
|
38
38
|
"html-entities": "^2.6.0",
|
|
39
39
|
"node-html-parser": "^7.0.1",
|
|
40
40
|
"pickleparser": "^0.2.1",
|
|
41
41
|
"semver": "^7.7.3",
|
|
42
|
-
"shelljs": "^0.10.0"
|
|
43
|
-
"uuid": "^13.0.0"
|
|
42
|
+
"shelljs": "^0.10.0"
|
|
44
43
|
},
|
|
45
44
|
"devDependencies": {
|
|
46
45
|
"@babel/cli": "^7.28.3",
|
|
47
46
|
"@babel/core": "^7.28.5",
|
|
48
47
|
"@babel/preset-env": "^7.28.5",
|
|
49
|
-
"@eslint/js": "^9.
|
|
50
|
-
"@inquirer/prompts": "^7.
|
|
51
|
-
"@types/node": "^24.
|
|
48
|
+
"@eslint/js": "^9.39.1",
|
|
49
|
+
"@inquirer/prompts": "^7.10.1",
|
|
50
|
+
"@types/node": "^24.10.1",
|
|
52
51
|
"@types/semver": "^7.7.1",
|
|
53
|
-
"@types/uuid": "^11.0.0",
|
|
54
52
|
"babel-loader": "^10.0.0",
|
|
55
53
|
"clean-jsdoc-theme": "^4.3.0",
|
|
56
54
|
"core-js": "^3.46.0",
|
|
57
|
-
"eslint": "^9.
|
|
58
|
-
"eslint-plugin-jest": "^29.0
|
|
59
|
-
"eslint-plugin-jsdoc": "^61.1
|
|
60
|
-
"globals": "^16.
|
|
55
|
+
"eslint": "^9.39.1",
|
|
56
|
+
"eslint-plugin-jest": "^29.1.0",
|
|
57
|
+
"eslint-plugin-jsdoc": "^61.2.1",
|
|
58
|
+
"globals": "^16.5.0",
|
|
61
59
|
"jest": "^30.2.0",
|
|
62
60
|
"jest-environment-jsdom": "^30.2.0",
|
|
63
61
|
"regenerator-runtime": "^0.14.1",
|
|
64
62
|
"typescript": "^5.9.3",
|
|
65
|
-
"typescript-eslint": "^8.46.
|
|
63
|
+
"typescript-eslint": "^8.46.4",
|
|
66
64
|
"webpack": "^5.102.1",
|
|
67
65
|
"webpack-bundle-analyzer": "^4.10.2",
|
|
68
66
|
"webpack-cli": "^6.0.1"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates an Interactive Fiction Identification (IFID) based the Treaty of Babel.
|
|
3
|
+
*
|
|
4
|
+
* For Twine works, the IFID is a UUID (v4) in uppercase.
|
|
5
|
+
* @see Treaty of Babel ({@link https://babel.ifarchive.org/babel_rev11.html#the-ifid-for-an-html-story-file})
|
|
6
|
+
* @function generate
|
|
7
|
+
* @description Generates a new IFID using UUIDv4 (RFC 4122). Browser version using Web Crypto API.
|
|
8
|
+
* @returns {string} IFID - A UUIDv4 string in uppercase format
|
|
9
|
+
* @example
|
|
10
|
+
* const ifid = generate();
|
|
11
|
+
* console.log(ifid);
|
|
12
|
+
* // => 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6'
|
|
13
|
+
*/
|
|
14
|
+
function generate () {
|
|
15
|
+
// Browser crypto.randomUUID() generates RFC 4122 version 4 UUIDs
|
|
16
|
+
// Available in modern browsers (Chrome 92+, Firefox 95+, Safari 15.4+)
|
|
17
|
+
return crypto.randomUUID().toUpperCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { generate };
|
package/src/IFID/generate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Generates an Interactive Fiction Identification (IFID) based the Treaty of Babel.
|
|
@@ -6,15 +6,16 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
6
6
|
* For Twine works, the IFID is a UUID (v4) in uppercase.
|
|
7
7
|
* @see Treaty of Babel ({@link https://babel.ifarchive.org/babel_rev11.html#the-ifid-for-an-html-story-file})
|
|
8
8
|
* @function generate
|
|
9
|
-
* @description Generates a new IFID.
|
|
10
|
-
* @returns {string} IFID
|
|
9
|
+
* @description Generates a new IFID using UUIDv4 (RFC 4122).
|
|
10
|
+
* @returns {string} IFID - A UUIDv4 string in uppercase format
|
|
11
11
|
* @example
|
|
12
12
|
* const ifid = generate();
|
|
13
13
|
* console.log(ifid);
|
|
14
14
|
* // => 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6'
|
|
15
15
|
*/
|
|
16
16
|
function generate () {
|
|
17
|
-
|
|
17
|
+
// crypto.randomUUID() generates RFC 4122 version 4 UUIDs
|
|
18
|
+
return randomUUID().toUpperCase();
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export { generate };
|
package/src/Story.js
CHANGED
|
@@ -9,23 +9,41 @@ class LightweightTwine1Parser {
|
|
|
9
9
|
constructor(html) {
|
|
10
10
|
this.html = html;
|
|
11
11
|
this.doc = null;
|
|
12
|
+
this.usingDOMParser = false;
|
|
12
13
|
|
|
13
14
|
// Parse HTML using browser's native DOMParser if available, otherwise fallback
|
|
14
15
|
if (typeof DOMParser !== 'undefined') {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
try {
|
|
17
|
+
const parser = new DOMParser();
|
|
18
|
+
this.doc = parser.parseFromString(html, 'text/html');
|
|
19
|
+
this.usingDOMParser = true;
|
|
20
|
+
|
|
21
|
+
// Check if parsing was successful (DOMParser doesn't throw errors, but creates error documents)
|
|
22
|
+
const parserError = this.doc.querySelector('parsererror');
|
|
23
|
+
if (parserError) {
|
|
24
|
+
console.warn('DOMParser encountered an error, falling back to regex parsing:', parserError.textContent);
|
|
25
|
+
this.doc = this.createSimpleDOM(html);
|
|
26
|
+
this.usingDOMParser = false;
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.warn('DOMParser failed, falling back to regex parsing:', error.message);
|
|
30
|
+
this.doc = this.createSimpleDOM(html);
|
|
31
|
+
this.usingDOMParser = false;
|
|
32
|
+
}
|
|
17
33
|
} else {
|
|
18
34
|
// Fallback for environments without DOMParser
|
|
19
35
|
this.doc = this.createSimpleDOM(html);
|
|
36
|
+
this.usingDOMParser = false;
|
|
20
37
|
}
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
querySelector(selector) {
|
|
24
|
-
if (this.doc && this.doc.querySelector) {
|
|
41
|
+
if (this.usingDOMParser && this.doc && this.doc.querySelector) {
|
|
42
|
+
// Use native DOM methods when DOMParser is available and working
|
|
25
43
|
return this.doc.querySelector(selector);
|
|
26
44
|
}
|
|
27
45
|
|
|
28
|
-
//
|
|
46
|
+
// Fallback implementation for environments without DOMParser
|
|
29
47
|
if (selector === '#storeArea') {
|
|
30
48
|
const match = this.html.match(/<div[^>]*id=["']storeArea["'][^>]*>/i);
|
|
31
49
|
return match ? { found: true } : null;
|
|
@@ -38,11 +56,31 @@ class LightweightTwine1Parser {
|
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
querySelectorAll(selector) {
|
|
41
|
-
if (this.doc && this.doc.querySelectorAll) {
|
|
42
|
-
|
|
59
|
+
if (this.usingDOMParser && this.doc && this.doc.querySelectorAll) {
|
|
60
|
+
// Use native DOM methods when DOMParser is available and working
|
|
61
|
+
const elements = Array.from(this.doc.querySelectorAll(selector));
|
|
62
|
+
|
|
63
|
+
// Convert DOM elements to expected format for compatibility
|
|
64
|
+
return elements.map(element => {
|
|
65
|
+
const attributes = {};
|
|
66
|
+
|
|
67
|
+
// Extract attributes using DOM methods - much more reliable than regex
|
|
68
|
+
if (element.attributes) {
|
|
69
|
+
for (let i = 0; i < element.attributes.length; i++) {
|
|
70
|
+
const attr = element.attributes[i];
|
|
71
|
+
// DOM automatically handles HTML entity decoding
|
|
72
|
+
attributes[attr.name] = attr.value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
attributes,
|
|
78
|
+
rawText: element.textContent || element.innerText || ''
|
|
79
|
+
};
|
|
80
|
+
});
|
|
43
81
|
}
|
|
44
82
|
|
|
45
|
-
// Fallback implementation for
|
|
83
|
+
// Fallback implementation for environments without DOMParser
|
|
46
84
|
if (selector === '[tiddler]') {
|
|
47
85
|
return this.extractTiddlerElements();
|
|
48
86
|
}
|
|
@@ -111,7 +149,8 @@ class LightweightTwine1Parser {
|
|
|
111
149
|
}
|
|
112
150
|
|
|
113
151
|
createSimpleDOM(html) {
|
|
114
|
-
// Minimal DOM-like object for fallback
|
|
152
|
+
// Minimal DOM-like object for fallback when DOMParser is not available
|
|
153
|
+
// This should only be used in very limited environments
|
|
115
154
|
return {
|
|
116
155
|
querySelector: (selector) => {
|
|
117
156
|
if (selector === '#storeArea' && html.includes('id="storeArea"')) {
|