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.
@@ -8,23 +8,48 @@ class LightweightTwine2ArchiveParser {
8
8
  constructor(html) {
9
9
  this.html = html;
10
10
  this.doc = null;
11
+ this.usingDOMParser = false;
11
12
 
12
13
  // Parse HTML using browser's native DOMParser if available, otherwise fallback
13
14
  if (typeof DOMParser !== 'undefined') {
14
- const parser = new DOMParser();
15
- this.doc = parser.parseFromString(html, 'text/html');
15
+ try {
16
+ const parser = new DOMParser();
17
+ this.doc = parser.parseFromString(html, 'text/html');
18
+ this.usingDOMParser = true;
19
+
20
+ // Check if parsing was successful (DOMParser doesn't throw errors, but creates error documents)
21
+ const parserError = this.doc.querySelector('parsererror');
22
+ if (parserError) {
23
+ console.warn('DOMParser encountered an error, falling back to regex parsing:', parserError.textContent);
24
+ this.doc = this.createSimpleDOM(html);
25
+ this.usingDOMParser = false;
26
+ }
27
+ } catch (error) {
28
+ console.warn('DOMParser failed, falling back to regex parsing:', error.message);
29
+ this.doc = this.createSimpleDOM(html);
30
+ this.usingDOMParser = false;
31
+ }
16
32
  } else {
17
33
  // Fallback for environments without DOMParser
18
34
  this.doc = this.createSimpleDOM(html);
35
+ this.usingDOMParser = false;
19
36
  }
20
37
  }
21
38
 
22
39
  getElementsByTagName(tagName) {
23
- if (this.doc && this.doc.getElementsByTagName) {
24
- return Array.from(this.doc.getElementsByTagName(tagName));
40
+ if (this.usingDOMParser && this.doc && this.doc.getElementsByTagName) {
41
+ // Use native DOM methods when DOMParser is available and working
42
+ const elements = Array.from(this.doc.getElementsByTagName(tagName));
43
+
44
+ // Convert DOM elements to expected format for compatibility
45
+ return elements.map(element => ({
46
+ outerHTML: element.outerHTML,
47
+ // For compatibility with the original parser interface
48
+ toString: () => element.outerHTML
49
+ }));
25
50
  }
26
51
 
27
- // Fallback implementation for tw-storydata elements
52
+ // Fallback implementation for environments without DOMParser
28
53
  if (tagName === 'tw-storydata') {
29
54
  return this.extractStoryDataElements();
30
55
  }
@@ -52,11 +77,12 @@ class LightweightTwine2ArchiveParser {
52
77
 
53
78
  // eslint-disable-next-line no-unused-vars
54
79
  createSimpleDOM(_htmlContent) {
55
- // Minimal DOM-like object for fallback
80
+ // Minimal DOM-like object for fallback when DOMParser is not available
81
+ // This should only be used in very limited environments
56
82
  return {
57
83
  getElementsByTagName: (tagName) => {
58
84
  if (tagName === 'tw-storydata') {
59
- return this.extractStoryDataElements(this.htmlContent);
85
+ return this.extractStoryDataElements();
60
86
  }
61
87
  return [];
62
88
  }
@@ -10,23 +10,61 @@ class LightweightTwine2Parser {
10
10
  constructor(html) {
11
11
  this.html = html;
12
12
  this.doc = null;
13
+ this.usingDOMParser = false;
13
14
 
14
15
  // Parse HTML using browser's native DOMParser if available, otherwise fallback
15
16
  if (typeof DOMParser !== 'undefined') {
16
- const parser = new DOMParser();
17
- this.doc = parser.parseFromString(html, 'text/html');
17
+ try {
18
+ const parser = new DOMParser();
19
+ this.doc = parser.parseFromString(html, 'text/html');
20
+ this.usingDOMParser = true;
21
+
22
+ // Check if parsing was successful (DOMParser doesn't throw errors, but creates error documents)
23
+ const parserError = this.doc.querySelector('parsererror');
24
+ if (parserError) {
25
+ console.warn('DOMParser encountered an error, falling back to regex parsing:', parserError.textContent);
26
+ this.doc = this.createSimpleDOM(html);
27
+ this.usingDOMParser = false;
28
+ }
29
+ } catch (error) {
30
+ console.warn('DOMParser failed, falling back to regex parsing:', error.message);
31
+ this.doc = this.createSimpleDOM(html);
32
+ this.usingDOMParser = false;
33
+ }
18
34
  } else {
19
35
  // Fallback for environments without DOMParser
20
36
  this.doc = this.createSimpleDOM(html);
37
+ this.usingDOMParser = false;
21
38
  }
22
39
  }
23
40
 
24
41
  getElementsByTagName(tagName) {
25
- if (this.doc && this.doc.getElementsByTagName) {
26
- return Array.from(this.doc.getElementsByTagName(tagName));
42
+ if (this.usingDOMParser && this.doc && this.doc.getElementsByTagName) {
43
+ // Use native DOM methods when DOMParser is available and working
44
+ const elements = Array.from(this.doc.getElementsByTagName(tagName));
45
+
46
+ // Convert DOM elements to our expected format
47
+ return elements.map(element => {
48
+ const attributes = {};
49
+
50
+ // Extract attributes using DOM methods - much more reliable than regex
51
+ if (element.attributes) {
52
+ for (let i = 0; i < element.attributes.length; i++) {
53
+ const attr = element.attributes[i];
54
+ // DOM automatically handles HTML entity decoding
55
+ attributes[attr.name] = attr.value;
56
+ }
57
+ }
58
+
59
+ return {
60
+ attributes,
61
+ innerHTML: element.innerHTML || '',
62
+ rawText: element.textContent || element.innerText || ''
63
+ };
64
+ });
27
65
  }
28
66
 
29
- // Fallback implementation
67
+ // Fallback implementation for environments without DOMParser or when DOM parsing fails
30
68
  if (tagName === 'tw-storydata') {
31
69
  return this.extractStoryDataElements();
32
70
  }
@@ -107,12 +145,49 @@ class LightweightTwine2Parser {
107
145
 
108
146
  const openingTag = openingTagMatch[0];
109
147
 
110
- // Common attribute patterns
111
- const attributeRegex = /(\w+(?:-\w+)*)=["']([^"']*)["']/g;
148
+ // Enhanced attribute parsing to handle multiple formats:
149
+ // 1. Quoted attributes: name="value" or name='value'
150
+ // 2. Unquoted attributes: name=value
151
+ // 3. Boolean attributes: hidden, selected, etc.
152
+
153
+ // First, handle quoted attributes (including those with escaped quotes)
154
+ const quotedAttributeRegex = /(\w+(?:-\w+)*)=["']([^"']*)["']/g;
112
155
  let match;
113
156
 
114
- while ((match = attributeRegex.exec(openingTag)) !== null) {
115
- attributes[match[1]] = match[2];
157
+ while ((match = quotedAttributeRegex.exec(openingTag)) !== null) {
158
+ // Decode basic HTML entities in attribute values
159
+ const value = match[2]
160
+ .replace(/&quot;/g, '"')
161
+ .replace(/&#39;/g, "'")
162
+ .replace(/&lt;/g, '<')
163
+ .replace(/&gt;/g, '>')
164
+ .replace(/&amp;/g, '&'); // This should be last
165
+
166
+ attributes[match[1]] = value;
167
+ }
168
+
169
+ // Handle unquoted attributes (but avoid matching already processed quoted ones)
170
+ let tagWithoutQuoted = openingTag;
171
+ const quotedMatches = [...openingTag.matchAll(quotedAttributeRegex)];
172
+ quotedMatches.forEach(quotedMatch => {
173
+ tagWithoutQuoted = tagWithoutQuoted.replace(quotedMatch[0], '');
174
+ });
175
+
176
+ const unquotedAttributeRegex = /(\w+(?:-\w+)*)=([^\s>]+)/g;
177
+ while ((match = unquotedAttributeRegex.exec(tagWithoutQuoted)) !== null) {
178
+ if (!attributes[match[1]]) { // Don't overwrite quoted attributes
179
+ attributes[match[1]] = match[2];
180
+ }
181
+ }
182
+
183
+ // Handle boolean attributes (attributes without values)
184
+ const booleanAttributeRegex = /\s(\w+(?:-\w+)*)(?=\s|>|$)/g;
185
+ while ((match = booleanAttributeRegex.exec(openingTag)) !== null) {
186
+ const attrName = match[1];
187
+ // Only add if it's not already parsed as a key=value attribute and not the tag name
188
+ if (!attributes[attrName] && !openingTag.includes(`${attrName}=`) && attrName !== openingTag.match(/<(\w+)/)?.[1]) {
189
+ attributes[attrName] = true;
190
+ }
116
191
  }
117
192
 
118
193
  return attributes;
@@ -132,9 +207,11 @@ class LightweightTwine2Parser {
132
207
 
133
208
  // eslint-disable-next-line no-unused-vars
134
209
  createSimpleDOM(_html) {
135
- // Minimal DOM-like object for fallback
210
+ // Minimal DOM-like object for fallback when DOMParser is not available
211
+ // This should only be used in very limited environments (like some older Node.js versions)
136
212
  return {
137
213
  getElementsByTagName: (tagName) => {
214
+ // Use regex-based extraction as fallback
138
215
  if (tagName === 'tw-storydata') {
139
216
  return this.extractStoryDataElements();
140
217
  }
@@ -201,8 +278,14 @@ function parse(content) {
201
278
  * The name of the story.
202
279
  */
203
280
  if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'name')) {
204
- // Set the story name
205
- story.name = storyData.attributes.name;
281
+ // Validate that the name is a non-empty string before setting
282
+ const nameValue = storyData.attributes.name;
283
+ if (typeof nameValue === 'string' && nameValue.trim().length > 0) {
284
+ story.name = nameValue.trim();
285
+ } else {
286
+ console.warn('Warning: The name attribute is empty or invalid on tw-storydata!');
287
+ // Keep the default name from Story constructor
288
+ }
206
289
  } else {
207
290
  // Name is a required field. Warn user.
208
291
  console.warn('Warning: The name attribute is missing from tw-storydata!');
@@ -215,15 +298,20 @@ function parse(content) {
215
298
  * hyphen that uniquely identify a story (see Treaty of Babel).
216
299
  */
217
300
  if (Object.prototype.hasOwnProperty.call(storyData.attributes, 'ifid')) {
218
- // Update story IFID.
219
- story.IFID = storyData.attributes.ifid;
301
+ // Validate that the IFID is a non-empty string before setting
302
+ const ifidValue = storyData.attributes.ifid;
303
+ if (typeof ifidValue === 'string' && ifidValue.trim().length > 0) {
304
+ story.IFID = ifidValue.trim();
305
+ } else {
306
+ console.warn('Warning: The ifid attribute is empty or invalid on tw-storydata!');
307
+ }
220
308
  } else {
221
- // Name is a required filed. Warn user.
309
+ // IFID is a required field. Warn user.
222
310
  console.warn('Warning: The ifid attribute is missing from tw-storydata!');
223
311
  }
224
312
 
225
- // Check if the IFID has valid formatting.
226
- if (story.IFID.match(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/) === null) {
313
+ // Check if the IFID has valid formatting (only if IFID was set).
314
+ if (story.IFID && story.IFID.match(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/) === null) {
227
315
  // IFID is not valid.
228
316
  console.warn('Warning: The IFID is not in valid UUIDv4 formatting on tw-storydata!');
229
317
  }
@@ -312,7 +312,7 @@ describe('Twine1HTML', function () {
312
312
  const el = '<div id="storeArea"><div tiddler="Test" modifier="twee">Content</div></div>';
313
313
  const s = parseTwine1HTMLWeb(el);
314
314
  expect(s.size()).toBe(1);
315
- expect(s.creator).toBe('twee');
315
+ expect(s.creator).toBe('extwee');
316
316
  } finally {
317
317
  global.DOMParser = originalDOMParser;
318
318
  }
@@ -128,7 +128,7 @@ describe('Twine2HTML', function () {
128
128
 
129
129
  const content = '<tw-storydata name="Test Story"><tw-passagedata pid="1" name="Start">Content</tw-passagedata></tw-storydata>';
130
130
  parseTwine2HTMLWeb(content);
131
- expect(warningMessage).toBe('Warning: The IFID is not in valid UUIDv4 formatting on tw-storydata!');
131
+ expect(warningMessage).toBe('Warning: The ifid attribute is missing from tw-storydata!');
132
132
  });
133
133
 
134
134
  it('Should warn for malformed IFID', function () {
@@ -201,7 +201,7 @@ describe('Twine2HTML', function () {
201
201
  });
202
202
 
203
203
  it('Should handle quoted empty tags', function () {
204
- const content = '<tw-storydata name="Test" ifid="12345678-1234-1234-1234-123456789012"><tw-passagedata pid="1" name="Start" tags=\\"\\"\\" >Content</tw-passagedata></tw-storydata>';
204
+ const content = '<tw-storydata name="Test" ifid="12345678-1234-1234-1234-123456789012"><tw-passagedata pid="1" name="Start" tags=\'""\'">Content</tw-passagedata></tw-storydata>';
205
205
 
206
206
  const story = parseTwine2HTMLWeb(content);
207
207
  const passage = story.getPassageByName('Start');
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * Tests for package.json web exports
7
+ * These tests verify that users can import the web builds directly
8
+ */
9
+
10
+ import { describe, expect, it } from '@jest/globals';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ describe('Web build exports', () => {
15
+ const buildDir = path.resolve(process.cwd(), 'build');
16
+
17
+ describe('Web build files exist', () => {
18
+ it('should have core web build file', () => {
19
+ const corePath = path.join(buildDir, 'extwee.core.min.js');
20
+ expect(fs.existsSync(corePath)).toBe(true);
21
+ });
22
+
23
+ it('should have twine1html web build file', () => {
24
+ const twine1Path = path.join(buildDir, 'extwee.twine1html.min.js');
25
+ expect(fs.existsSync(twine1Path)).toBe(true);
26
+ });
27
+
28
+ it('should have twine2archive web build file', () => {
29
+ const twine2ArchivePath = path.join(buildDir, 'extwee.twine2archive.min.js');
30
+ expect(fs.existsSync(twine2ArchivePath)).toBe(true);
31
+ });
32
+
33
+ it('should have tws web build file', () => {
34
+ const twsPath = path.join(buildDir, 'extwee.tws.min.js');
35
+ expect(fs.existsSync(twsPath)).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe('Web build content validation', () => {
40
+ it('should have UMD wrapper in core build', () => {
41
+ const corePath = path.join(buildDir, 'extwee.core.min.js');
42
+ const content = fs.readFileSync(corePath, 'utf8');
43
+
44
+ // Should contain UMD pattern
45
+ expect(content).toMatch(/function.*webpackUniversalModuleDefinition|define.*function|module\.exports.*function/);
46
+
47
+ // Should be minified (no excessive whitespace)
48
+ expect(content.split('\n').length).toBeLessThan(10);
49
+ });
50
+
51
+ it('should have Extwee functionality in core build', () => {
52
+ const corePath = path.join(buildDir, 'extwee.core.min.js');
53
+ const content = fs.readFileSync(corePath, 'utf8');
54
+
55
+ // Should contain core Extwee functionality
56
+ expect(content).toMatch(/parseTwee|parseJSON|generateIFID|Story/);
57
+ });
58
+
59
+ it('should have appropriate size for minified builds', () => {
60
+ const corePath = path.join(buildDir, 'extwee.core.min.js');
61
+ const stats = fs.statSync(corePath);
62
+
63
+ // Core build should be substantial but not excessive (roughly 50-150KB)
64
+ expect(stats.size).toBeGreaterThan(50000); // > 50KB
65
+ expect(stats.size).toBeLessThan(150000); // < 150KB
66
+ });
67
+
68
+ it('should have smaller specialized builds', () => {
69
+ const corePath = path.join(buildDir, 'extwee.core.min.js');
70
+ const twine1Path = path.join(buildDir, 'extwee.twine1html.min.js');
71
+ const coreStats = fs.statSync(corePath);
72
+ const twine1Stats = fs.statSync(twine1Path);
73
+
74
+ // Specialized builds should be smaller than core
75
+ expect(twine1Stats.size).toBeLessThan(coreStats.size);
76
+ });
77
+ });
78
+
79
+ describe('Package.json exports configuration', () => {
80
+ it('should have exports field in package.json', () => {
81
+ const packagePath = path.resolve(process.cwd(), 'package.json');
82
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
83
+
84
+ expect(packageJson.exports).toBeDefined();
85
+ expect(typeof packageJson.exports).toBe('object');
86
+ });
87
+
88
+ it('should have correct web export paths', () => {
89
+ const packagePath = path.resolve(process.cwd(), 'package.json');
90
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
91
+
92
+ expect(packageJson.exports['.']).toBe('./index.js');
93
+ expect(packageJson.exports['./web']).toBe('./build/extwee.core.min.js');
94
+ expect(packageJson.exports['./web/core']).toBe('./build/extwee.core.min.js');
95
+ expect(packageJson.exports['./web/twine1html']).toBe('./build/extwee.twine1html.min.js');
96
+ expect(packageJson.exports['./web/twine2archive']).toBe('./build/extwee.twine2archive.min.js');
97
+ expect(packageJson.exports['./web/tws']).toBe('./build/extwee.tws.min.js');
98
+ });
99
+
100
+ it('should point to files that actually exist', () => {
101
+ const packagePath = path.resolve(process.cwd(), 'package.json');
102
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
103
+
104
+ // Check each web export path exists
105
+ const webExports = [
106
+ packageJson.exports['./web'],
107
+ packageJson.exports['./web/core'],
108
+ packageJson.exports['./web/twine1html'],
109
+ packageJson.exports['./web/twine2archive'],
110
+ packageJson.exports['./web/tws']
111
+ ];
112
+
113
+ for (const exportPath of webExports) {
114
+ const fullPath = path.resolve(process.cwd(), exportPath);
115
+ expect(fs.existsSync(fullPath)).toBe(true);
116
+ }
117
+ });
118
+ });
119
+
120
+ describe('Documentation for web exports', () => {
121
+ it('should provide clear usage examples', () => {
122
+ // This test documents how users can import the web builds
123
+ const usageExamples = {
124
+ 'import Extwee from "extwee/web"': 'Core web build with most functionality',
125
+ 'import Extwee from "extwee/web/core"': 'Same as above (explicit)',
126
+ 'import Extwee from "extwee/web/twine1html"': 'Twine 1 HTML parsing only',
127
+ 'import Extwee from "extwee/web/twine2archive"': 'Twine 2 Archive HTML parsing only',
128
+ 'import Extwee from "extwee/web/tws"': 'TWS (Twine 1 workspace) parsing only'
129
+ };
130
+
131
+ // Just verify we have documented the usage patterns
132
+ expect(Object.keys(usageExamples)).toHaveLength(5);
133
+ expect(usageExamples['import Extwee from "extwee/web"']).toContain('Core web build');
134
+ });
135
+ });
136
+ });
@@ -251,5 +251,5 @@ export class Story {
251
251
  #private;
252
252
  }
253
253
  export const creatorName: "extwee";
254
- export const creatorVersion: "2.3.5";
254
+ export const creatorVersion: "2.3.7";
255
255
  import Passage from './Passage.js';