extwee 2.3.5 → 2.3.7
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/README.md +31 -0
- 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/README.md +33 -0
- 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 +462 -0
- package/package.json +17 -10
- 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/test/Web/web-exports.test.js +136 -0
- package/types/src/Story.d.ts +1 -1
|
@@ -0,0 +1,462 @@
|
|
|
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
|
+
fileInfo.innerHTML = `
|
|
193
|
+
<strong>File:</strong> ${file.name} (${fileSize} KB)<br>
|
|
194
|
+
<strong>Last Modified:</strong> ${new Date(file.lastModified).toLocaleString()}
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
displayResults() {
|
|
199
|
+
this.showResults();
|
|
200
|
+
this.populateOverview();
|
|
201
|
+
this.populatePassages();
|
|
202
|
+
this.populateTags();
|
|
203
|
+
this.populateMetadata();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
populateOverview() {
|
|
207
|
+
const passages = this.story.passages;
|
|
208
|
+
const allText = passages.map(p => p.text).join(' ');
|
|
209
|
+
const wordCount = this.countWords(allText);
|
|
210
|
+
const characterCount = allText.length;
|
|
211
|
+
const uniqueTags = this.getUniqueTags(passages);
|
|
212
|
+
|
|
213
|
+
document.getElementById('passageCount').textContent = passages.length;
|
|
214
|
+
document.getElementById('wordCount').textContent = wordCount.toLocaleString();
|
|
215
|
+
document.getElementById('characterCount').textContent = characterCount.toLocaleString();
|
|
216
|
+
document.getElementById('tagCount').textContent = uniqueTags.length;
|
|
217
|
+
|
|
218
|
+
document.getElementById('storyTitle').textContent = this.story.name || 'Untitled';
|
|
219
|
+
document.getElementById('storyIFID').textContent = this.story.IFID || 'None';
|
|
220
|
+
document.getElementById('storyFormat').textContent = this.story.format || 'Unknown';
|
|
221
|
+
document.getElementById('storyFormatVersion').textContent = this.story.formatVersion || 'Unknown';
|
|
222
|
+
document.getElementById('startPassage').textContent = this.story.start || 'None';
|
|
223
|
+
document.getElementById('storyCreator').textContent = this.story.creator || 'Unknown';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
populatePassages() {
|
|
227
|
+
const passagesList = document.getElementById('passagesList');
|
|
228
|
+
const passageListCount = document.getElementById('passageListCount');
|
|
229
|
+
|
|
230
|
+
passageListCount.textContent = this.story.passages.length;
|
|
231
|
+
passagesList.innerHTML = '';
|
|
232
|
+
|
|
233
|
+
this.story.passages.forEach((passage, index) => {
|
|
234
|
+
const passageElement = this.createPassageElement(passage, index);
|
|
235
|
+
passagesList.appendChild(passageElement);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
createPassageElement(passage, index) {
|
|
240
|
+
const div = document.createElement('div');
|
|
241
|
+
div.className = 'passage-item';
|
|
242
|
+
|
|
243
|
+
const wordCount = this.countWords(passage.text);
|
|
244
|
+
const characterCount = passage.text.length;
|
|
245
|
+
const tagsHtml = passage.tags.length > 0
|
|
246
|
+
? passage.tags.map(tag => `<span class="tag">${tag}</span>`).join('')
|
|
247
|
+
: '<span class="no-tags">No tags</span>';
|
|
248
|
+
|
|
249
|
+
div.innerHTML = `
|
|
250
|
+
<div class="passage-header">
|
|
251
|
+
<h4 class="passage-name">${this.escapeHtml(passage.name)}</h4>
|
|
252
|
+
<div class="passage-stats">
|
|
253
|
+
<span class="stat">${wordCount} words</span>
|
|
254
|
+
<span class="stat">${characterCount} chars</span>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
<div class="passage-tags">${tagsHtml}</div>
|
|
258
|
+
<div class="passage-preview">
|
|
259
|
+
${this.createTextPreview(passage.text)}
|
|
260
|
+
</div>
|
|
261
|
+
`;
|
|
262
|
+
|
|
263
|
+
return div;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
populateTags() {
|
|
267
|
+
const tagsContainer = document.getElementById('tagsContainer');
|
|
268
|
+
const tagStats = this.getTagStatistics();
|
|
269
|
+
|
|
270
|
+
if (tagStats.length === 0) {
|
|
271
|
+
tagsContainer.innerHTML = '<p class="no-data">No tags found in this story.</p>';
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
tagsContainer.innerHTML = '';
|
|
276
|
+
|
|
277
|
+
tagStats.forEach(tagStat => {
|
|
278
|
+
const tagElement = document.createElement('div');
|
|
279
|
+
tagElement.className = 'tag-stat';
|
|
280
|
+
|
|
281
|
+
const color = this.story.tagColors[tagStat.name] || '#666';
|
|
282
|
+
|
|
283
|
+
tagElement.innerHTML = `
|
|
284
|
+
<div class="tag-stat-header">
|
|
285
|
+
<span class="tag-name" style="border-left: 4px solid ${color};">${tagStat.name}</span>
|
|
286
|
+
<span class="tag-count">${tagStat.count} passage${tagStat.count !== 1 ? 's' : ''}</span>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="tag-passages">
|
|
289
|
+
${tagStat.passages.map(p => `<span class="passage-link">${this.escapeHtml(p)}</span>`).join(', ')}
|
|
290
|
+
</div>
|
|
291
|
+
`;
|
|
292
|
+
|
|
293
|
+
tagsContainer.appendChild(tagElement);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
populateMetadata() {
|
|
298
|
+
const metadataContent = document.getElementById('metadataContent');
|
|
299
|
+
|
|
300
|
+
const metadata = {
|
|
301
|
+
'Story Properties': {
|
|
302
|
+
'Name': this.story.name,
|
|
303
|
+
'IFID': this.story.IFID,
|
|
304
|
+
'Start Passage': this.story.start,
|
|
305
|
+
'Format': this.story.format,
|
|
306
|
+
'Format Version': this.story.formatVersion,
|
|
307
|
+
'Creator': this.story.creator,
|
|
308
|
+
'Creator Version': this.story.creatorVersion,
|
|
309
|
+
'Zoom Level': this.story.zoom
|
|
310
|
+
},
|
|
311
|
+
'Statistics': {
|
|
312
|
+
'Total Passages': this.story.passages.length,
|
|
313
|
+
'Total Words': this.countWords(this.story.passages.map(p => p.text).join(' ')),
|
|
314
|
+
'Total Characters': this.story.passages.map(p => p.text).join('').length,
|
|
315
|
+
'Unique Tags': new Set(this.story.passages.flatMap(p => p.tags)).size,
|
|
316
|
+
'Average Words per Passage': Math.round(this.countWords(this.story.passages.map(p => p.text).join(' ')) / this.story.passages.length)
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (Object.keys(this.story.metadata).length > 0) {
|
|
321
|
+
metadata['Additional Metadata'] = this.story.metadata;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
metadataContent.innerHTML = '';
|
|
325
|
+
|
|
326
|
+
Object.entries(metadata).forEach(([section, data]) => {
|
|
327
|
+
const sectionDiv = document.createElement('div');
|
|
328
|
+
sectionDiv.className = 'metadata-section';
|
|
329
|
+
|
|
330
|
+
const sectionTitle = document.createElement('h4');
|
|
331
|
+
sectionTitle.textContent = section;
|
|
332
|
+
sectionDiv.appendChild(sectionTitle);
|
|
333
|
+
|
|
334
|
+
const dataList = document.createElement('dl');
|
|
335
|
+
dataList.className = 'metadata-list';
|
|
336
|
+
|
|
337
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
338
|
+
const dt = document.createElement('dt');
|
|
339
|
+
dt.textContent = key;
|
|
340
|
+
const dd = document.createElement('dd');
|
|
341
|
+
dd.textContent = value || 'N/A';
|
|
342
|
+
|
|
343
|
+
dataList.appendChild(dt);
|
|
344
|
+
dataList.appendChild(dd);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
sectionDiv.appendChild(dataList);
|
|
348
|
+
metadataContent.appendChild(sectionDiv);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
getTagStatistics() {
|
|
353
|
+
const tagMap = new Map();
|
|
354
|
+
|
|
355
|
+
this.story.passages.forEach(passage => {
|
|
356
|
+
passage.tags.forEach(tag => {
|
|
357
|
+
if (!tagMap.has(tag)) {
|
|
358
|
+
tagMap.set(tag, []);
|
|
359
|
+
}
|
|
360
|
+
tagMap.get(tag).push(passage.name);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
return Array.from(tagMap.entries())
|
|
365
|
+
.map(([name, passages]) => ({
|
|
366
|
+
name,
|
|
367
|
+
count: passages.length,
|
|
368
|
+
passages: passages.sort()
|
|
369
|
+
}))
|
|
370
|
+
.sort((a, b) => b.count - a.count);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
countWords(text) {
|
|
374
|
+
if (!text) return 0;
|
|
375
|
+
// Remove HTML tags and count words
|
|
376
|
+
const cleanText = text.replace(/<[^>]*>/g, '').trim();
|
|
377
|
+
return cleanText ? cleanText.split(/\s+/).length : 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
getUniqueTags(passages) {
|
|
381
|
+
const tags = new Set();
|
|
382
|
+
passages.forEach(passage => {
|
|
383
|
+
passage.tags.forEach(tag => tags.add(tag));
|
|
384
|
+
});
|
|
385
|
+
return Array.from(tags);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
createTextPreview(text, maxLength = 200) {
|
|
389
|
+
if (!text) return '<em>Empty passage</em>';
|
|
390
|
+
|
|
391
|
+
// Remove HTML tags for preview
|
|
392
|
+
const cleanText = text.replace(/<[^>]*>/g, '').trim();
|
|
393
|
+
|
|
394
|
+
if (cleanText.length <= maxLength) {
|
|
395
|
+
return this.escapeHtml(cleanText);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return this.escapeHtml(cleanText.substring(0, maxLength)) + '...';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
filterPassages(searchTerm) {
|
|
402
|
+
const passageItems = document.querySelectorAll('.passage-item');
|
|
403
|
+
const term = searchTerm.toLowerCase();
|
|
404
|
+
|
|
405
|
+
passageItems.forEach(item => {
|
|
406
|
+
const passageName = item.querySelector('.passage-name').textContent.toLowerCase();
|
|
407
|
+
const passageText = item.querySelector('.passage-preview').textContent.toLowerCase();
|
|
408
|
+
const passageTags = Array.from(item.querySelectorAll('.tag')).map(tag => tag.textContent.toLowerCase());
|
|
409
|
+
|
|
410
|
+
const matches = passageName.includes(term) ||
|
|
411
|
+
passageText.includes(term) ||
|
|
412
|
+
passageTags.some(tag => tag.includes(term));
|
|
413
|
+
|
|
414
|
+
item.style.display = matches ? 'block' : 'none';
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
switchTab(tabName) {
|
|
419
|
+
// Update tab buttons
|
|
420
|
+
document.querySelectorAll('.tab-button').forEach(btn => {
|
|
421
|
+
btn.classList.remove('active');
|
|
422
|
+
});
|
|
423
|
+
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
|
424
|
+
|
|
425
|
+
// Update tab content
|
|
426
|
+
document.querySelectorAll('.tab-content').forEach(content => {
|
|
427
|
+
content.classList.remove('active');
|
|
428
|
+
});
|
|
429
|
+
document.getElementById(tabName).classList.add('active');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
showResults() {
|
|
433
|
+
document.getElementById('resultsSection').style.display = 'block';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
hideResults() {
|
|
437
|
+
document.getElementById('resultsSection').style.display = 'none';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
showError(message) {
|
|
441
|
+
document.getElementById('errorText').textContent = message;
|
|
442
|
+
document.getElementById('errorSection').style.display = 'block';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
hideError() {
|
|
446
|
+
document.getElementById('errorSection').style.display = 'none';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
escapeHtml(text) {
|
|
450
|
+
const div = document.createElement('div');
|
|
451
|
+
div.textContent = text;
|
|
452
|
+
return div.innerHTML;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Initialize the decompiler when the page loads
|
|
457
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
458
|
+
new TwineDecompiler();
|
|
459
|
+
});
|
|
460
|
+
</script>
|
|
461
|
+
</body>
|
|
462
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "extwee",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.7",
|
|
4
4
|
"description": "A story compiler tool using Twine-compatible formats",
|
|
5
5
|
"author": "Dan Cox",
|
|
6
6
|
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./web": "./build/extwee.core.min.js",
|
|
10
|
+
"./web/core": "./build/extwee.core.min.js",
|
|
11
|
+
"./web/twine1html": "./build/extwee.twine1html.min.js",
|
|
12
|
+
"./web/twine2archive": "./build/extwee.twine2archive.min.js",
|
|
13
|
+
"./web/tws": "./build/extwee.tws.min.js"
|
|
14
|
+
},
|
|
7
15
|
"bin": {
|
|
8
16
|
"extwee": "src/extwee.js"
|
|
9
17
|
},
|
|
@@ -25,7 +33,7 @@
|
|
|
25
33
|
],
|
|
26
34
|
"license": "MIT",
|
|
27
35
|
"dependencies": {
|
|
28
|
-
"commander": "^14.0.
|
|
36
|
+
"commander": "^14.0.2",
|
|
29
37
|
"graphemer": "^1.4.0",
|
|
30
38
|
"html-entities": "^2.6.0",
|
|
31
39
|
"node-html-parser": "^7.0.1",
|
|
@@ -36,25 +44,24 @@
|
|
|
36
44
|
},
|
|
37
45
|
"devDependencies": {
|
|
38
46
|
"@babel/cli": "^7.28.3",
|
|
39
|
-
"@babel/core": "^7.28.
|
|
40
|
-
"@babel/preset-env": "^7.28.
|
|
41
|
-
"@eslint/js": "^9.
|
|
47
|
+
"@babel/core": "^7.28.5",
|
|
48
|
+
"@babel/preset-env": "^7.28.5",
|
|
49
|
+
"@eslint/js": "^9.38.0",
|
|
42
50
|
"@inquirer/prompts": "^7.9.0",
|
|
43
|
-
"@types/node": "^24.
|
|
51
|
+
"@types/node": "^24.9.1",
|
|
44
52
|
"@types/semver": "^7.7.1",
|
|
45
|
-
"@types/uuid": "^11.0.0",
|
|
46
53
|
"babel-loader": "^10.0.0",
|
|
47
54
|
"clean-jsdoc-theme": "^4.3.0",
|
|
48
55
|
"core-js": "^3.46.0",
|
|
49
|
-
"eslint": "^9.
|
|
56
|
+
"eslint": "^9.38.0",
|
|
50
57
|
"eslint-plugin-jest": "^29.0.1",
|
|
51
|
-
"eslint-plugin-jsdoc": "^61.1.
|
|
58
|
+
"eslint-plugin-jsdoc": "^61.1.8",
|
|
52
59
|
"globals": "^16.4.0",
|
|
53
60
|
"jest": "^30.2.0",
|
|
54
61
|
"jest-environment-jsdom": "^30.2.0",
|
|
55
62
|
"regenerator-runtime": "^0.14.1",
|
|
56
63
|
"typescript": "^5.9.3",
|
|
57
|
-
"typescript-eslint": "^8.46.
|
|
64
|
+
"typescript-eslint": "^8.46.2",
|
|
58
65
|
"webpack": "^5.102.1",
|
|
59
66
|
"webpack-bundle-analyzer": "^4.10.2",
|
|
60
67
|
"webpack-cli": "^6.0.1"
|
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"')) {
|