├── docs ├── .nojekyll ├── formats │ ├── tws.md │ ├── twee.md │ ├── twine2ArchiveHTML.md │ ├── twine2HTML.md │ ├── twine1HTML.md │ └── json.md ├── install │ ├── npm.md │ └── npx.md ├── _sidebar.md ├── examples │ ├── jsonToTwee.md │ ├── twsToTwee.md │ └── dynamicPassages.md ├── index.html ├── objects │ ├── passage.md │ ├── storyformat.md │ └── story.md └── demos │ └── compiler │ └── index.css ├── test ├── CLI │ ├── files │ │ ├── output │ │ │ └── test.twee │ │ ├── example.json │ │ ├── test.twee │ │ ├── tweeExample.twee │ │ ├── example6.twee │ │ ├── twineExample.html │ │ └── twine1 │ │ │ └── LICENSE.txt │ └── CLI.test.js ├── Config │ ├── files │ │ ├── invalid.json │ │ ├── empty.json │ │ ├── valid.json │ │ └── full.json │ ├── isDirectory.test.js │ ├── isFile.test.js │ ├── readDirectories.test.js │ └── Config.test.js ├── Twine1HTML │ └── Twine1HTMLCompiler │ │ ├── test.html │ │ ├── engineTest.html │ │ ├── test1.html │ │ ├── test2.html │ │ └── jonah-1.4.2 │ │ └── LICENSE ├── Twee │ └── TweeParser │ │ ├── emptytags.twee │ │ ├── noTitle.twee │ │ ├── start.twee │ │ ├── pasagemetadataerror.twee │ │ ├── malformed.twee │ │ ├── multipletags.twee │ │ ├── style.twee │ │ ├── singletag.twee │ │ ├── stylePassage.twee │ │ ├── scriptPassage.twee │ │ ├── multipleScriptPassages.twee │ │ ├── multipleStyleTag.twee │ │ ├── cursed.twee │ │ ├── notes.twee │ │ ├── startMetadata.twee │ │ ├── test.twee │ │ ├── storydataerror.twee │ │ ├── missing.twee │ │ ├── example.twee │ │ └── cycling.twee ├── TWS │ ├── TWSParser │ │ ├── noscale.tws │ │ └── nostory.tws │ └── Parse.test.js ├── Roundtrip │ ├── Files │ │ ├── example1.twee │ │ ├── example4.twee │ │ ├── example2.twee │ │ └── LICENSE │ └── Roundtrip.test.js ├── IFID │ └── IFID.Generate.test.js ├── Twine2HTML │ ├── Twine2HTMLCompiler │ │ ├── example6.twee │ │ ├── format.js │ │ └── missingStoryTitle.twee │ └── Twine2HTMLParser │ │ ├── missingScript.html │ │ ├── missingStyle.html │ │ ├── missingIFID.html │ │ ├── missingCreator.html │ │ ├── missingCreatorVersion.html │ │ ├── missingFormat.html │ │ ├── missingFormatVersion.html │ │ ├── missingStartnode.html │ │ ├── missingZoom.html │ │ ├── missingPassageTags.html │ │ ├── missingPosition.html │ │ ├── Tags.html │ │ ├── missingSize.html │ │ ├── lyingStartnode.html │ │ ├── twineExample2.html │ │ ├── twineExample3.html │ │ ├── twineExample.html │ │ └── Example1.html ├── Twine2ArchiveHTML │ ├── Twine2ArchiveHTMLCompiler │ │ └── test1.html │ ├── Twine2ArchiveHTMLParser │ │ └── test1.html │ ├── Twine2ArchiveHTML.Compile.test.js │ └── Twine2ArchiveHTML.Parse.test.js ├── Web │ ├── web-tws.test.js │ ├── web-twine2archive.test.js │ ├── web-core-global.test.js │ └── web-twine1html.test.js └── Objects │ └── SnowmanCompatibility.test.js ├── types ├── src │ ├── Web │ │ ├── web-index.d.ts │ │ ├── web-tws.d.ts │ │ ├── uuid-lite.d.ts │ │ ├── html-entities-lite.d.ts │ │ ├── semver-lite.d.ts │ │ ├── web-twine1html.d.ts │ │ ├── web-twine2archive.d.ts │ │ └── web-core.d.ts │ ├── extwee.d.ts │ ├── CLI │ │ ├── isFile.d.ts │ │ ├── isDirectory.d.ts │ │ ├── CommandLineProcessing.d.ts │ │ ├── ProcessConfig │ │ │ ├── readDirectories.d.ts │ │ │ └── loadStoryFormat.d.ts │ │ └── ProcessConfig.d.ts │ ├── Config │ │ ├── parser.d.ts │ │ └── reader.d.ts │ ├── Twine1HTML │ │ ├── parse.d.ts │ │ ├── parse-web.d.ts │ │ └── compile.d.ts │ ├── StoryFormat │ │ ├── compile.d.ts │ │ └── parse.d.ts │ ├── TWS │ │ └── parse.d.ts │ ├── IFID │ │ ├── generate.d.ts │ │ └── generate-web.d.ts │ ├── Twine2HTML │ │ ├── compile.d.ts │ │ ├── parse.d.ts │ │ └── parse-web.d.ts │ ├── Twee │ │ └── parse.d.ts │ ├── Twine2ArchiveHTML │ │ ├── compile.d.ts │ │ ├── parse.d.ts │ │ └── parse-web.d.ts │ ├── JSON │ │ └── parse.d.ts │ └── StoryFormat.d.ts ├── Config │ ├── parser.d.ts │ └── reader.d.ts ├── Twee │ └── parse.d.ts ├── Twine1HTML │ ├── parse.d.ts │ └── compile.d.ts ├── StoryFormat │ ├── compile.d.ts │ └── parse.d.ts ├── TWS │ └── parse.d.ts ├── IFID │ └── generate.d.ts ├── Twine2HTML │ ├── compile.d.ts │ └── parse.d.ts ├── index.d.ts ├── Twine2ArchiveHTML │ ├── compile.d.ts │ └── parse.d.ts ├── JSON │ └── parse.d.ts └── StoryFormat.d.ts ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yml ├── workflows │ ├── dependabot-automerge.yml │ └── nodejs.yml ├── codeql-analysis.yml └── PULL_REQUEST_TEMPLATE.md ├── jest.config.json ├── extwee.config.json ├── .travis.yml ├── babel.config.json ├── SECURITY.md ├── eslint.config.js ├── tsconfig.json ├── src ├── CLI │ ├── isFile.js │ ├── isDirectory.js │ └── ProcessConfig │ │ └── readDirectories.js ├── IFID │ ├── generate.js │ └── generate-web.js ├── StoryFormat │ └── compile.js ├── Web │ ├── web-tws.js │ ├── web-twine1html.js │ ├── web-twine2archive.js │ └── web-core.js ├── extwee.js ├── Config │ ├── reader.js │ └── parser.js ├── Twine2HTML │ └── compile.js ├── Twine2ArchiveHTML │ ├── compile.js │ └── parse.js ├── Twine1HTML │ ├── compile.js │ └── parse.js └── TWS │ └── parse.js ├── .npmignore ├── LICENSE ├── index.js ├── webpack.config.js ├── extwee.config.md ├── .gitignore └── package.json /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/CLI/files/output/test.twee: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/Config/files/invalid.json: -------------------------------------------------------------------------------- 1 | invalid -------------------------------------------------------------------------------- /types/src/Web/web-index.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /test/Twine1HTML/Twine1HTMLCompiler/test.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/Twine1HTML/Twine1HTMLCompiler/engineTest.html: -------------------------------------------------------------------------------- 1 | "ENGINE" -------------------------------------------------------------------------------- /test/Config/files/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "some": "value" 3 | } -------------------------------------------------------------------------------- /test/Twee/TweeParser/emptytags.twee: -------------------------------------------------------------------------------- 1 | :: Start [] 2 | Content 3 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/noTitle.twee: -------------------------------------------------------------------------------- 1 | :: Start 2 | Nothing else 3 | -------------------------------------------------------------------------------- /types/src/extwee.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | export {}; 3 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/start.twee: -------------------------------------------------------------------------------- 1 | :: Start 2 | Technically, a valid story. -------------------------------------------------------------------------------- /types/src/CLI/isFile.d.ts: -------------------------------------------------------------------------------- 1 | export function isFile(path: any): boolean; 2 | -------------------------------------------------------------------------------- /types/src/CLI/isDirectory.d.ts: -------------------------------------------------------------------------------- 1 | export function isDirectory(path: any): boolean; 2 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/pasagemetadataerror.twee: -------------------------------------------------------------------------------- 1 | :: Start {"position} 2 | Some content 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: videlais 4 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bail": 1, 3 | "verbose": true, 4 | "collectCoverage": true 5 | } -------------------------------------------------------------------------------- /test/Twee/TweeParser/malformed.twee: -------------------------------------------------------------------------------- 1 | :: Start {"position":"353,60""size":"100,100"} 2 | Malformed metadata. -------------------------------------------------------------------------------- /test/TWS/TWSParser/noscale.tws: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/videlais/extwee/HEAD/test/TWS/TWSParser/noscale.tws -------------------------------------------------------------------------------- /test/TWS/TWSParser/nostory.tws: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/videlais/extwee/HEAD/test/TWS/TWSParser/nostory.tws -------------------------------------------------------------------------------- /test/Twine1HTML/Twine1HTMLCompiler/test1.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /extwee.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "decompile", 3 | "input": "index.html", 4 | "output": "index.twee", 5 | "story-format": "harlowe" 6 | } -------------------------------------------------------------------------------- /test/Config/files/valid.json: -------------------------------------------------------------------------------- 1 | { 2 | "story-format": "harlowe", 3 | "mode": "decompile", 4 | "input": "index.html", 5 | "output": "index.twee" 6 | } -------------------------------------------------------------------------------- /test/Twine1HTML/Twine1HTMLCompiler/test2.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/multipletags.twee: -------------------------------------------------------------------------------- 1 | :: Start [tag tags] 2 | Content 3 | 4 | :: StoryTitle 5 | Title 6 | 7 | :: StoryData 8 | { 9 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128" 10 | } 11 | -------------------------------------------------------------------------------- /test/Config/files/full.json: -------------------------------------------------------------------------------- 1 | { 2 | "story-format": "harlowe", 3 | "mode": "compile", 4 | "input": "index.twee", 5 | "output": "index.html", 6 | "story-format-version": "3.2.0", 7 | "twine1-project": false 8 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "node" 5 | 6 | install: 7 | - npm install 8 | - npm install codecov 9 | 10 | script: 11 | - npm run test 12 | - npm run report-coverage 13 | - codecov 14 | -------------------------------------------------------------------------------- /types/src/Web/web-tws.d.ts: -------------------------------------------------------------------------------- 1 | export default Extwee; 2 | export { parseTWS as parse }; 3 | declare namespace Extwee { 4 | export { parseTWS }; 5 | export { parseTWS as parse }; 6 | } 7 | import { parse as parseTWS } from '../TWS/parse.js'; 8 | -------------------------------------------------------------------------------- /types/src/Web/uuid-lite.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Lightweight UUID v4 generator for web builds 3 | * This replaces the full uuid package to reduce bundle size 4 | * @returns {string} A randomly generated UUID v4 string 5 | */ 6 | export function v4(): string; 7 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/style.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | Style Tag Usage 3 | 4 | 5 | :: StoryData 6 | { 7 | "ifid": "2A4D6978-93A7-4FD3-96FB-94B995FCBE29" 8 | } 9 | 10 | :: Example [stylesheet] 11 | #green { 12 | color:green; 13 | } 14 | 15 | :: Start 16 | Nothing here! -------------------------------------------------------------------------------- /test/Twee/TweeParser/singletag.twee: -------------------------------------------------------------------------------- 1 | :: Start [tag] 2 | Content 3 | 4 | :: StoryTitle 5 | Title 6 | 7 | :: StoryData 8 | { 9 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 10 | "format": "Harlowe", 11 | "formatVersion": "2.1.0", 12 | "zoom": "1" 13 | } 14 | -------------------------------------------------------------------------------- /test/CLI/files/example.json: -------------------------------------------------------------------------------- 1 | {"name":"Test","tagColors":{"r":"red"},"ifid":"dd","start":"Start","formatVersion":"1.0","metadata":{"some":"thing"},"format":"Snowman","creator":"extwee","creatorVersion":"2.2.0","zoom":1,"passages":[{"name":"Start","tags":["tag1"],"metadata":{"s":"e"},"text":"Word"}]} -------------------------------------------------------------------------------- /test/Roundtrip/Files/example1.twee: -------------------------------------------------------------------------------- 1 | :: StoryData 2 | { 3 | "ifid": "E70FC479-01D9-4E44-AC6A-AFF9F5E1C475", 4 | "format": "Chapbook", 5 | "format-version": "1.1.0", 6 | "zoom": 1, 7 | "start": "StoryTitle" 8 | } 9 | 10 | [object Object][object Object][object Object][object Object] -------------------------------------------------------------------------------- /types/Config/parser.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses a JSON object and extracts the StoryFormat, StoryTitle and StoryVersion. 3 | * @param {object} obj Incoming JSON object. 4 | * @returns {object} An object containing the extracted results. 5 | */ 6 | export function parser(obj: object): object; 7 | -------------------------------------------------------------------------------- /types/src/Config/parser.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses a JSON object and extracts the StoryFormat, StoryTitle and StoryVersion. 3 | * @param {object} obj Incoming JSON object. 4 | * @returns {object} An object containing the extracted results. 5 | */ 6 | export function parser(obj: object): object; 7 | -------------------------------------------------------------------------------- /test/CLI/files/test.twee: -------------------------------------------------------------------------------- 1 | :: StoryData 2 | { 3 | "ifid": "2EF8F18A-5588-40B0-B0B7-C8D472677B35", 4 | "format": "Harlowe", 5 | "format-version": "3.0.2", 6 | "zoom": 1, 7 | "start": "Untitled" 8 | } 9 | 10 | :: StoryTitle 11 | Title 12 | 13 | :: Start 14 | Content 15 | 16 | :: Untitled 17 | Some stuff 18 | 19 | -------------------------------------------------------------------------------- /test/CLI/files/tweeExample.twee: -------------------------------------------------------------------------------- 1 | :: StoryData 2 | { 3 | "ifid": "2EF8F18A-5588-40B0-B0B7-C8D472677B35", 4 | "format": "Test", 5 | "format-version": "1.2.3", 6 | "zoom": 1, 7 | "start": "Untitled" 8 | } 9 | 10 | :: StoryTitle 11 | Title 12 | 13 | :: Start 14 | Content 15 | 16 | :: Untitled 17 | Some stuff 18 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/stylePassage.twee: -------------------------------------------------------------------------------- 1 | :: Start 2 | Content 3 | 4 | :: StoryTitle 5 | Title 6 | 7 | :: UserStylesheet [stylesheet] 8 | 1 9 | 10 | :: StoryData 11 | { 12 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 13 | "format": "Harlowe", 14 | "formatVersion": "2.1.0", 15 | "zoom": "1" 16 | } 17 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/scriptPassage.twee: -------------------------------------------------------------------------------- 1 | :: Start 2 | Content 3 | 4 | :: StoryTitle 5 | Title 6 | 7 | :: UserScript [script] 8 | window.example = {} 9 | 10 | :: StoryData 11 | { 12 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 13 | "format": "Harlowe", 14 | "formatVersion": "2.1.0", 15 | "zoom": "1" 16 | } 17 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/multipleScriptPassages.twee: -------------------------------------------------------------------------------- 1 | :: Start 2 | Content 3 | 4 | :: StoryTitle 5 | Title 6 | 7 | :: UserScript [script] 8 | 1 9 | 10 | :: UserScript2 [script] 11 | 2 12 | 13 | :: StoryData 14 | { 15 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 16 | "format": "Harlowe", 17 | "formatVersion": "2.1.0", 18 | "zoom": "1" 19 | } 20 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [], 3 | "presets": [ 4 | [ 5 | "@babel/env", 6 | { 7 | "targets": { 8 | "edge": "17", 9 | "firefox": "60", 10 | "chrome": "67", 11 | "safari": "11.1" 12 | }, 13 | "useBuiltIns": "usage", 14 | "corejs": "3.8.0" 15 | } 16 | ] 17 | ] 18 | } -------------------------------------------------------------------------------- /test/Twee/TweeParser/multipleStyleTag.twee: -------------------------------------------------------------------------------- 1 | :: Start 2 | Content 3 | 4 | :: StoryTitle 5 | Title 6 | 7 | :: UserStylesheet1 [stylesheet] 8 | 1 9 | 10 | :: UserStylesheet2 [stylesheet] 11 | 2 12 | 13 | :: StoryData 14 | { 15 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 16 | "format": "Harlowe", 17 | "formatVersion": "2.1.0", 18 | "zoom": "1" 19 | } 20 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/cursed.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | Cursed 3 | 4 | 5 | :: StoryData 6 | { 7 | "ifid": "22F25A58-7062-4927-95B6-F424DDB2EC65", 8 | "format": "Harlowe", 9 | "format-version": "3.3.8", 10 | "start": "[Hello] {world} \\\\", 11 | "zoom": 1 12 | } 13 | 14 | 15 | :: \[Hello\] \{world\} \\\\ {"position":"400,200","size":"100,100"} 16 | \:: Extra header 17 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/notes.twee: -------------------------------------------------------------------------------- 1 | /** 2 | These are some notes at the top of the file 3 | */ 4 | :: StoryTitle 5 | twineExample 6 | 7 | :: Start {"position":"102,104","size":"100,100"} 8 | Start passage 9 | 10 | :: StoryData 11 | { 12 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 13 | "format": "Harlowe", 14 | "formatVersion": "2.1.0", 15 | "zoom": "1" 16 | } 17 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/startMetadata.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | Example 3 | 4 | :: StoryData 5 | { 6 | "ifid": "A74F654F-3F85-44DE-8A87-9845AECD04B0", 7 | "format": "Harlowe", 8 | "formatVersion": "3.2.1", 9 | "zoom": "1", 10 | "start": "Untitled Passage" 11 | } 12 | 13 | :: Untitled Passage {"position":"100,99","size":"100,100"} 14 | Double-click this passage to edit it. 15 | -------------------------------------------------------------------------------- /test/IFID/IFID.Generate.test.js: -------------------------------------------------------------------------------- 1 | import { generate } from '../../src/IFID/generate.js'; 2 | 3 | describe('src/IFID/generate.js', () => { 4 | describe('generate()', () => { 5 | it('should generate a valid IFID', () => { 6 | const ifid = generate(); 7 | expect(ifid).toMatch(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/); 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /types/src/Web/html-entities-lite.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Encode HTML entities 3 | * @param {string} str - String to encode 4 | * @returns {string} Encoded string 5 | */ 6 | export function encode(str: string): string; 7 | /** 8 | * Decode HTML entities 9 | * @param {string} str - String to decode 10 | * @returns {string} Decoded string 11 | */ 12 | export function decode(str: string): string; 13 | -------------------------------------------------------------------------------- /types/src/CLI/CommandLineProcessing.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Process command line arguments. 3 | * @function CommandLineProcessing 4 | * @description This function processes the command line arguments passed to the Extwee CLI. 5 | * @module CLI/commandLineProcessing 6 | * @param {Array} argv - The command line arguments passed to the CLI. 7 | */ 8 | export function CommandLineProcessing(argv: any[]): void; 9 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLCompiler/example6.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | twineExample 3 | 4 | :: Start [tag tags] {"position": "200,200", "size": "100,100"} 5 | Content 6 | 7 | :: Style1 [stylesheet] 8 | body {background-color: green;} 9 | 10 | :: StoryData 11 | { 12 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 13 | "format": "Harlowe", 14 | "formatVersion": "2.1.0", 15 | "zoom": "1" 16 | } -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.6 | :x: | 8 | | 2.X | :white_check_mark: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | When reporting a vulnerability, start with an issue. This will be flagged and integrated into the current project and fast-tracked for a new version. 13 | -------------------------------------------------------------------------------- /docs/formats/tws.md: -------------------------------------------------------------------------------- 1 | # TWS 2 | 3 | The [Twine 1 TWS documentation](https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-twsoutput.md) details the Python pickle format used in Twine 1. 4 | 5 | Extwee can only parse TWS files. 6 | 7 | ## Parsing 8 | 9 | When using the `parseTWS()` function (or `TWS/parse.js` export), incoming TWS will be converted into a [**Story**](/objects/story.md) object. 10 | -------------------------------------------------------------------------------- /types/Twee/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses Twee 3 text into a Story object. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twee-3-specification.md Twee 3 Specification} 4 | * @function parse 5 | * @param {string} fileContents - File contents to parse 6 | * @returns {Story} story 7 | */ 8 | export function parse(fileContents: string): Story; 9 | import { Story } from '../Story.js'; 10 | -------------------------------------------------------------------------------- /types/Twine1HTML/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses Twine 1 HTML into a Story object. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 4 | * @function parse 5 | * @param {string} content - Twine 1 HTML content to parse. 6 | * @returns {Story} Story object 7 | */ 8 | export function parse(content: string): Story; 9 | import { Story } from '../Story.js'; 10 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLCompiler/format.js: -------------------------------------------------------------------------------- 1 | window.storyFormat({ 2 | "name": "Test Story Format", 3 | "version": "1.0.0", 4 | "author": "Dan Cox", 5 | "description": "A test story format.", 6 | "source": "{{STORY_NAME}}{{STORY_DATA}}", 7 | "license": "MIT", 8 | "proofing": false 9 | }); -------------------------------------------------------------------------------- /types/src/Twine1HTML/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses Twine 1 HTML into a Story object. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 4 | * @function parse 5 | * @param {string} content - Twine 1 HTML content to parse. 6 | * @returns {Story} Story object 7 | */ 8 | export function parse(content: string): Story; 9 | import { Story } from '../Story.js'; 10 | -------------------------------------------------------------------------------- /types/src/Web/semver-lite.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Lightweight semantic version validation for web builds 3 | * This replaces the full semver package to reduce bundle size 4 | */ 5 | /** 6 | * Validates if a string is a valid semantic version 7 | * @param {string} version - Version string to validate 8 | * @returns {string|null} Returns the version if valid, null if invalid 9 | */ 10 | export function valid(version: string): string | null; 11 | -------------------------------------------------------------------------------- /test/Roundtrip/Files/example4.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | Example4 3 | 4 | :: user[script] 5 | console.log("Hi!"); 6 | 7 | :: user2[script] 8 | console.log("Hi!"); 9 | 10 | :: style1[stylesheet] 11 | body { 12 | color: #ccc; 13 | } 14 | 15 | :: style2[stylesheet] 16 | html { 17 | color: #ccc; 18 | } 19 | 20 | :: Start 21 | [[Another passage]] 22 | 23 | :: Another passage 24 | [[A third]] 25 | 26 | :: A third 27 | Double-click this passage to edit it. 28 | -------------------------------------------------------------------------------- /types/src/Web/web-twine1html.d.ts: -------------------------------------------------------------------------------- 1 | export default Extwee; 2 | declare namespace Extwee { 3 | export { parseTwine1HTML }; 4 | export { compileTwine1HTML }; 5 | export { parseTwine1HTML as parse }; 6 | export { compileTwine1HTML as compile }; 7 | } 8 | import { parse as parseTwine1HTML } from '../Twine1HTML/parse-web.js'; 9 | import { compile as compileTwine1HTML } from '../Twine1HTML/compile.js'; 10 | export { parseTwine1HTML as parse, compileTwine1HTML as compile }; 11 | -------------------------------------------------------------------------------- /test/Roundtrip/Files/example2.twee: -------------------------------------------------------------------------------- 1 | :: StoryData 2 | { 3 | "ifid": "E70FC479-01D9-4E44-AC6A-AFF9F5E1C475", 4 | "format": "Chapbook", 5 | "format-version": "1.1.0", 6 | "zoom": 1, 7 | "start": "Start" 8 | } 9 | 10 | :: StoryTitle 11 | Example1 12 | 13 | :: UserScript[script] 14 | 15 | 16 | :: UserStylesheet[stylesheet] 17 | 18 | 19 | :: Start 20 | [[Another passage]] 21 | 22 | :: Another passage 23 | [[A third]] 24 | 25 | :: A third 26 | Double-click this passage to edit it. 27 | 28 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/test.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | twineExample 3 | 4 | :: Start 5 | Some content. 6 | 7 | :: StoryData 8 | { 9 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 10 | "format": "Harlowe", 11 | "formatVersion": "2.1.0", 12 | "zoom": "1" 13 | } 14 | 15 | :: Script1 [script] 16 | // Some code 17 | 18 | :: Script2 [script] 19 | //More code here! 20 | 21 | :: Style1 [stylesheet] 22 | body {font-size: 1.2em} 23 | 24 | :: Style2 [stylesheet] 25 | p {font-style: italic;} 26 | -------------------------------------------------------------------------------- /test/Twine2ArchiveHTML/Twine2ArchiveHTMLCompiler/test1.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /types/StoryFormat/compile.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compiles a {@link StoryFormat} object into a JSONP string for writing to a `format.js` file. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-storyformats-spec.md Twine 2 Story Formats Specification} 4 | * @param {StoryFormat} storyFormat Story format object to compile. 5 | * @returns {string} JSONP string. 6 | */ 7 | export function compile(storyFormat: StoryFormat): string; 8 | import StoryFormat from '../StoryFormat.js'; 9 | -------------------------------------------------------------------------------- /types/src/StoryFormat/compile.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compiles a {@link StoryFormat} object into a JSONP string for writing to a `format.js` file. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-storyformats-spec.md Twine 2 Story Formats Specification} 4 | * @param {StoryFormat} storyFormat Story format object to compile. 5 | * @returns {string} JSONP string. 6 | */ 7 | export function compile(storyFormat: StoryFormat): string; 8 | import StoryFormat from '../StoryFormat.js'; 9 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/storydataerror.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | twineExample 3 | 4 | :: Start 5 | Some content. 6 | 7 | :: StoryData 8 | { 9 | "ifid": 2B68ECD6-348F-4CF5-96F8-549A512A8128", 10 | "format": "Harlowe", 11 | "formatVersion": "2.1.0", 12 | "zoom": "1" 13 | } 14 | 15 | :: Script1 [script] 16 | // Some code 17 | 18 | :: Script2 [script] 19 | //More code here! 20 | 21 | :: Style1 [stylesheet] 22 | body {font-size: 1.2em} 23 | 24 | :: Style2 [stylesheet] 25 | p {font-style: italic;} 26 | -------------------------------------------------------------------------------- /types/Config/reader.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Read a JSON file and return its contents. 3 | * @param {string} path Path to the JSON file. 4 | * @returns {object} Parsed JSON object. 5 | * @throws {Error} If the file does not exist. 6 | * @throws {Error} If the file is not a valid JSON file. 7 | * @example 8 | * const contents = reader('test/Config/files/valid.json'); 9 | * console.log(contents); // {"story-format": 'Harlowe', "story-title": "My Story"} 10 | */ 11 | export function reader(path: string): object; 12 | -------------------------------------------------------------------------------- /types/TWS/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse TWS file (as Buffer) into Story. 3 | * Unless it throws an error, it will return a Story object. 4 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 5 | * @function parse 6 | * @param {Buffer} binaryFileContents - File contents to parse as Buffer. 7 | * @returns {Story} Story object. 8 | */ 9 | export function parse(binaryFileContents: Buffer): Story; 10 | import { Story } from '../Story.js'; 11 | -------------------------------------------------------------------------------- /types/src/Config/reader.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Read a JSON file and return its contents. 3 | * @param {string} path Path to the JSON file. 4 | * @returns {object} Parsed JSON object. 5 | * @throws {Error} If the file does not exist. 6 | * @throws {Error} If the file is not a valid JSON file. 7 | * @example 8 | * const contents = reader('test/Config/files/valid.json'); 9 | * console.log(contents); // {"story-format": 'Harlowe', "story-title": "My Story"} 10 | */ 11 | export function reader(path: string): object; 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: 💬 Discussion Forum 4 | url: https://github.com/videlais/extwee/discussions 5 | about: Ask questions and discuss ideas with the community 6 | - name: 📚 Documentation 7 | url: https://videlais.github.io/extwee/ 8 | about: Read the official documentation 9 | - name: 📖 Twine Specifications 10 | url: https://github.com/iftechfoundation/twine-specs 11 | about: Reference for Twine format specifications 12 | -------------------------------------------------------------------------------- /types/src/TWS/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse TWS file (as Buffer) into Story. 3 | * Unless it throws an error, it will return a Story object. 4 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 5 | * @function parse 6 | * @param {Buffer} binaryFileContents - File contents to parse as Buffer. 7 | * @returns {Story} Story object. 8 | */ 9 | export function parse(binaryFileContents: Buffer): Story; 10 | import { Story } from '../Story.js'; 11 | -------------------------------------------------------------------------------- /test/CLI/files/example6.twee: -------------------------------------------------------------------------------- 1 | :: StoryData 2 | { 3 | "ifid": "D674C58C-DEFA-4F70-B7A2-27742230C0FC", 4 | "format": "SugarCube", 5 | "format-version": "2.28.2", 6 | "start": "Start", 7 | "tag-colors": { 8 | "bar": "green", 9 | "foo": "red", 10 | "qaz": "blue" 11 | }, 12 | "zoom": 0.25 13 | } 14 | 15 | :: StoryTitle 16 | twineExample 17 | 18 | :: Start [tag tags] {"position": "200,200", "size": "100,100"} 19 | Content 20 | 21 | :: Style1 [stylesheet] 22 | body {background-color: green;} 23 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/missing.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | twineExample 3 | 4 | :: Start 5 | This is the start passage 6 | 7 | :: Another passage {"position":"353,60","size":"100,100"} 8 | [[A fourth passage]] 9 | 10 | [[A third passage]] 11 | 12 | :: A third passage {"position":"350,288","size":"100,100"} 13 | [[Start]] 14 | 15 | :: A fourth passage {"position":"587,197","size":"100,100"} 16 | [[A fifth passage]] 17 | 18 | :: A fifth passage {"position":"800,306","size":"100,100"} 19 | Double-click this passage to edit it. 20 | -------------------------------------------------------------------------------- /types/src/Twine1HTML/parse-web.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Web-optimized Twine 1 HTML parser with reduced dependencies 3 | * Parses Twine 1 HTML into a Story object using lightweight DOM parsing 4 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 5 | * @function parse 6 | * @param {string} content - Twine 1 HTML content to parse. 7 | * @returns {Story} Story object 8 | */ 9 | export function parse(content: string): Story; 10 | import { Story } from '../Story.js'; 11 | -------------------------------------------------------------------------------- /docs/install/npm.md: -------------------------------------------------------------------------------- 1 | # NPM 2 | 3 | While general compilation and de-compilation is possible using the CLI, more advanced usage patterns can be enabled through the API. 4 | 5 | By installing `extwee`, developers can directly access its objects, parsers, and compilation functionality. 6 | 7 | ## Example 8 | 9 | ```javascript 10 | import { Story, Passage } from 'extwee'; 11 | 12 | // Create the story. 13 | const example = new Story( 'Example' ); 14 | // Add a new passage. 15 | example.addPassage(new Passage( 'Test', 'Some Text') ); 16 | ``` 17 | -------------------------------------------------------------------------------- /types/src/Web/web-twine2archive.d.ts: -------------------------------------------------------------------------------- 1 | export default Extwee; 2 | declare namespace Extwee { 3 | export { parseTwine2ArchiveHTML }; 4 | export { compileTwine2ArchiveHTML }; 5 | export { parseTwine2ArchiveHTML as parse }; 6 | export { compileTwine2ArchiveHTML as compile }; 7 | } 8 | import { parse as parseTwine2ArchiveHTML } from '../Twine2ArchiveHTML/parse-web.js'; 9 | import { compile as compileTwine2ArchiveHTML } from '../Twine2ArchiveHTML/compile.js'; 10 | export { parseTwine2ArchiveHTML as parse, compileTwine2ArchiveHTML as compile }; 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /types/IFID/generate.d.ts: -------------------------------------------------------------------------------- 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. 8 | * @returns {string} IFID 9 | * @example 10 | * const ifid = generate(); 11 | * console.log(ifid); 12 | * // => 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6' 13 | */ 14 | export function generate(): string; 15 | -------------------------------------------------------------------------------- /types/src/CLI/ProcessConfig/readDirectories.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Read the contents of a directory and returns all directories. 3 | * @function readDirectories 4 | * @description This function reads the contents of a directory and returns a list of directories. 5 | * @param {string} directory - The path to the directory to read. 6 | * @returns {Array} - An array of directories in the directory. 7 | * @throws {Error} - If the directory does not exist or if there is an error reading the directory. 8 | */ 9 | export function readDirectories(directory: string): Array; 10 | -------------------------------------------------------------------------------- /docs/formats/twee.md: -------------------------------------------------------------------------------- 1 | # Twee 2 | 3 | The [Twee 3 specification](https://github.com/iftechfoundation/twine-specs/blob/master/twee-3-specification.md) defines a human-readable format for storing a Twine 2 compatible story and passage data. 4 | 5 | Extwee can perform two actions with Twee. 6 | 7 | ## Parse 8 | 9 | When using the `parseTwee()` function (or `Twee/parse.js` export), incoming Twee will be converted into a [**Story**](/objects/story.md) object. 10 | 11 | ## Output 12 | 13 | Every **Story** object can create a Twee representation of its data using the `Story.toTwee()` method. 14 | -------------------------------------------------------------------------------- /types/src/IFID/generate.d.ts: -------------------------------------------------------------------------------- 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). 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 | export function generate(): string; 15 | -------------------------------------------------------------------------------- /types/src/IFID/generate-web.d.ts: -------------------------------------------------------------------------------- 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 | export function generate(): string; 15 | -------------------------------------------------------------------------------- /types/src/CLI/ProcessConfig.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Processes the config file, if present. 3 | * @function ConfigFileProcessing 4 | * @description This function processes the config file. 5 | * It checks if the config file exists and if it does, it reads the config file. 6 | * If the config file does not exist, the function will exit the process with an error message. 7 | * The config file is used to store configuration options for the Extwee CLI. 8 | * @returns {void} 9 | * @throws {Error} - If the config file does not exist or if there is an error parsing the config file. 10 | */ 11 | export function ConfigFileProcessing(): void; 12 | export function ConfigFilePresent(): boolean; 13 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingScript.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingStyle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingIFID.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/formats/twine2ArchiveHTML.md: -------------------------------------------------------------------------------- 1 | # Twine 2 Archive HTML 2 | 3 | The [Twine 2 Archive HTML specification](https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md) defines a collection of Twine 2 stories stored in HTML. 4 | 5 | Extwee can perform two actions with Twine 2 archive HTML. 6 | 7 | ## Parsing 8 | 9 | When using the `parseTwine2ArchiveHTML()` function (or `Twine2ArchiveHTML/parse.js` export), incoming Twine 2 Archive HTML will be converted into an array of [**Story**](/objects/story.md) objects. 10 | 11 | ## Compilation 12 | 13 | When using the `compileTwine2ArchiveHTML()` function (or `Twine2ArchiveHTML/compile.js` export), an array of **Story** objects can be converted into Twine 2 Archive HTML. 14 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingCreator.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingCreatorVersion.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingFormat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingFormatVersion.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingStartnode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingZoom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import jest from "eslint-plugin-jest"; 4 | import jsdoc from 'eslint-plugin-jsdoc'; 5 | 6 | export default [ 7 | jsdoc.configs['flat/recommended'], 8 | { 9 | languageOptions: { 10 | globals: { 11 | ...globals.browser, 12 | ...globals.node, 13 | ...globals.jest 14 | } 15 | }, 16 | plugins: { 17 | jest: jest, 18 | jsdoc: jsdoc 19 | }, 20 | rules: { 21 | 'jsdoc/require-description': 'warn', 22 | 'jsdoc/check-tag-names': ['error', { 23 | definedTags: ['jest-environment'] 24 | }] 25 | } 26 | }, 27 | pluginJs.configs.recommended, 28 | ]; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // Change this to match your project 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | // Tells TypeScript to read JS files, as 6 | // normally they are ignored as source files 7 | "allowJs": true, 8 | // Generate d.ts files 9 | "declaration": true, 10 | // This compiler run should 11 | // only output d.ts files 12 | "emitDeclarationOnly": true, 13 | // Types should go into this directory. 14 | // Removing this would place the .d.ts files 15 | // next to the .js files 16 | "outDir": "types", 17 | // go to js file when using IDE functions like 18 | // "Go to Definition" in VSCode 19 | "declarationMap": true 20 | } 21 | } -------------------------------------------------------------------------------- /docs/formats/twine2HTML.md: -------------------------------------------------------------------------------- 1 | # Twine 2 HTML 2 | 3 | The [Twine 2 HTML Output specification](https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md) defines the elements and attributes for encoding Twine 2 story and passage data. 4 | 5 | Extwee can perform two actions with Twine 2 HTML. 6 | 7 | ## Parsing 8 | 9 | When using the `parseTwine2HTML()` function (or `Twine2HTML/parse.js` export), incoming Twine 2 HTML will be converted into a [**Story**](/objects/story.md) object. 10 | 11 | ## Compilation 12 | 13 | When using the `compileTwine2HTML()` function (or `Twine2HTML/compile.js` export), [**Story**](/objects/story.md) and [**StoryFormat**](/objects/storyformat.md) objects can be compiled into Twine 2 HTML output. 14 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingPassageTags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/CLI/isFile.js: -------------------------------------------------------------------------------- 1 | // Import fs. 2 | import { statSync } from 'node:fs'; 3 | 4 | /* 5 | * Check if a passed option is a valid file. 6 | * @function isFile 7 | * @description Check if a file exists. 8 | * @param {string} path - Path to file. 9 | * @returns {boolean} True if file exists, false if not. 10 | */ 11 | export const isFile = (path) => { 12 | // set default. 13 | let result = false; 14 | 15 | try { 16 | // Attempt t0 get stats. 17 | const stats = statSync(path); 18 | 19 | // Return if path is a file. 20 | result = stats.isFile(); 21 | } catch (e) { 22 | // If there was an error, log it. 23 | console.error(`Error: ${e}`); 24 | } 25 | 26 | // Return either the default (false) or the result (true). 27 | return result; 28 | }; -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingPosition.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /docs/formats/twine1HTML.md: -------------------------------------------------------------------------------- 1 | # Twine 1 HTML 2 | 3 | The [Twine 1 HTML Output specification](https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md) defines the elements and attributes for encoding Twine 1 story and passage data. 4 | 5 | Extwee can perform two actions with Twine 1 HTML. 6 | 7 | ## Parsing 8 | 9 | When using the `parseTwine1HTML()` function (or `Twine1HTML/parse.js` export), incoming Twine 1 HTML will be converted into a [**Story**](/objects/story.md) object. 10 | 11 | ## Compilation 12 | 13 | When using the `compileTwine1HTML()` function (or `Twine2HTML/compile.js` export), [**Story**](/objects/story.md) objects can be compiled into Twine 1 HTML output with additional content from `engine.js`, `header.html`, and `code.js` files. 14 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/Tags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLCompiler/missingStoryTitle.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | twineExample 3 | 4 | :: Start {"position":"102,104","size":"100,100"} 5 | [[Another passage]] 6 | 7 | [[A third passage]] 8 | 9 | :: Another passage {"position":"353,60","size":"100,100"} 10 | [[A fourth passage]] 11 | 12 | [[A third passage]] 13 | 14 | :: A third passage {"position":"350,288","size":"100,100"} 15 | [[Start]] 16 | 17 | :: A fourth passage {"position":"587,197","size":"100,100"} 18 | [[A fifth passage]] 19 | 20 | :: A fifth passage {"position":"800,306","size":"100,100"} 21 | Double-click this passage to edit it. 22 | 23 | :: StoryData 24 | { 25 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 26 | "format": "Harlowe", 27 | "formatVersion": "2.1.0", 28 | "zoom": "1" 29 | } 30 | -------------------------------------------------------------------------------- /docs/formats/json.md: -------------------------------------------------------------------------------- 1 | # JSON 2 | 3 | The [Twine 2 JSON specification](https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-jsonoutput-doc.md) defines a format for storing a Twine 2 compatible story and passage data in JavaScript Objection Notation (JSON). 4 | 5 | Extwee can perform two actions. 6 | 7 | ## Parse 8 | 9 | When using the `parseJSON()` function (or `JSON/parse.js` export), incoming Twine 2 JSON will be converted into a [**Story**](/objects/story.md) object. 10 | 11 | ## Output 12 | 13 | Every **Story** object can create a JSON representation of its data using the `Story.toJSON()` method. 14 | 15 | ## Format Caution 16 | 17 | As of December 2023, no current story compilation (other than Extwee) or version of Twine supports input or output of the Twine 2 JSON format. 18 | -------------------------------------------------------------------------------- /src/CLI/isDirectory.js: -------------------------------------------------------------------------------- 1 | import { statSync } from 'node:fs'; 2 | 3 | /* 4 | * Check if a passed option is a valid directory. 5 | * @function isDirectory 6 | * @description Check if a directory exists. 7 | * @param {string} path - Path to directory. 8 | * @returns {boolean} True if directory exists, false if not. 9 | */ 10 | export const isDirectory = (path) => { 11 | // set default. 12 | let result = false; 13 | 14 | try { 15 | // Attempt t0 get stats. 16 | const stats = statSync(path); 17 | 18 | // Return if path is a directory. 19 | result = stats.isDirectory(); 20 | } catch (e) { 21 | // If there was an error, log it. 22 | console.error(`Error: ${e}`); 23 | } 24 | 25 | // Return either the default (false) or the result (true). 26 | return result; 27 | }; -------------------------------------------------------------------------------- /test/Twee/TweeParser/example.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | twineExample 3 | 4 | :: StoryAuthor 5 | Dan 6 | 7 | :: Start {"position":"102,104","size":"100,100"} 8 | [[Another passage]] 9 | 10 | [[A third passage]] 11 | 12 | :: Another passage {"position":"353,60","size":"100,100"} 13 | [[A fourth passage]] 14 | 15 | [[A third passage]] 16 | 17 | :: A third passage {"position":"350,288","size":"100,100"} 18 | [[Start]] 19 | 20 | :: A fourth passage {"position":"587,197","size":"100,100"} 21 | [[A fifth passage]] 22 | 23 | :: A fifth passage {"position":"800,306","size":"100,100"} 24 | Double-click this passage to edit it. 25 | 26 | :: StoryData 27 | { 28 | "ifid": "2B68ECD6-348F-4CF5-96F8-549A512A8128", 29 | "format": "Harlowe", 30 | "formatVersion": "2.1.0", 31 | "zoom": "1" 32 | } 33 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Development files 2 | .git/ 3 | .github/ 4 | .gitignore 5 | .vscode/ 6 | .idea/ 7 | 8 | # Test files 9 | test/ 10 | coverage/ 11 | jest.config.json 12 | *.test.js 13 | *.spec.js 14 | 15 | # Documentation source 16 | docs/ 17 | 18 | # Build tools 19 | webpack.config.js 20 | babel.config.json 21 | eslint.config.js 22 | tsconfig.json 23 | .travis.yml 24 | 25 | # Development dependencies 26 | node_modules/ 27 | 28 | # Example and config files 29 | extwee.config.json 30 | extwee.config.md 31 | verify-*.js 32 | benchmark-*.js 33 | 34 | # Logs 35 | *.log 36 | npm-debug.log* 37 | yarn-debug.log* 38 | yarn-error.log* 39 | lerna-debug.log* 40 | 41 | # OS files 42 | .DS_Store 43 | Thumbs.db 44 | 45 | # Editor files 46 | *.swp 47 | *.swo 48 | *~ 49 | 50 | # Temporary files 51 | tmp/ 52 | temp/ 53 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - Installation and General Usage 2 | - [Using NPX (as part of workflow)](/install/npx.md) 3 | - [Using NPM (via API)](/install/npm.md) 4 | - Objects 5 | - [Passage](/objects/passage.md) 6 | - [Story](/objects/story.md) 7 | - [Story Format](/objects/storyformat.md) 8 | - Formats 9 | - [Twee 3](/formats/twee.md) 10 | - [Twine 2 HTML](/formats/twine2HTML.md) 11 | - [Twine 2 JSON](/formats/json.md) 12 | - [Twine 2 Archive HTML](/formats/twine2ArchiveHTML.md) 13 | - [Twine 1 HTML](/formats/twine1HTML.md) 14 | - [Twine 1 TWS](/formats/tws.md) 15 | 16 | - Examples 17 | - [Dynamically Generating Passages](/examples/dynamicPassages.md) 18 | - [Converting Twine 2 JSON into Twee 3](/examples/jsonToTwee.md) 19 | - [Converting Twine 1 TWS into Twee 3](/examples/twsToTwee.md) 20 | -------------------------------------------------------------------------------- /src/IFID/generate.js: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | 3 | /** 4 | * Generates an Interactive Fiction Identification (IFID) based the Treaty of Babel. 5 | * 6 | * For Twine works, the IFID is a UUID (v4) in uppercase. 7 | * @see Treaty of Babel ({@link https://babel.ifarchive.org/babel_rev11.html#the-ifid-for-an-html-story-file}) 8 | * @function generate 9 | * @description Generates a new IFID using UUIDv4 (RFC 4122). 10 | * @returns {string} IFID - A UUIDv4 string in uppercase format 11 | * @example 12 | * const ifid = generate(); 13 | * console.log(ifid); 14 | * // => 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6' 15 | */ 16 | function generate () { 17 | // crypto.randomUUID() generates RFC 4122 version 4 UUIDs 18 | return randomUUID().toUpperCase(); 19 | } 20 | 21 | export { generate }; 22 | -------------------------------------------------------------------------------- /types/Twine2HTML/compile.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Write a combination of Story + StoryFormat into Twine 2 HTML file. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md Twine 2 HTML Output Specification} 4 | * @function compile 5 | * @param {Story} story - Story object to write. 6 | * @param {StoryFormat} storyFormat - StoryFormat to write. 7 | * @returns {string} Twine 2 HTML based on StoryFormat and Story. 8 | * @throws {Error} If story is not instance of Story. 9 | * @throws {Error} If storyFormat is not instance of StoryFormat. 10 | * @throws {Error} If storyFormat.source is empty string. 11 | */ 12 | export function compile(story: Story, storyFormat: StoryFormat): string; 13 | import { Story } from '../Story.js'; 14 | import StoryFormat from '../StoryFormat.js'; 15 | -------------------------------------------------------------------------------- /types/src/Twine2HTML/compile.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Write a combination of Story + StoryFormat into Twine 2 HTML file. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md Twine 2 HTML Output Specification} 4 | * @function compile 5 | * @param {Story} story - Story object to write. 6 | * @param {StoryFormat} storyFormat - StoryFormat to write. 7 | * @returns {string} Twine 2 HTML based on StoryFormat and Story. 8 | * @throws {Error} If story is not instance of Story. 9 | * @throws {Error} If storyFormat is not instance of StoryFormat. 10 | * @throws {Error} If storyFormat.source is empty string. 11 | */ 12 | export function compile(story: Story, storyFormat: StoryFormat): string; 13 | import { Story } from '../Story.js'; 14 | import StoryFormat from '../StoryFormat.js'; 15 | -------------------------------------------------------------------------------- /docs/examples/jsonToTwee.md: -------------------------------------------------------------------------------- 1 | # Converting Twine 2 JSON to Twee 3 2 | 3 | Conversion from Twine 2 JSON to Twee 3 requires multiple steps: 4 | 5 | 1. Read the JSON file. 6 | 2. Use `parseJSON()` to convert JSON into a **Story** object. 7 | 3. Using `Story.toTwee()`, convert the **Story** object into Twee 3. 8 | 9 | ```javascript 10 | // Import only Story and parseJSON(). 11 | import { Story, parseJSON } from 'extwee'; 12 | // Import only readFileSync() and writeFileSync() for reading and writing to files. 13 | import { readFileSync, writeFileSync } from 'node:fs'; 14 | 15 | // Read in the JSON file 16 | const inputFile = readFileSync( 'example.json', 'utf-8' ); 17 | 18 | // Convert from Twine 2 JSON to Story. 19 | const s = parseJSON(inputFile); 20 | 21 | // Write Twee output 22 | writeFileSync( 'output.twee', s.toTwee() ); 23 | ``` 24 | -------------------------------------------------------------------------------- /src/StoryFormat/compile.js: -------------------------------------------------------------------------------- 1 | import StoryFormat from '../StoryFormat.js'; 2 | 3 | /** 4 | * Compiles a {@link StoryFormat} object into a JSONP string for writing to a `format.js` file. 5 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-storyformats-spec.md Twine 2 Story Formats Specification} 6 | * @param {StoryFormat} storyFormat Story format object to compile. 7 | * @returns {string} JSONP string. 8 | */ 9 | function compile (storyFormat) { 10 | // Test if storyFormat is a StoryFormat object. 11 | if (!(storyFormat instanceof StoryFormat)) { 12 | throw new TypeError('Error: Incoming object is not a storyFormat object'); 13 | } 14 | 15 | // Create a JSONP string wrapped with the function window.StoryFormat. 16 | return `window.storyFormat(${JSON.stringify(storyFormat)})`; 17 | } 18 | 19 | export { compile }; -------------------------------------------------------------------------------- /src/IFID/generate-web.js: -------------------------------------------------------------------------------- 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 }; 21 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'videlais/extwee' 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444713e90b7 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' 20 | run: gh pr merge --auto --merge "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | -------------------------------------------------------------------------------- /types/Twine2HTML/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse Twine 2 HTML into Story object. 3 | * 4 | * See: Twine 2 HTML Output Specification 5 | * (https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md) 6 | * 7 | * Produces warnings for: 8 | * - Missing name attribute on `` element. 9 | * - Missing IFID attribute on `` element. 10 | * - Malformed IFID attribute on `` element. 11 | * @function parse 12 | * @param {string} content - Twine 2 HTML content to parse. 13 | * @returns {Story} Story object based on Twine 2 HTML content. 14 | * @throws {TypeError} Content is not a string. 15 | * @throws {Error} Not Twine 2 HTML content! 16 | * @throws {Error} Cannot parse passage data without name! 17 | * @throws {Error} Passages are required to have PID! 18 | */ 19 | export function parse(content: string): Story; 20 | import { Story } from '../Story.js'; 21 | -------------------------------------------------------------------------------- /types/src/Twine2HTML/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse Twine 2 HTML into Story object. 3 | * 4 | * See: Twine 2 HTML Output Specification 5 | * (https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md) 6 | * 7 | * Produces warnings for: 8 | * - Missing name attribute on `` element. 9 | * - Missing IFID attribute on `` element. 10 | * - Malformed IFID attribute on `` element. 11 | * @function parse 12 | * @param {string} content - Twine 2 HTML content to parse. 13 | * @returns {Story} Story object based on Twine 2 HTML content. 14 | * @throws {TypeError} Content is not a string. 15 | * @throws {Error} Not Twine 2 HTML content! 16 | * @throws {Error} Cannot parse passage data without name! 17 | * @throws {Error} Passages are required to have PID! 18 | */ 19 | export function parse(content: string): Story; 20 | import { Story } from '../Story.js'; 21 | -------------------------------------------------------------------------------- /src/Web/web-tws.js: -------------------------------------------------------------------------------- 1 | // TWS parser module 2 | import { parse as parseTWS } from '../TWS/parse.js'; 3 | 4 | // Create UMD-compatible export object 5 | const Extwee = { 6 | parseTWS, 7 | parse: parseTWS // For module consistency 8 | }; 9 | 10 | // Export for webpack UMD build 11 | export default Extwee; 12 | 13 | // Also export individual functions for ES6 module usage 14 | export { 15 | parseTWS as parse 16 | }; 17 | 18 | // Add to global Extwee object for direct usage 19 | const globalObject = (function() { 20 | if (typeof globalThis !== 'undefined') return globalThis; 21 | if (typeof window !== 'undefined') return window; 22 | if (typeof global !== 'undefined') return global; 23 | if (typeof self !== 'undefined') return self; 24 | return null; 25 | })(); 26 | 27 | if (globalObject) { 28 | globalObject.Extwee = globalObject.Extwee || {}; 29 | globalObject.Extwee.parseTWS = parseTWS; 30 | } 31 | -------------------------------------------------------------------------------- /test/Roundtrip/Files/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Leon Arnott 2 | 3 | This software is provided 'as-is', without any express or implied warranty. In 4 | no event will the authors be held liable for any damages arising from the use 5 | of this software. 6 | 7 | Permission is granted to anyone to use this software for any purpose, including 8 | commercial applications, and to alter it and redistribute it freely, subject to 9 | the following restrictions: 10 | 11 | 1. The origin of this software must not be misrepresented; you must not claim 12 | that you wrote the original software. If you use this software in a product, an 13 | acknowledgment in the product documentation would be appreciated but is not 14 | required. 15 | 16 | 2. Altered source versions must be plainly marked as such, and must not be 17 | misrepresented as being the original software. 18 | 19 | 3. This notice may not be removed or altered from any source distribution. 20 | -------------------------------------------------------------------------------- /src/extwee.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | /** 4 | * @file CLI for Extwee 5 | * @author Dan Cox 6 | */ 7 | 8 | import { CommandLineProcessing } from "./CLI/CommandLineProcessing.js"; 9 | import { ConfigFilePresent, ConfigFileProcessing } from "./CLI/ProcessConfig.js"; 10 | 11 | /** 12 | * As a command-line tool, Extwee can be invoked multiple ways. 13 | * (1) Via NPX with command-line arguments. (process.argv.length > 2) 14 | * (2) Via NPX in the presence of a `extwee.config.json` file. 15 | */ 16 | 17 | // Check if the command line arguments are present. 18 | if(process.argv.length > 2) { 19 | // Process the command line arguments. 20 | CommandLineProcessing(process.argv); 21 | // Exit the process. 22 | process.exit(0); 23 | } 24 | 25 | // Check if the config file exists. 26 | if(ConfigFilePresent()) { 27 | // Process the config file. 28 | ConfigFileProcessing(); 29 | // Exit the process. 30 | process.exit(0); 31 | } -------------------------------------------------------------------------------- /docs/examples/twsToTwee.md: -------------------------------------------------------------------------------- 1 | # TWS To Twee 2 | 3 | Converting from TWS to Twee 3 is similar to many other conversion processes with one small difference. TWS conversion needs to be begin from the [**Buffer** data type in JavaScript](https://nodejs.org/api/buffer.html). 4 | 5 | 1. Read the binary file. 6 | 2. Convert the binary files into a Buffer. 7 | 3. Parse the Buffer into a Story. 8 | 4. Convert Story data into Twee. 9 | 10 | ```javascript 11 | // Import only readFileSynce() and writeFileSync(). 12 | import { readFileSync, writeFileSync } from 'node:fs'; 13 | // Only import Story and parseTWS(). 14 | import { Story, parseTWS } from 'extwee'; 15 | 16 | // Read the file contents using binary encoding. 17 | const contents = readFileSync( 'Example1.tws', 'binary' ); 18 | // Convert from binary into Buffer. 19 | const b = Buffer.from( contents, 'binary' ); 20 | // convert TWS into Story. 21 | const s = parseTWS( b ); 22 | 23 | // Write Twee to output file. 24 | writeFileSync( 'example.twee', s.toTwee() ); 25 | ``` 26 | -------------------------------------------------------------------------------- /types/src/Twine2HTML/parse-web.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Web-optimized Twine 2 HTML parser with reduced dependencies 3 | * Parse Twine 2 HTML into Story object using lightweight DOM parsing 4 | * 5 | * See: Twine 2 HTML Output Specification 6 | * (https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md) 7 | * 8 | * Produces warnings for: 9 | * - Missing name attribute on `` element. 10 | * - Missing IFID attribute on `` element. 11 | * - Malformed IFID attribute on `` element. 12 | * @function parse 13 | * @param {string} content - Twine 2 HTML content to parse. 14 | * @returns {Story} Story object based on Twine 2 HTML content. 15 | * @throws {TypeError} Content is not a string. 16 | * @throws {Error} Not Twine 2 HTML content! 17 | * @throws {Error} Cannot parse passage data without name! 18 | * @throws {Error} Passages are required to have PID! 19 | */ 20 | export function parse(content: string): Story; 21 | import { Story } from '../Story.js'; 22 | -------------------------------------------------------------------------------- /types/src/Web/web-core.d.ts: -------------------------------------------------------------------------------- 1 | export default Extwee; 2 | import { parse as parseTwee } from '../Twee/parse.js'; 3 | import { parse as parseJSON } from '../JSON/parse.js'; 4 | import { parse as parseStoryFormat } from '../StoryFormat/parse.js'; 5 | import { parse as parseTwine2HTML } from '../Twine2HTML/parse-web.js'; 6 | import { compile as compileTwine2HTML } from '../Twine2HTML/compile.js'; 7 | import { generate as generateIFID } from '../IFID/generate.js'; 8 | import { Story } from '../Story.js'; 9 | import Passage from '../Passage.js'; 10 | import StoryFormat from '../StoryFormat.js'; 11 | declare namespace Extwee { 12 | export { parseTwee }; 13 | export { parseJSON }; 14 | export { parseStoryFormat }; 15 | export { parseTwine2HTML }; 16 | export { compileTwine2HTML }; 17 | export { generateIFID }; 18 | export { Story }; 19 | export { Passage }; 20 | export { StoryFormat }; 21 | export let version: string; 22 | } 23 | export { parseTwee, parseJSON, parseStoryFormat, parseTwine2HTML, compileTwine2HTML, generateIFID, Story, Passage, StoryFormat }; 24 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dan Cox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/Twine2ArchiveHTML/Twine2ArchiveHTMLParser/test1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /types/Twine1HTML/compile.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Write a combination of Story object, `engine.js` (from Twine 1), `header.html`, and optional `code.js`. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 4 | * @function compile 5 | * @param {Story} story - Story object to write. 6 | * @param {string} engine - Source of `engine.js` file from Twine 1. 7 | * @param {string} header - `header.html` content for Twine 1 story format. 8 | * @param {string} name - Name of the story format (needed for `code.js` inclusion). 9 | * @param {string} codeJS - `code.js` content with additional JavaScript. 10 | * @param {object} config - Limited configuration object acting in place of `StorySettings`. 11 | * @param {string} config.jquery - jQuery source. 12 | * @param {string} config.modernizr - Modernizr source. 13 | * @returns {string} Twine 1 HTML. 14 | */ 15 | export function compile(story: Story, engine?: string, header?: string, name?: string, codeJS?: string, config?: { 16 | jquery: string; 17 | modernizr: string; 18 | }): string; 19 | import { Story } from '../Story.js'; 20 | -------------------------------------------------------------------------------- /types/src/Twine1HTML/compile.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Write a combination of Story object, `engine.js` (from Twine 1), `header.html`, and optional `code.js`. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 4 | * @function compile 5 | * @param {Story} story - Story object to write. 6 | * @param {string} engine - Source of `engine.js` file from Twine 1. 7 | * @param {string} header - `header.html` content for Twine 1 story format. 8 | * @param {string} name - Name of the story format (needed for `code.js` inclusion). 9 | * @param {string} codeJS - `code.js` content with additional JavaScript. 10 | * @param {object} config - Limited configuration object acting in place of `StorySettings`. 11 | * @param {string} config.jquery - jQuery source. 12 | * @param {string} config.modernizr - Modernizr source. 13 | * @returns {string} Twine 1 HTML. 14 | */ 15 | export function compile(story: Story, engine?: string, header?: string, name?: string, codeJS?: string, config?: { 16 | jquery: string; 17 | modernizr: string; 18 | }): string; 19 | import { Story } from '../Story.js'; 20 | -------------------------------------------------------------------------------- /src/Config/reader.js: -------------------------------------------------------------------------------- 1 | import {readFileSync, existsSync} from 'node:fs'; 2 | 3 | /** 4 | * Read a JSON file and return its contents. 5 | * @param {string} path Path to the JSON file. 6 | * @returns {object} Parsed JSON object. 7 | * @throws {Error} If the file does not exist. 8 | * @throws {Error} If the file is not a valid JSON file. 9 | * @example 10 | * const contents = reader('test/Config/files/valid.json'); 11 | * console.log(contents); // {"story-format": 'Harlowe', "story-title": "My Story"} 12 | */ 13 | export function reader(path) { 14 | // Does the file exist? 15 | if (!existsSync(path)) { 16 | throw new Error(`Error: File ${path} not found`); 17 | } 18 | 19 | // Read the file. 20 | const contents = readFileSync(path, 'utf8'); 21 | 22 | // Parsed contents. 23 | let parsedContents = null; 24 | 25 | // Try to parse the contents into JSON object. 26 | try { 27 | parsedContents = JSON.parse(contents); 28 | } catch (error) { 29 | throw new Error(`Error: File ${path} is not a valid JSON file. ${error.message}`); 30 | } 31 | 32 | // Return the parsed contents. 33 | return parsedContents; 34 | } -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/missingSize.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tags 6 | 7 | 8 | 9 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | node-version: [20.x, 22.x, 23.x] 16 | os: [ubuntu-latest, windows-latest, macos-latest] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Run linter 31 | run: npm run lint 32 | 33 | - name: Run test linter 34 | run: npm run lint:test 35 | 36 | - name: Run tests 37 | run: npm test 38 | env: 39 | CI: true 40 | 41 | - name: Build 42 | run: npm run build:web 43 | 44 | - name: Upload coverage to Codecov 45 | if: matrix.node-version == '20.x' && matrix.os == 'ubuntu-latest' 46 | uses: codecov/codecov-action@v4 47 | with: 48 | file: ./coverage/lcov.info 49 | fail_ci_if_error: false 50 | -------------------------------------------------------------------------------- /test/CLI/files/twineExample.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | twineExample 6 | 7 | 8 | 9 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /docs/examples/dynamicPassages.md: -------------------------------------------------------------------------------- 1 | # Dynamically Generating Passages 2 | 3 | Through using the API, it is possible to dynamically create passages and then export this story into Twee (or JSON) using only the **Story** and **Passage** objects. 4 | 5 | Both **Story** and **Passage** objects can be created through the `new` keyword in JavaScript. After creating a **Story**, the use of the method `addPassage(Passage)` can be used to add new passages. 6 | 7 | In the following example, the use of a `for()` loop is used to generate 20 passages and a starting passage is set. Finally, the **Story** data is converted into Twee and written to an output file. 8 | 9 | ```javascript 10 | // Import only Story and Passage. 11 | import { Story, Passage } from 'extwee'; 12 | // Import only writeFileSync() for writing to files. 13 | import { writeFileSync } from 'node:fs'; 14 | 15 | // Create the story. 16 | const example = new Story( 'Example' ); 17 | 18 | // Generate 20 passages and add them to the story. 19 | for(let i = 0; i < 20; i++) { 20 | example.addPassage( new Passage( `Passage ${i}`, 'Some Text') ); 21 | } 22 | 23 | // Set a starting passage. 24 | example.start = 'Passage 1'; 25 | 26 | // Create a Twee file. 27 | writeFileSync( 'example.twee', example.toTwee() ); 28 | ``` 29 | -------------------------------------------------------------------------------- /src/Web/web-twine1html.js: -------------------------------------------------------------------------------- 1 | // Twine1HTML parser module 2 | import { parse as parseTwine1HTML } from '../Twine1HTML/parse-web.js'; 3 | import { compile as compileTwine1HTML } from '../Twine1HTML/compile.js'; 4 | 5 | // Create UMD-compatible export object 6 | const Extwee = { 7 | parseTwine1HTML, 8 | compileTwine1HTML, 9 | parse: parseTwine1HTML, // For module consistency 10 | compile: compileTwine1HTML // For module consistency 11 | }; 12 | 13 | // Export for webpack UMD build 14 | export default Extwee; 15 | 16 | // Also export individual functions for ES6 module usage 17 | export { 18 | parseTwine1HTML as parse, 19 | compileTwine1HTML as compile 20 | }; 21 | 22 | // Add to global Extwee object for direct usage 23 | const globalObject = (function() { 24 | if (typeof globalThis !== 'undefined') return globalThis; 25 | if (typeof window !== 'undefined') return window; 26 | if (typeof global !== 'undefined') return global; 27 | if (typeof self !== 'undefined') return self; 28 | return null; 29 | })(); 30 | 31 | if (globalObject) { 32 | globalObject.Extwee = globalObject.Extwee || {}; 33 | globalObject.Extwee.parseTwine1HTML = parseTwine1HTML; 34 | globalObject.Extwee.compileTwine1HTML = compileTwine1HTML; 35 | } 36 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/lyingStartnode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | twineExample 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { parse as parseTwee } from './src/Twee/parse.js'; 2 | import { parse as parseJSON } from './src/JSON/parse.js'; 3 | import { parse as parseTWS } from './src/TWS/parse.js'; 4 | import { parse as parseStoryFormat } from './src/StoryFormat/parse.js'; 5 | import { parse as parseTwine1HTML } from './src/Twine1HTML/parse.js'; 6 | import { parse as parseTwine2HTML } from './src/Twine2HTML/parse.js'; 7 | import { parse as parseTwine2ArchiveHTML } from './src/Twine2ArchiveHTML/parse.js'; 8 | import { compile as compileTwine1HTML } from './src/Twine1HTML/compile.js'; 9 | import { compile as compileTwine2HTML } from './src/Twine2HTML/compile.js'; 10 | import { compile as compileTwine2ArchiveHTML } from './src/Twine2ArchiveHTML/compile.js'; 11 | import { compile as compileStoryFormat } from './src/StoryFormat/compile.js'; 12 | import { generate as generateIFID } from './src/IFID/generate.js'; 13 | import { Story } from './src/Story.js'; 14 | import Passage from './src/Passage.js'; 15 | import StoryFormat from './src/StoryFormat.js'; 16 | export { parseTwee, parseJSON, parseTWS, parseStoryFormat, parseTwine1HTML, parseTwine2HTML, parseTwine2ArchiveHTML, compileTwine1HTML, compileTwine2HTML, compileTwine2ArchiveHTML, compileStoryFormat, generateIFID, Story, Passage, StoryFormat }; 17 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/twineExample2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | twineExample 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/twineExample3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | twineExample 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /types/src/CLI/ProcessConfig/loadStoryFormat.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Load the story format from the story-formats directory. 3 | * @function loadStoryFormat 4 | * @description This function loads the story format from the story-formats directory. 5 | * It checks if the story-formats directory exists, if the named story format exists, 6 | * if the version directory exists, and if the format.js file exists. 7 | * If any of these checks fail, the function will exit the process with an error message. 8 | * If all checks pass, the function will return the contents of the format.js file. 9 | * @param {string} storyFormatName - The name of the story format. 10 | * @param {string} storyFormatVersion - The version of the story format. 11 | * @returns {string} - The contents of the format.js file. 12 | * @throws {Error} - If the story-formats directory does not exist, if the named story format does not exist, 13 | * if the version directory does not exist, or if the format.js file does not exist. 14 | * @example 15 | * // Load the story format from the story-formats directory. 16 | * const storyFormat = loadStoryFormat('Harlowe', '3.2.0'); 17 | * console.log(storyFormat); 18 | * // Output: The contents of the format.js file. 19 | */ 20 | export function loadStoryFormat(storyFormatName: string, storyFormatVersion: string): string; 21 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/twineExample.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | twineExample 6 | 7 | 8 | 9 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /test/Twine2ArchiveHTML/Twine2ArchiveHTML.Compile.test.js: -------------------------------------------------------------------------------- 1 | import { compile as compileTwine2ArchiveHTML } from '../../src/Twine2ArchiveHTML/compile.js'; 2 | import { Story } from '../../src/Story.js'; 3 | import Passage from '../../src/Passage.js'; 4 | 5 | describe('Twine2ArchiveHTML', function () { 6 | describe('compile()', function () { 7 | it('Should throw error if stories is not an array', function () { 8 | expect(() => { compileTwine2ArchiveHTML({}); }).toThrow(); 9 | }); 10 | 11 | it('Should throw error array does not contain stories', function () { 12 | expect(() => { compileTwine2ArchiveHTML([1]); }).toThrow(); 13 | }); 14 | 15 | it('Should write one Story object', function () { 16 | // Create an array of Stories. 17 | const s = new Story('Test1'); 18 | 19 | // Add a passage (as Start passage) 20 | s.addPassage(new Passage('Start', 'Work')); 21 | 22 | // Set startingPassage. 23 | s.start = 'Start'; 24 | 25 | // Write to file. 26 | const result = compileTwine2ArchiveHTML([s]); 27 | 28 | // Test for story name. 29 | expect(result.includes('tw-storydata name="Test1"')).toBe(true); 30 | 31 | // Test for passage. 32 | expect(result.includes('tw-passagedata pid="1" name="Start"')).toBe(true); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/Web/web-twine2archive.js: -------------------------------------------------------------------------------- 1 | // Twine2ArchiveHTML parser module 2 | import { parse as parseTwine2ArchiveHTML } from '../Twine2ArchiveHTML/parse-web.js'; 3 | import { compile as compileTwine2ArchiveHTML } from '../Twine2ArchiveHTML/compile.js'; 4 | 5 | // Create UMD-compatible export object 6 | const Extwee = { 7 | parseTwine2ArchiveHTML, 8 | compileTwine2ArchiveHTML, 9 | parse: parseTwine2ArchiveHTML, // For module consistency 10 | compile: compileTwine2ArchiveHTML // For module consistency 11 | }; 12 | 13 | // Export for webpack UMD build 14 | export default Extwee; 15 | 16 | // Also export individual functions for ES6 module usage 17 | export { 18 | parseTwine2ArchiveHTML as parse, 19 | compileTwine2ArchiveHTML as compile 20 | }; 21 | 22 | // Add to global Extwee object for direct usage 23 | const globalObject = (function() { 24 | if (typeof globalThis !== 'undefined') return globalThis; 25 | if (typeof window !== 'undefined') return window; 26 | if (typeof global !== 'undefined') return global; 27 | if (typeof self !== 'undefined') return self; 28 | return null; 29 | })(); 30 | 31 | if (globalObject) { 32 | globalObject.Extwee = globalObject.Extwee || {}; 33 | globalObject.Extwee.parseTwine2ArchiveHTML = parseTwine2ArchiveHTML; 34 | globalObject.Extwee.compileTwine2ArchiveHTML = compileTwine2ArchiveHTML; 35 | } 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { parse as parseTwee } from './src/Twee/parse.js'; 2 | import { parse as parseJSON } from './src/JSON/parse.js'; 3 | import { parse as parseStoryFormat } from './src/StoryFormat/parse.js'; 4 | import { parse as parseTwine1HTML } from './src/Twine1HTML/parse.js'; 5 | import { parse as parseTwine2HTML } from './src/Twine2HTML/parse.js'; 6 | import { parse as parseTwine2ArchiveHTML } from './src/Twine2ArchiveHTML/parse.js'; 7 | import { parse as parseTWS } from './src/TWS/parse.js'; 8 | import { compile as compileTwine1HTML } from './src/Twine1HTML/compile.js'; 9 | import { compile as compileTwine2HTML } from './src/Twine2HTML/compile.js'; 10 | import { compile as compileTwine2ArchiveHTML } from './src/Twine2ArchiveHTML/compile.js'; 11 | import { compile as compileStoryFormat } from './src/StoryFormat/compile.js'; 12 | import { generate as generateIFID } from './src/IFID/generate.js'; 13 | import { Story } from './src/Story.js'; 14 | import Passage from './src/Passage.js'; 15 | import StoryFormat from './src/StoryFormat.js'; 16 | 17 | export { 18 | parseTwee, 19 | parseJSON, 20 | parseTWS, 21 | parseStoryFormat, 22 | parseTwine1HTML, 23 | parseTwine2HTML, 24 | parseTwine2ArchiveHTML, 25 | compileTwine1HTML, 26 | compileTwine2HTML, 27 | compileTwine2ArchiveHTML, 28 | compileStoryFormat, 29 | generateIFID, 30 | Story, 31 | Passage, 32 | StoryFormat 33 | }; 34 | -------------------------------------------------------------------------------- /types/src/Twee/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses Twee 3 text into a Story object. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twee-3-specification.md Twee 3 Specification} 4 | * @function parse 5 | * @param {string} fileContents - File contents to parse 6 | * @returns {Story} story 7 | */ 8 | export function parse(fileContents: string): Story; 9 | /** 10 | * Escapes Twee 3 metacharacters according to the specification. 11 | * This is used when writing Twee files to ensure special characters are properly escaped. 12 | * @function escapeTweeMetacharacters 13 | * @param {string} text - Text to escape 14 | * @returns {string} Escaped text 15 | */ 16 | export function escapeTweeMetacharacters(text: string): string; 17 | /** 18 | * Unescapes Twee 3 metacharacters according to the specification. 19 | * 20 | * From the Twee 3 specification: 21 | * - Encoding: To avoid ambiguity, non-escape backslashes must also be escaped via 22 | * the same mechanism (i.e. `foo\bar` must become `foo\\bar`). 23 | * - Decoding: To make decoding more robust, any escaped character within a chunk of 24 | * encoded text must yield the character minus the backslash (i.e. `\q` must yield `q`). 25 | * @function unescapeTweeMetacharacters 26 | * @param {string} text - Text to unescape 27 | * @returns {string} Unescaped text 28 | */ 29 | export function unescapeTweeMetacharacters(text: string): string; 30 | import { Story } from '../Story.js'; 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import webpack from 'webpack'; 3 | 4 | export default { 5 | mode: 'production', 6 | entry: { 7 | // Core bundle with most common functionality 8 | 'extwee.core': './src/Web/web-core.js', 9 | // Individual parser modules 10 | 'extwee.twine1html': './src/Web/web-twine1html.js', 11 | 'extwee.twine2archive': './src/Web/web-twine2archive.js', 12 | 'extwee.tws': './src/Web/web-tws.js' 13 | }, 14 | output: { 15 | path: path.resolve('./', 'build'), 16 | filename: '[name].min.js', 17 | library: { 18 | type: 'umd', 19 | name: 'Extwee', // Use a single library name for all modules 20 | export: 'default' // Export the default export directly 21 | }, 22 | globalObject: 'this' 23 | }, 24 | plugins: [ 25 | // Replace Node.js IFID generator with browser version for web builds 26 | new webpack.NormalModuleReplacementPlugin( 27 | /src[\\/]IFID[\\/]generate\.js$/, 28 | './generate-web.js' 29 | ) 30 | ], 31 | resolve: { 32 | fallback: { 33 | // Exclude Node.js core modules from browser builds 34 | 'crypto': false 35 | } 36 | }, 37 | optimization: { 38 | usedExports: true, 39 | sideEffects: false, 40 | splitChunks: false // Don't split chunks for individual modules 41 | }, 42 | performance: { 43 | maxAssetSize: 250000, // 244 KiB 44 | maxEntrypointSize: 250000, 45 | hints: 'warning' 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /.github/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | paths-ignore: 7 | - 'test/**' 8 | pull_request: 9 | branches: [ develop ] 10 | paths-ignore: 11 | - 'test/**' 12 | 13 | jobs: 14 | analyze: 15 | name: Analyze 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | language: [ 'javascript' ] 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v3 26 | 27 | - name: Initialize CodeQL 28 | uses: github/codeql-action/init@v2 29 | with: 30 | languages: ${{ matrix.language }} 31 | 32 | - name: Perform CodeQL Analysis 33 | uses: github/codeql-action/analyze@v2 34 | with: 35 | upload: false # disable the upload here - we will upload in a different action 36 | output: sarif-results 37 | 38 | - name: filter-sarif 39 | uses: advanced-security/filter-sarif@v1 40 | with: 41 | # filter out all test files unless they contain a sql-injection vulnerability 42 | patterns: 43 | -test/**/* 44 | +test/**/*:js/sql-injection 45 | input: sarif-results/${{ matrix.language }}.sarif 46 | output: sarif-results/${{ matrix.language }}.sarif 47 | 48 | - name: Upload SARIF 49 | uses: github/codeql-action/upload-sarif@v2 50 | with: 51 | sarif_file: sarif-results/${{ matrix.language }}.sarif 52 | -------------------------------------------------------------------------------- /test/Twine2ArchiveHTML/Twine2ArchiveHTML.Parse.test.js: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | import { parse as parseTwine2ArchiveHTML } from '../../src/Twine2ArchiveHTML/parse.js'; 3 | import { readFileSync } from 'node:fs'; 4 | 5 | describe('Twine2ArchiveHTML', function () { 6 | describe('parse()', function () { 7 | it('Should throw error when content is not string', function () { 8 | expect(() => { parseTwine2ArchiveHTML(2); }).toThrow(); 9 | }); 10 | 11 | it('Should produce two stories', function () { 12 | // Read Twine 2 Archive. 13 | const archiveFile = readFileSync('test/Twine2ArchiveHTML/Twine2ArchiveHTMLParser/test1.html', 'utf-8'); 14 | 15 | // Parse Twine 2 Story. 16 | const stories = parseTwine2ArchiveHTML(archiveFile); 17 | 18 | // Expect two stories. 19 | expect(stories.length).toBe(2); 20 | }); 21 | }); 22 | 23 | describe('Warnings', function () { 24 | beforeEach(() => { 25 | // Mock console.warn. 26 | jest.spyOn(console, 'warn').mockImplementation(); 27 | }); 28 | 29 | afterEach(() => { 30 | // Restore all mocks. 31 | jest.restoreAllMocks(); 32 | }); 33 | 34 | it('Should produce warning when no Twine 2 HTML content is found', function () { 35 | // Parse Twine 2 Story. 36 | parseTwine2ArchiveHTML(''); 37 | 38 | // Expect warning. 39 | expect(console.warn).toHaveBeenCalledWith('Warning: No Twine 2 HTML content found!'); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /docs/objects/passage.md: -------------------------------------------------------------------------------- 1 | # Passage 2 | 3 | The smallest unit in Twine is a *passage*. 4 | 5 | ## Properties 6 | 7 | When using the Extwee API, a **Passage** object holds four properties: 8 | 9 | - name ( string ) Name of the passage. 10 | - text ( string ) Content of the passage. 11 | - tags ( array(string) ) Array of tags associated with the passage. 12 | - Metadata ( object ) Key-value pairs associated with the passage. 13 | 14 | ### Property Example 15 | 16 | ```javascript 17 | import { Passage } from 'extwee'; 18 | 19 | // Create a single passage. 20 | const example = new Passage('Example', 'Some text'); 21 | 22 | // Output current passage text. 23 | console.log( example.text ); 24 | ``` 25 | 26 | ## Methods 27 | 28 | Each passage is capable of producing multiple formats of its internal data: 29 | 30 | - `toTwee()`: Converts passage data into Twee 3. 31 | - `toJSON()`: Converts passage data into Twine 2 JSON. 32 | - `toTwine2HTML()`: Converts passage data into Twine 2 HTML. 33 | - `toTwine1HTML()`: Converts passage data into Twine 1 HTML. 34 | 35 | ### Method Example 36 | 37 | ```javascript 38 | import { Passage } from 'extwee'; 39 | 40 | // Create a single passage. 41 | const example = new Passage('Example', 'Some text'); 42 | 43 | // Convert to Twee 3. 44 | console.log( example.toTwee() ); 45 | ``` 46 | 47 | **Note:** While each passage can create different representations of its data, each conversion is considered partial without the corresponding story metadata or story format to create the playable form. 48 | -------------------------------------------------------------------------------- /types/Twine2ArchiveHTML/compile.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Write array of Story objects into Twine 2 Archive HTML. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md Twine 2 Archive Specification} 4 | * @function compile 5 | * @param {Array} stories - Array of Story objects. 6 | * @returns {string} Twine 2 Archive HTML. 7 | * @example 8 | * const story1 = new Story(); 9 | * const story2 = new Story(); 10 | * const stories = [story1, story2]; 11 | * console.log(compile(stories)); 12 | * // => '\n\n\n\n' 13 | */ 14 | export function compile(stories: any[]): string; 15 | -------------------------------------------------------------------------------- /types/src/Twine2ArchiveHTML/compile.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Write array of Story objects into Twine 2 Archive HTML. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md Twine 2 Archive Specification} 4 | * @function compile 5 | * @param {Array} stories - Array of Story objects. 6 | * @returns {string} Twine 2 Archive HTML. 7 | * @example 8 | * const story1 = new Story(); 9 | * const story2 = new Story(); 10 | * const stories = [story1, story2]; 11 | * console.log(compile(stories)); 12 | * // => '\n\n\n\n' 13 | */ 14 | export function compile(stories: any[]): string; 15 | -------------------------------------------------------------------------------- /types/JSON/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse JSON representation into Story. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-jsonoutput-doc.md Twine 2 JSON Specification} 4 | * @function parse 5 | * @param {string} jsonString - JSON string to convert to Story. 6 | * @throws {Error} - Invalid JSON! 7 | * @returns {Story} Story object. 8 | * @example 9 | * const jsonString = `{ 10 | * "name": "My Story", 11 | * "start": "First Passage", 12 | * "ifid": "A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6", 13 | * "format": "SugarCube", 14 | * "formatVersion": "2.31.0", 15 | * "creator": "Twine", 16 | * "creatorVersion": "2.3.9", 17 | * "zoom": 1, 18 | * "passages": [ 19 | * { 20 | * "name": "First Passage", 21 | * "tags": "", 22 | * "metadata": "", 23 | * "text": "This is the first passage." 24 | * }, 25 | * ] 26 | * }`; 27 | * const story = parse(jsonString); 28 | * console.log(story); 29 | * // => Story { 30 | * // name: 'My Story', 31 | * // start: 'First Passage', 32 | * // IFID: 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6', 33 | * // format: 'SugarCube', 34 | * // formatVersion: '2.31.0', 35 | * // creator: 'Twine', 36 | * // creatorVersion: '2.3.9', 37 | * // zoom: 1, 38 | * // tagColors: {}, 39 | * // metadata: {}, 40 | * // passages: [ 41 | * // Passage { 42 | * // name: 'First Passage', 43 | * // tags: '', 44 | * // metadata: '', 45 | * // text: 'This is the first passage.' 46 | * // } 47 | * // ] 48 | * // } 49 | */ 50 | export function parse(jsonString: string): Story; 51 | import { Story } from '../Story.js'; 52 | -------------------------------------------------------------------------------- /types/src/JSON/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse JSON representation into Story. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-jsonoutput-doc.md Twine 2 JSON Specification} 4 | * @function parse 5 | * @param {string} jsonString - JSON string to convert to Story. 6 | * @throws {Error} - Invalid JSON! 7 | * @returns {Story} Story object. 8 | * @example 9 | * const jsonString = `{ 10 | * "name": "My Story", 11 | * "start": "First Passage", 12 | * "ifid": "A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6", 13 | * "format": "SugarCube", 14 | * "formatVersion": "2.31.0", 15 | * "creator": "Twine", 16 | * "creatorVersion": "2.3.9", 17 | * "zoom": 1, 18 | * "passages": [ 19 | * { 20 | * "name": "First Passage", 21 | * "tags": "", 22 | * "metadata": "", 23 | * "text": "This is the first passage." 24 | * }, 25 | * ] 26 | * }`; 27 | * const story = parse(jsonString); 28 | * console.log(story); 29 | * // => Story { 30 | * // name: 'My Story', 31 | * // start: 'First Passage', 32 | * // IFID: 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6', 33 | * // format: 'SugarCube', 34 | * // formatVersion: '2.31.0', 35 | * // creator: 'Twine', 36 | * // creatorVersion: '2.3.9', 37 | * // zoom: 1, 38 | * // tagColors: {}, 39 | * // metadata: {}, 40 | * // passages: [ 41 | * // Passage { 42 | * // name: 'First Passage', 43 | * // tags: '', 44 | * // metadata: '', 45 | * // text: 'This is the first passage.' 46 | * // } 47 | * // ] 48 | * // } 49 | */ 50 | export function parse(jsonString: string): Story; 51 | import { Story } from '../Story.js'; 52 | -------------------------------------------------------------------------------- /docs/objects/storyformat.md: -------------------------------------------------------------------------------- 1 | # Story Format 2 | 3 | For a story to be playable in Twine, it must be combined with a *story format*. 4 | 5 | In Twine 1, story formats can exist across multiple files: 6 | 7 | - `engine.js`: "engine" as included from Twine 1. 8 | - `header.html`: HTML "header" the Twine 1 HTML will be added into to create published file. 9 | - `code.js`: While technically optional, most story formats include extra code found in a `code.js` file. 10 | 11 | In Twine 2, story formats are single files generally named `format.js`. These are [JSONP files](https://en.wikipedia.org/wiki/JSONP) with JSON data of the story format included within a function call payload. 12 | 13 | ## Properties 14 | 15 | When working with Twine 2 story formats, the associated `parse()` function call be used to create a **StoryFormat** object with the following properties: 16 | 17 | - name ( string ) Name of the story format. 18 | - version ( string ) Semantic version. 19 | - author ( string ) Author of the story format. 20 | - description ( string ) Summary of the story format. 21 | - image ( string ) URL of an image (ideally SVG) acting as its icon in Twine. 22 | - url ( string ) URL of the directory containing the `format.js` file. 23 | - license ( string ) License acronym and sometimes version. 24 | - proofing ( boolean ) (defaults to false). True if the story format is a "proofing" format. 25 | - source ( string ) Full HTML output of the story format including the two placeholders {{STORY_NAME}} and {{STORY_DATA}}. (The placeholders are not themselves required.) 26 | 27 | **Note:** Generally, even when using the Extwee API, it is rare to work with story formats beyond parsing them as part of Twine 1 or Twine 2 HTML compilation. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for Extwee 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ## Feature Description 10 | 11 | A clear and concise description of the feature you'd like to see. 12 | 13 | ## Problem Statement 14 | 15 | What problem does this feature solve? Describe the use case. 16 | 17 | **Example:** "I'm always frustrated when..." 18 | 19 | ## Proposed Solution 20 | 21 | Describe how you envision this feature working. 22 | 23 | **Example:** 24 | 25 | ```bash 26 | # CLI usage 27 | extwee --new-feature input.twee output.html 28 | ``` 29 | 30 | Or for API usage: 31 | 32 | ```javascript 33 | const result = story.newFeature(options); 34 | ``` 35 | 36 | ## Alternatives Considered 37 | 38 | Describe any alternative solutions or features you've considered. 39 | 40 | ## Use Cases 41 | 42 | Describe specific scenarios where this feature would be useful: 43 | 44 | 1. Use case 1 45 | 2. Use case 2 46 | 3. Use case 3 47 | 48 | ## Impact 49 | 50 | Who would benefit from this feature? 51 | 52 | - [ ] CLI users 53 | - [ ] API users 54 | - [ ] Story format developers 55 | - [ ] Story authors 56 | - [ ] All users 57 | 58 | ## Additional Context 59 | 60 | Add any other context, mockups, examples from other tools, or screenshots about the feature request here. 61 | 62 | ## Related Issues 63 | 64 | List any related issues or discussions: 65 | 66 | - #123 67 | - #456 68 | 69 | ## Implementation Considerations 70 | 71 | If you have thoughts on how this could be implemented, share them here: 72 | 73 | - Technical approach 74 | - Backward compatibility concerns 75 | - Performance implications 76 | - Documentation needs 77 | -------------------------------------------------------------------------------- /test/CLI/files/twine1/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Sugarcane is based on: 2 | 3 | TiddlyWiki 1.2.39 by Jeremy Ruston, (jeremy [at] osmosoft [dot] com) 4 | 5 | Published under a BSD open source license 6 | 7 | Copyright (c) Osmosoft Limited 2005 8 | 9 | Redistribution and use in source and binary forms, with or without modification, 10 | are permitted provided that the following conditions are met: 11 | 12 | Redistributions of source code must retain the above copyright notice, this 13 | list of conditions and the following disclaimer. 14 | 15 | Redistributions in binary form must reproduce the above copyright notice, this 16 | list of conditions and the following disclaimer in the documentation and/or other 17 | materials provided with the distribution. 18 | 19 | Neither the name of the Osmosoft Limited nor the names of its contributors may be 20 | used to endorse or promote products derived from this software without specific 21 | prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY 24 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 25 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT 26 | SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 27 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 28 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 29 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 30 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 31 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 32 | DAMAGE. 33 | -------------------------------------------------------------------------------- /src/CLI/ProcessConfig/readDirectories.js: -------------------------------------------------------------------------------- 1 | import { readdirSync } from 'node:fs'; 2 | import { isDirectory } from '../isDirectory.js'; 3 | 4 | /** 5 | * Read the contents of a directory and returns all directories. 6 | * @function readDirectories 7 | * @description This function reads the contents of a directory and returns a list of directories. 8 | * @param {string} directory - The path to the directory to read. 9 | * @returns {Array} - An array of directories in the directory. 10 | * @throws {Error} - If the directory does not exist or if there is an error reading the directory. 11 | */ 12 | export function readDirectories(directory) { 13 | 14 | // Create default response. 15 | let results = []; 16 | 17 | // Check if the directory exists. 18 | const isDir = isDirectory(directory); 19 | // If the directory does not exist, return an empty array 20 | // and log an error message. 21 | if (isDir == false) { 22 | console.error(`Error: Directory ${directory} does not exist.`); 23 | } 24 | 25 | // Read the directory and return the list of files. 26 | try { 27 | results = readdirSync(directory); 28 | } catch (error) { 29 | console.error(`Error reading directory ${directory}:`, error); 30 | results = []; 31 | } 32 | 33 | // Check if results is an array. 34 | // This should not happen, but for some reason it can. 35 | if (!Array.isArray(results)) { 36 | results = []; 37 | } 38 | 39 | // Filter the list to only include directories. 40 | const directoriesOnly = results.filter((item) => { 41 | return isDirectory(`${directory}/${item}`); 42 | }); 43 | 44 | // Return the list of directories. 45 | return directoriesOnly; 46 | } -------------------------------------------------------------------------------- /test/Twine1HTML/Twine1HTMLCompiler/jonah-1.4.2/LICENSE: -------------------------------------------------------------------------------- 1 | Jonah is based on: 2 | 3 | TiddlyWiki 1.2.39 by Jeremy Ruston, (jeremy [at] osmosoft [dot] com) 4 | 5 | Published under a BSD open source license 6 | 7 | Copyright (c) Osmosoft Limited 2005 8 | 9 | Redistribution and use in source and binary forms, with or without modification, 10 | are permitted provided that the following conditions are met: 11 | 12 | Redistributions of source code must retain the above copyright notice, this 13 | list of conditions and the following disclaimer. 14 | 15 | Redistributions in binary form must reproduce the above copyright notice, this 16 | list of conditions and the following disclaimer in the documentation and/or other 17 | materials provided with the distribution. 18 | 19 | Neither the name of the Osmosoft Limited nor the names of its contributors may be 20 | used to endorse or promote products derived from this software without specific 21 | prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY 24 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 25 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT 26 | SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 27 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 28 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 29 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 30 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 31 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 32 | DAMAGE. 33 | -------------------------------------------------------------------------------- /types/Twine2ArchiveHTML/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse Twine 2 Archive HTML and returns an array of story objects. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md Twine 2 Archive Specification} 4 | * @function parse 5 | * @param {string} content - Content to parse for Twine 2 HTML elements. 6 | * @throws {TypeError} - Content is not a string! 7 | * @returns {Array} Array of stories found in content. 8 | * @example 9 | * const content = ''; 10 | * console.log(parse(content)); 11 | * // => [ 12 | * // Story { 13 | * // name: 'Untitled', 14 | * // startnode: '1', 15 | * // creator: 'Twine', 16 | * // creatorVersion: '2.3.9', 17 | * // ifid: 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6', 18 | * // zoom: '1', 19 | * // format: 'Harlowe', 20 | * // formatVersion: '3.1.0', 21 | * // options: '', 22 | * // hidden: '', 23 | * // passages: [ 24 | * // Passage { 25 | * // pid: '1', 26 | * // name: 'Untitled Passage', 27 | * // tags: '', 28 | * // position: '0,0', 29 | * // size: '100,100', 30 | * // text: '' 31 | * // } 32 | * // ] 33 | * // } 34 | * // ] 35 | */ 36 | export function parse(content: string): any[]; 37 | -------------------------------------------------------------------------------- /types/src/Twine2ArchiveHTML/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse Twine 2 Archive HTML and returns an array of story objects. 3 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md Twine 2 Archive Specification} 4 | * @function parse 5 | * @param {string} content - Content to parse for Twine 2 HTML elements. 6 | * @throws {TypeError} - Content is not a string! 7 | * @returns {Array} Array of stories found in content. 8 | * @example 9 | * const content = ''; 10 | * console.log(parse(content)); 11 | * // => [ 12 | * // Story { 13 | * // name: 'Untitled', 14 | * // startnode: '1', 15 | * // creator: 'Twine', 16 | * // creatorVersion: '2.3.9', 17 | * // ifid: 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6', 18 | * // zoom: '1', 19 | * // format: 'Harlowe', 20 | * // formatVersion: '3.1.0', 21 | * // options: '', 22 | * // hidden: '', 23 | * // passages: [ 24 | * // Passage { 25 | * // pid: '1', 26 | * // name: 'Untitled Passage', 27 | * // tags: '', 28 | * // position: '0,0', 29 | * // size: '100,100', 30 | * // text: '' 31 | * // } 32 | * // ] 33 | * // } 34 | * // ] 35 | */ 36 | export function parse(content: string): any[]; 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve Extwee 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Bug Description 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | ## To Reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Create a file with '...' 18 | 2. Run command '....' 19 | 3. See error '....' 20 | 21 | ## Expected Behavior 22 | 23 | A clear and concise description of what you expected to happen. 24 | 25 | ## Actual Behavior 26 | 27 | A clear and concise description of what actually happened. 28 | 29 | ## Minimal Reproducible Example 30 | 31 | ```javascript 32 | // If applicable, provide the smallest code example that demonstrates the issue 33 | const story = new Story(); 34 | // ... 35 | ``` 36 | 37 | Or provide a minimal Twee file: 38 | 39 | ```twee 40 | :: Start 41 | Content here 42 | ``` 43 | 44 | ## Environment 45 | 46 | - **Extwee Version**: [e.g., 2.3.12] 47 | - **Node.js Version**: [e.g., 20.10.0] (run `node --version`) 48 | - **npm Version**: [e.g., 10.2.3] (run `npm --version`) 49 | - **Operating System**: [e.g., macOS 14.1, Windows 11, Ubuntu 22.04] 50 | - **Installation Method**: [npm, npx, local clone] 51 | 52 | ## Error Messages 53 | 54 | ```text 55 | Paste any error messages here 56 | ``` 57 | 58 | ## Additional Context 59 | 60 | Add any other context about the problem here, such as: 61 | 62 | - Story format being used (Harlowe, SugarCube, Snowman, etc.) 63 | - Input file format (Twee, Twine 1 HTML, Twine 2 HTML, etc.) 64 | - Whether using CLI or programmatic API 65 | - Any relevant configuration settings 66 | 67 | ## Screenshots 68 | 69 | If applicable, add screenshots to help explain your problem. 70 | 71 | ## Possible Solution 72 | 73 | If you have suggestions on how to fix this, please share them here. 74 | -------------------------------------------------------------------------------- /test/Roundtrip/Roundtrip.test.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import { parse as parseTwee } from '../../src/Twee/parse.js'; 3 | import { parse as parseTwine2HTML } from '../../src/Twine2HTML/parse.js'; 4 | import { compile as compileTwine2HTML } from '../../src/Twine2HTML/compile.js'; 5 | import { parse as parseStoryFormat } from '../../src/StoryFormat/parse.js'; 6 | 7 | describe('Round-trip testing', () => { 8 | it('Should round-trip Twine 2 HTML to Twee', () => { 9 | // Read HTML. 10 | const fr = readFileSync('test/Roundtrip/Files/Example1.html', 'utf-8'); 11 | 12 | // Parse HTML. 13 | const s = parseTwine2HTML(fr); 14 | 15 | // Parse the new Twee. 16 | const s2 = parseTwee(s.toTwee()); 17 | 18 | // Twee adds StoryData. 19 | // There will be one extra passage in Twee than HTML. 20 | expect(s2.size()).toBe(s.size()); 21 | 22 | // IFID should be the same 23 | expect(s.ifid).toBe(s2.ifid); 24 | }); 25 | 26 | it('Should round-trip Twee to Twine 2 HTML', () => { 27 | // Read StoryFormat. 28 | const storyFormat = readFileSync('test/Roundtrip/Files/harlowe.js', 'utf-8'); 29 | 30 | // Parse StoryFormat. 31 | const sfp = parseStoryFormat(storyFormat); 32 | 33 | // Read Twee. 34 | const fr = readFileSync('test/Roundtrip/Files/example2.twee', 'utf-8'); 35 | 36 | // Parse Twee. 37 | const story = parseTwee(fr); 38 | 39 | // Write HTML. 40 | const fr2 = compileTwine2HTML(story, sfp); 41 | 42 | // Parse HTML 43 | const story2 = parseTwine2HTML(fr2); 44 | 45 | // Number of passages should be the same, too 46 | expect(story2.size()).toBe(story.size()); 47 | 48 | // IFID should be the same 49 | expect(story.ifid).toBe(story2.ifid); 50 | 51 | // Should have same 'start' name 52 | expect(story.start).toBe(story2.start); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/Web/web-core.js: -------------------------------------------------------------------------------- 1 | // Core web build - Common parsers and functionality 2 | import { parse as parseTwee } from '../Twee/parse.js'; 3 | import { parse as parseJSON } from '../JSON/parse.js'; 4 | import { parse as parseStoryFormat } from '../StoryFormat/parse.js'; 5 | import { parse as parseTwine2HTML } from '../Twine2HTML/parse-web.js'; 6 | import { compile as compileTwine2HTML } from '../Twine2HTML/compile.js'; 7 | import { generate as generateIFID } from '../IFID/generate.js'; 8 | import { Story } from '../Story.js'; 9 | import Passage from '../Passage.js'; 10 | import StoryFormat from '../StoryFormat.js'; 11 | 12 | // Core functionality - most commonly used 13 | const Extwee = { 14 | // Core parsers (immediately available) 15 | parseTwee, 16 | parseJSON, 17 | parseStoryFormat, 18 | parseTwine2HTML, 19 | 20 | // Core compiler 21 | compileTwine2HTML, 22 | 23 | // Core utilities 24 | generateIFID, 25 | Story, 26 | Passage, 27 | StoryFormat, 28 | 29 | // Version info 30 | version: '2.3.3' 31 | }; 32 | 33 | // Export individual functions for ES6 module usage 34 | export { parseTwee, parseJSON, parseStoryFormat, parseTwine2HTML, compileTwine2HTML, generateIFID, Story, Passage, StoryFormat }; 35 | 36 | // Export default for webpack UMD build 37 | export default Extwee; 38 | 39 | // For direct ES6 module usage, also assign to global object 40 | // Use globalThis for cross-environment compatibility (browser, Node.js, Web Workers) 41 | const globalObject = (function() { 42 | if (typeof globalThis !== 'undefined') return globalThis; 43 | if (typeof window !== 'undefined') return window; 44 | if (typeof global !== 'undefined') return global; 45 | if (typeof self !== 'undefined') return self; 46 | return null; 47 | })(); 48 | 49 | if (globalObject) { 50 | globalObject.Extwee = Extwee; 51 | } 52 | -------------------------------------------------------------------------------- /types/src/Twine2ArchiveHTML/parse-web.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Web-optimized Twine 2 Archive HTML parser with reduced dependencies 3 | * Parse Twine 2 Archive HTML and returns an array of story objects using browser DOM APIs. 4 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md Twine 2 Archive Specification} 5 | * @function parse 6 | * @param {string} content - Content to parse for Twine 2 HTML elements. 7 | * @throws {TypeError} - Content is not a string! 8 | * @returns {Array} Array of stories found in content. 9 | * @example 10 | * const content = ''; 11 | * console.log(parse(content)); 12 | * // => [ 13 | * // Story { 14 | * // name: 'Untitled', 15 | * // startnode: '1', 16 | * // creator: 'Twine', 17 | * // creatorVersion: '2.3.9', 18 | * // ifid: 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6', 19 | * // zoom: '1', 20 | * // format: 'Harlowe', 21 | * // formatVersion: '3.1.0', 22 | * // options: '', 23 | * // hidden: '', 24 | * // passages: [ 25 | * // Passage { 26 | * // pid: '1', 27 | * // name: 'Untitled Passage', 28 | * // tags: '', 29 | * // position: '0,0', 30 | * // size: '100,100', 31 | * // text: '' 32 | * // } 33 | * // ] 34 | * // } 35 | * // ] 36 | */ 37 | export function parse(content: string): any[]; 38 | -------------------------------------------------------------------------------- /test/Twine2HTML/Twine2HTMLParser/Example1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example1 5 | 6 | 7 | 8 | 9 | 52 | 53 | -------------------------------------------------------------------------------- /extwee.config.md: -------------------------------------------------------------------------------- 1 | # Extwee Config File Options 2 | 3 | The configuration file supports some, but not all, possible actions using command-line arguments. 4 | 5 | ## Defining `mode` 6 | 7 | The most important option is `mode`. This **MUST BE** either `compile` or `decompile`. 8 | 9 | ```json 10 | { 11 | "mode": "decompile" 12 | } 13 | ``` 14 | 15 | ## Defining `input` and `output` 16 | 17 | To process files, the `input` and `output` properties **MUST BE** defined using either an absolute or relative path. 18 | 19 | ```json 20 | { 21 | "mode": "compile", 22 | "input": "index.twee", 23 | "output": "index.html" 24 | } 25 | ``` 26 | 27 | ## Defining `story-format` 28 | 29 | If using the `"mode": "compile"` option, the `story-format` property **MUST BE** defined. This should be the name of a directory in the relative `./story-formats` directory. 30 | 31 | For example, if using Harlowe, the path would be `./story-formats/harlowe` and the key-value pair would be `"story-format": "harlowe"`. 32 | 33 | ```json 34 | { 35 | "mode": "compile", 36 | "input": "index.twee", 37 | "output": "index.html", 38 | "story-format": "harlowe" 39 | } 40 | ``` 41 | 42 | ### Defining optional `story-format-version` 43 | 44 | The Story Format Archive retrieves story formats based on its version in a sub-directory structure: 45 | 46 | ```file 47 | story-formats/ 48 | ├── harlowe/ 49 | │ ├── 2.3.0/ 50 | │ └── format.js 51 | │ ├── 2.4.0/ 52 | │ └── format.js 53 | ``` 54 | 55 | This can be specified, such as `3.2.0`, or the default `latest`, can be used. 56 | 57 | ```json 58 | { 59 | "mode": "compile", 60 | "input": "index.twee", 61 | "output": "index.html", 62 | "story-format": "harlowe", 63 | "story-format-version": "3.2.0" 64 | } 65 | ``` 66 | 67 | If only the story format name is specified, and it can be found in the local `story-formats` directory, it will search for a corresponding `format.js` file. 68 | -------------------------------------------------------------------------------- /src/Config/parser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses a JSON object and extracts the StoryFormat, StoryTitle and StoryVersion. 3 | * @param {object} obj Incoming JSON object. 4 | * @returns {object} An object containing the extracted results. 5 | */ 6 | export function parser(obj) { 7 | // Check if the object is a valid JSON object. 8 | if (typeof obj !== 'object' || obj === null) { 9 | throw new Error('Error: Invalid JSON object'); 10 | } 11 | 12 | // Extracted results. 13 | let results = { 14 | StoryFormat: null, 15 | Input: null, 16 | Output: null, 17 | Mode: null, 18 | Twine1Project: false, 19 | StoryFormatVersion: null 20 | }; 21 | 22 | // Does the object contain 'StoryFormat'? 23 | if (Object.hasOwnProperty.call(obj, 'story-format')) { 24 | results.StoryFormat = obj['story-format']; 25 | } 26 | 27 | // Does the object contain 'StoryFormatVersion'? 28 | if (Object.hasOwnProperty.call(obj, 'story-format-version')) { 29 | results.StoryFormatVersion = obj['story-format-version']; 30 | } else { 31 | results.StoryFormatVersion = "latest"; 32 | } 33 | 34 | // Does the object contain 'mode'? 35 | if (Object.hasOwnProperty.call(obj, 'mode')) { 36 | results.Mode = obj['mode']; 37 | } 38 | 39 | // Does the object contain 'input'? 40 | if (Object.hasOwnProperty.call(obj, 'input')) { 41 | results.Input = obj['input']; 42 | } 43 | 44 | // Does the object contain 'output'? 45 | if (Object.hasOwnProperty.call(obj, 'output')) { 46 | results.Output = obj['output']; 47 | } 48 | 49 | // Does the object contain 'twine1-project'? 50 | if (Object.hasOwnProperty.call(obj, 'twine1-project')) { 51 | results.Twine1Project = obj['twine1-project']; 52 | } else { 53 | results.Twine1Project = false; 54 | } 55 | 56 | // Return the extracted results. 57 | return results; 58 | } -------------------------------------------------------------------------------- /test/Config/isDirectory.test.js: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | 3 | // Mock the fs module before importing anything that uses it 4 | const mockStatSync = jest.fn(); 5 | jest.unstable_mockModule('node:fs', () => ({ 6 | statSync: mockStatSync 7 | })); 8 | 9 | // Now import the modules that depend on the mocked module 10 | const { isDirectory } = await import('../../src/CLI/isDirectory.js'); 11 | 12 | describe('isDirectory', () => { 13 | afterEach(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | it('should return true if the path is a directory', () => { 18 | const mockStats = { isDirectory: jest.fn(() => true) }; 19 | mockStatSync.mockReturnValue(mockStats); 20 | 21 | const result = isDirectory('/valid/directory/path'); 22 | expect(mockStatSync).toHaveBeenCalledWith('/valid/directory/path'); 23 | expect(mockStats.isDirectory).toHaveBeenCalled(); 24 | expect(result).toBe(true); 25 | }); 26 | 27 | it('should return false if the path is not a directory', () => { 28 | const mockStats = { isDirectory: jest.fn(() => false) }; 29 | mockStatSync.mockReturnValue(mockStats); 30 | 31 | const result = isDirectory('/valid/file/path'); 32 | expect(mockStatSync).toHaveBeenCalledWith('/valid/file/path'); 33 | expect(mockStats.isDirectory).toHaveBeenCalled(); 34 | expect(result).toBe(false); 35 | }); 36 | 37 | it('should return false and log an error if statSync throws an error', () => { 38 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 39 | mockStatSync.mockImplementation(() => { 40 | throw new Error('Test error'); 41 | }); 42 | 43 | const result = isDirectory('/invalid/path'); 44 | expect(mockStatSync).toHaveBeenCalledWith('/invalid/path'); 45 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Error: Test error')); 46 | expect(result).toBe(false); 47 | 48 | consoleErrorSpy.mockRestore(); 49 | }); 50 | }); -------------------------------------------------------------------------------- /test/Config/isFile.test.js: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | 3 | // Mock the fs module before importing anything that uses it 4 | const mockStatSync = jest.fn(); 5 | jest.unstable_mockModule('node:fs', () => ({ 6 | statSync: mockStatSync 7 | })); 8 | 9 | // Now import the modules that depend on the mocked module 10 | const { isFile } = await import('../../src/CLI/isFile.js'); 11 | 12 | describe('isFile', () => { 13 | afterEach(() => { 14 | jest.clearAllMocks(); 15 | }); 16 | 17 | it('should return true if the path is a valid file', () => { 18 | // Mock statSync to return an object with isFile() returning true. 19 | mockStatSync.mockReturnValue({ 20 | isFile: jest.fn(() => true), 21 | }); 22 | 23 | const result = isFile('/path/to/file'); 24 | expect(result).toBe(true); 25 | expect(mockStatSync).toHaveBeenCalledWith('/path/to/file'); 26 | }); 27 | 28 | it('should return false if the path is not a valid file', () => { 29 | // Mock statSync to return an object with isFile() returning false. 30 | mockStatSync.mockReturnValue({ 31 | isFile: jest.fn(() => false), 32 | }); 33 | 34 | const result = isFile('/path/to/directory'); 35 | expect(result).toBe(false); 36 | expect(mockStatSync).toHaveBeenCalledWith('/path/to/directory'); 37 | }); 38 | 39 | it('should return false and log an error if statSync throws an error', () => { 40 | // Mock statSync to throw an error. 41 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 42 | mockStatSync.mockImplementation(() => { 43 | throw new Error('File not found'); 44 | }); 45 | 46 | const result = isFile('/invalid/path'); 47 | expect(result).toBe(false); 48 | expect(mockStatSync).toHaveBeenCalledWith('/invalid/path'); 49 | expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Error: Error: File not found')); 50 | 51 | consoleErrorSpy.mockRestore(); 52 | }); 53 | }); -------------------------------------------------------------------------------- /test/TWS/Parse.test.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { parse as parseTWS } from '../../src/TWS/parse.js'; 3 | 4 | describe('TWSParser', () => { 5 | describe('#parse()', () => { 6 | it('Should throw error if input is not Buffer', function () { 7 | expect(() => { parseTWS(0); }).toThrow(); 8 | }); 9 | 10 | describe('Passage parsing', function () { 11 | let r = null; 12 | 13 | beforeAll(() => { 14 | const contents = fs.readFileSync('test/TWS/TWSParser/Example5.tws', 'binary'); 15 | const b = Buffer.from(contents, 'binary'); 16 | r = parseTWS(b); 17 | }); 18 | 19 | it('Should parse passage - tags', function () { 20 | const p = r.getPassageByName('Untitled Passage 1'); 21 | expect(p.tags.length).toBe(3); 22 | }); 23 | 24 | it('Should parse passage - text', function () { 25 | const p = r.getPassageByName('Untitled Passage 2'); 26 | expect(p.text).toBe('dd'); 27 | }); 28 | 29 | it('Should set Story start (to start passage)', function () { 30 | expect(r.start).toBe('Start'); 31 | }); 32 | }); 33 | 34 | describe('Exceptions and parsing issues', function () { 35 | it('Should throw error if parsing a Buffer but not pickle data', function () { 36 | const contents = 'Test'; 37 | const b = Buffer.from(contents); 38 | expect(() => { parseTWS(b); }).toThrow(); 39 | }); 40 | 41 | it('Should create default Story object if pickle data but not TWS data', function () { 42 | const contents = fs.readFileSync('test/TWS/TWSParser/nostory.tws', 'binary'); 43 | const b = Buffer.from(contents, 'binary'); 44 | const r = parseTWS(b); 45 | expect(r.size()).toBe(0); 46 | }); 47 | 48 | it('Should parse storyPanel but no scale', function () { 49 | const contents = fs.readFileSync('test/TWS/TWSParser/noscale.tws', 'binary'); 50 | const b = Buffer.from(contents, 'binary'); 51 | const r = parseTWS(b); 52 | expect(r.zoom).toBe(1); 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/Twee/TweeParser/cycling.twee: -------------------------------------------------------------------------------- 1 | :: StoryTitle 2 | Cycling Choices in Snowman 3 | 4 | 5 | :: StoryData 6 | { 7 | "ifid": "2A4D6978-93A7-4FD3-96FB-94B995FCBE29" 8 | } 9 | 10 | 11 | :: UserScript[script] 12 | $(function() { 13 | 14 | // Create a global object 15 | window.setup = window.setup || {}; 16 | 17 | // Iterate through all elements with the class 'cycle' 18 | // For each, save the current 'choices' and 'selection' 19 | // (This sets all the 'default' values.) 20 | $('.cycle').each(function() { 21 | 22 | // Create a global object for each 'id' 23 | var id = $(this).attr('id'); 24 | setup[id] = {}; 25 | 26 | // Save the current 'choices' for each 27 | var choices = JSON.parse($(this).attr("data-cycling-choices")); 28 | setup[id].choices = choices; 29 | 30 | // Save the current 'selection' for each 31 | var selection = $(this).attr("data-cycling-selection"); 32 | setup[id].selection = selection; 33 | 34 | }); 35 | 36 | $('.cycle').click(function(){ 37 | 38 | // Save the 'id' 39 | var id = $(this).attr('id'); 40 | 41 | // Retrieve the global 'choices' 42 | var choices = setup[id].choices; 43 | 44 | // Retrieve the global 'selection' 45 | var selection = setup[id].selection; 46 | 47 | // Update the 'selection' number 48 | selection++; 49 | 50 | // Check if 'selection' is greater than length of choices 51 | if(selection >= choices.length) { 52 | selection = 0; 53 | } 54 | 55 | // Update the 'selection' on the element 56 | $(this).attr("data-cycling-selection", selection); 57 | 58 | // Update the text of the element with the choice 59 | $(this).text(choices[selection]); 60 | 61 | // Update the global values of 'choices' and 'selection' 62 | setup[id].choices = choices; 63 | setup[id].selection = selection; 64 | 65 | }); 66 | 67 | }); 68 | 69 | :: Start 70 | One 71 | 72 | [[Submit|Results]] 73 | 74 | :: Results 75 | <%= setup["cycleOne"].choices[setup["cycleOne"].selection] %> 76 | -------------------------------------------------------------------------------- /types/StoryFormat/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses story format content into a {@link StoryFormat} object. 3 | * 4 | * Story formats are generally JSONP files containing a JSON object with the following properties: 5 | * - name: (string) Optional. (Omitting the name will lead to an Untitled Story Format.) 6 | * - version: (string) Required, and semantic version-style formatting (x.y.z, e.g., 1.2.1) of the version is also required. 7 | * - author: (string) Optional. 8 | * - description: (string) Optional. 9 | * - image: (string) Optional. 10 | * - url: (string) Optional. 11 | * - license: (string) Optional. 12 | * - proofing: (boolean) Optional (defaults to false). 13 | * - source: (string) Required. 14 | * 15 | * If existing properties do not match their expected type, a warning will be produced and incoming value will be ignored. 16 | * 17 | * This function does "soft parsing." It will not throw an error if a specific property is missing or malformed. 18 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-storyformats-spec.md Twine 2 Story Formats Specification} 19 | * @function parse 20 | * @param {string} contents - JSONP content. 21 | * @throws {Error} - Unable to find Twine 2 JSON chunk! 22 | * @throws {Error} - Unable to parse Twine 2 JSON chunk! 23 | * @returns {StoryFormat} StoryFormat object. 24 | * @example 25 | * const contents = `{ 26 | * "name": "My Story Format", 27 | * "version": "1.0.0", 28 | * "author": "Twine", 29 | * "description": "A story format.", 30 | * "image": "icon.svg", 31 | * "url": "https://example.com", 32 | * "license": "MIT", 33 | * "proofing": false, 34 | * "source": "" 35 | * }`; 36 | * const storyFormat = parse(contents); 37 | * console.log(storyFormat); 38 | * // => StoryFormat { 39 | * // name: 'My Story Format', 40 | * // version: '1.0.0', 41 | * // description: 'A story format.', 42 | * // image: 'icon.svg', 43 | * // url: 'https://example.com', 44 | * // license: 'MIT', 45 | * // proofing: false, 46 | * // source: '' 47 | * // } 48 | */ 49 | export function parse(contents: string): StoryFormat; 50 | import StoryFormat from '../StoryFormat.js'; 51 | -------------------------------------------------------------------------------- /types/src/StoryFormat/parse.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses story format content into a {@link StoryFormat} object. 3 | * 4 | * Story formats are generally JSONP files containing a JSON object with the following properties: 5 | * - name: (string) Optional. (Omitting the name will lead to an Untitled Story Format.) 6 | * - version: (string) Required, and semantic version-style formatting (x.y.z, e.g., 1.2.1) of the version is also required. 7 | * - author: (string) Optional. 8 | * - description: (string) Optional. 9 | * - image: (string) Optional. 10 | * - url: (string) Optional. 11 | * - license: (string) Optional. 12 | * - proofing: (boolean) Optional (defaults to false). 13 | * - source: (string) Required. 14 | * 15 | * If existing properties do not match their expected type, a warning will be produced and incoming value will be ignored. 16 | * 17 | * This function does "soft parsing." It will not throw an error if a specific property is missing or malformed. 18 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-storyformats-spec.md Twine 2 Story Formats Specification} 19 | * @function parse 20 | * @param {string} contents - JSONP content. 21 | * @throws {Error} - Unable to find Twine 2 JSON chunk! 22 | * @throws {Error} - Unable to parse Twine 2 JSON chunk! 23 | * @returns {StoryFormat} StoryFormat object. 24 | * @example 25 | * const contents = `{ 26 | * "name": "My Story Format", 27 | * "version": "1.0.0", 28 | * "author": "Twine", 29 | * "description": "A story format.", 30 | * "image": "icon.svg", 31 | * "url": "https://example.com", 32 | * "license": "MIT", 33 | * "proofing": false, 34 | * "source": "" 35 | * }`; 36 | * const storyFormat = parse(contents); 37 | * console.log(storyFormat); 38 | * // => StoryFormat { 39 | * // name: 'My Story Format', 40 | * // version: '1.0.0', 41 | * // description: 'A story format.', 42 | * // image: 'icon.svg', 43 | * // url: 'https://example.com', 44 | * // license: 'MIT', 45 | * // proofing: false, 46 | * // source: '' 47 | * // } 48 | */ 49 | export function parse(contents: string): StoryFormat; 50 | import StoryFormat from '../StoryFormat.js'; 51 | -------------------------------------------------------------------------------- /src/Twine2HTML/compile.js: -------------------------------------------------------------------------------- 1 | import { Story } from '../Story.js'; 2 | import StoryFormat from '../StoryFormat.js'; 3 | 4 | /** 5 | * Write a combination of Story + StoryFormat into Twine 2 HTML file. 6 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-htmloutput-spec.md Twine 2 HTML Output Specification} 7 | * @function compile 8 | * @param {Story} story - Story object to write. 9 | * @param {StoryFormat} storyFormat - StoryFormat to write. 10 | * @returns {string} Twine 2 HTML based on StoryFormat and Story. 11 | * @throws {Error} If story is not instance of Story. 12 | * @throws {Error} If storyFormat is not instance of StoryFormat. 13 | * @throws {Error} If storyFormat.source is empty string. 14 | */ 15 | function compile (story, storyFormat) { 16 | // Check if story is instanceof Story. 17 | if (!(story instanceof Story)) { 18 | throw new Error('Error: story must be a Story object!'); 19 | } 20 | 21 | // Check if storyFormat is instanceof StoryFormat. 22 | if (!(storyFormat instanceof StoryFormat)) { 23 | throw new Error('storyFormat must be a StoryFormat object!'); 24 | } 25 | 26 | // Check if storyFormat.source is empty string. 27 | if (storyFormat.source === '') { 28 | throw new Error('StoryFormat source empty string!'); 29 | } 30 | 31 | /** 32 | * There are two required attributes: 33 | * - story.IFID: UUIDv4 34 | * - story.name: string (non-empty) 35 | */ 36 | 37 | // Check if story.IFID is UUIDv4 formatted. 38 | if (story.IFID.match(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[89ABab][0-9A-F]{3}-[0-9A-F]{12}$/) === null) { 39 | throw new Error('Story IFID is invalid!'); 40 | } 41 | 42 | // Check if story.name is empty string. 43 | if (story.name === '') { 44 | throw new Error('Story name empty string!'); 45 | } 46 | 47 | // Translate story to Twine 2 HTML. 48 | const storyData = story.toTwine2HTML(); 49 | 50 | // Replace the story name in the source file. 51 | storyFormat.source = storyFormat.source.replaceAll(/{{STORY_NAME}}/gm, story.name); 52 | 53 | // Replace the story data. 54 | storyFormat.source = storyFormat.source.replaceAll(/{{STORY_DATA}}/gm, storyData); 55 | 56 | // Return content. 57 | return storyFormat.source; 58 | } 59 | 60 | export { compile }; 61 | -------------------------------------------------------------------------------- /src/Twine2ArchiveHTML/compile.js: -------------------------------------------------------------------------------- 1 | import { Story } from '../Story.js'; 2 | 3 | /** 4 | * Write array of Story objects into Twine 2 Archive HTML. 5 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md Twine 2 Archive Specification} 6 | * @function compile 7 | * @param {Array} stories - Array of Story objects. 8 | * @returns {string} Twine 2 Archive HTML. 9 | * @example 10 | * const story1 = new Story(); 11 | * const story2 = new Story(); 12 | * const stories = [story1, story2]; 13 | * console.log(compile(stories)); 14 | * // => '\n\n\n\n' 15 | */ 16 | function compile (stories) { 17 | // Can only accept array. 18 | if (!Array.isArray(stories)) { 19 | throw new TypeError('Stories is not array!'); 20 | } 21 | 22 | // Output 23 | let outputContents = ''; 24 | 25 | // Go through each entry (which must be a Story). 26 | for (const story of stories) { 27 | // If this is not a story, throw a TypeError. 28 | if (!(story instanceof Story)) { 29 | // Throw TypeError. 30 | throw new TypeError('Error: story must be a Story object!'); 31 | } 32 | 33 | // Append content. 34 | outputContents += story.toTwine2HTML(); 35 | 36 | // Append newlines. 37 | outputContents += '\n\n'; 38 | } 39 | 40 | // Return output 41 | return outputContents; 42 | } 43 | 44 | export { compile }; 45 | -------------------------------------------------------------------------------- /docs/install/npx.md: -------------------------------------------------------------------------------- 1 | # NPX 2 | 3 | Extwee can be used via NPX (node package execution) without being added to a local project. When used this way, precede all uses with "npx". 4 | 5 | ## NPX Example 6 | 7 | ```bash 8 | npx extwee -c -i -s -o 9 | ``` 10 | 11 | ## Config File Usage 12 | 13 | When used via NPX, Extwee will also look for a local file, `extwee.config.json`. 14 | 15 | The configuration file supports some, but not all, possible actions using command-line arguments. 16 | 17 | ### Defining `mode` 18 | 19 | The most important option is `mode`. This **MUST BE** either `compile` or `decompile`. 20 | 21 | ```json 22 | { 23 | "mode": "decompile" 24 | } 25 | ``` 26 | 27 | ### Defining `input` and `output` 28 | 29 | To process files, the `input` and `output` properties **MUST BE** defined using either an absolute or relative path. 30 | 31 | ```json 32 | { 33 | "mode": "compile", 34 | "input": "index.twee", 35 | "output": "index.html" 36 | } 37 | ``` 38 | 39 | ### Defining `story-format` 40 | 41 | If using the `"mode": "compile"` option, the `story-format` property **MUST BE** defined. This should be the name of a directory in the relative `./story-formats` directory. 42 | 43 | For example, if using Harlowe, the path would be `./story-formats/harlowe` and the key-value pair would be `"story-format": "harlowe"`. 44 | 45 | ```json 46 | { 47 | "mode": "compile", 48 | "input": "index.twee", 49 | "output": "index.html", 50 | "story-format": "harlowe" 51 | } 52 | ``` 53 | 54 | #### Defining optional `story-format-version` 55 | 56 | The Story Format Archive retrieves story formats based on its version in a sub-directory structure: 57 | 58 | ```file 59 | story-formats/ 60 | ├── harlowe/ 61 | │ ├── 2.3.0/ 62 | │ └── format.js 63 | │ ├── 2.4.0/ 64 | │ └── format.js 65 | ``` 66 | 67 | This can be specified, such as `3.2.0`, or the default `latest`, can be used. 68 | 69 | ```json 70 | { 71 | "mode": "compile", 72 | "input": "index.twee", 73 | "output": "index.html", 74 | "story-format": "harlowe", 75 | "story-format-version": "3.2.0" 76 | } 77 | ``` 78 | 79 | If only the story format name is specified, and it can be found in the local `story-formats` directory, it will search for a corresponding `format.js` file. 80 | -------------------------------------------------------------------------------- /test/CLI/CLI.test.js: -------------------------------------------------------------------------------- 1 | import shell from 'shelljs'; 2 | 3 | // We could get this from process, 4 | // but since we are using shelljs, 5 | // we ask for the pwd() instead of cwd(). 6 | const currentPath = shell.pwd().stdout; 7 | const testFilePath = currentPath + '/test/CLI/files'; 8 | 9 | describe('CLI', () => { 10 | // Remove the test files, if they exist. 11 | beforeAll(() => { 12 | // Test for files beginning with "test." in the output directory. 13 | if (shell.ls('-A', `${testFilePath}/output/`).length > 0) { 14 | // Remove the files. 15 | shell.rm(`${testFilePath}/output/*`); 16 | } 17 | }); 18 | 19 | it('Twine 2 - de-compile: Twine 2 HTML into Twee 3', () => { 20 | shell.exec(`node ${currentPath}/src/extwee.js -d -i ${testFilePath}/input.html -o ${testFilePath}/output/test.twee`); 21 | expect(shell.test('-e', `${testFilePath}/output/test.twee`)).toBe(true); 22 | }); 23 | 24 | it('Twine 2 - compile: Twee 3 + StoryFormat into Twine 2 HTML', () => { 25 | shell.exec(`node ${currentPath}/src/extwee.js -c -i ${testFilePath}/example6.twee -s ${testFilePath}/harlowe.js -o ${testFilePath}/output/test2.html`); 26 | expect(shell.test('-e', `${testFilePath}/output/test2.html`)).toBe(true); 27 | }); 28 | 29 | it('Twine 1 - compile: Twee 3 + Twine 1 engine.js + Twine 1 code.js + Twine 1 header.html', () => { 30 | shell.exec(`node ${currentPath}/src/extwee.js --twine1 -c -i ${testFilePath}/example6.twee -o ${testFilePath}/output/test3.html --codejs ${testFilePath}/twine1/code.js --engine ${testFilePath}/twine1/engine.js --header ${testFilePath}/twine1/header.html --name Test`); 31 | expect(shell.test('-e', `${testFilePath}/output/test3.html`)).toBe(true); 32 | }); 33 | 34 | it('Twine 1 - de-compile: Twine 1 HTML into Twee 3', () => { 35 | shell.exec(`node ${currentPath}/src/extwee.js --twine1 -d -i ${testFilePath}/twine1Test.html -o ${testFilePath}/output/test.twee`); 36 | expect(shell.test('-e', `${testFilePath}/output/test.twee`)).toBe(true); 37 | }); 38 | 39 | // Remove the test files, if they exist. 40 | afterAll(() => { 41 | // Test for files in the output directory. 42 | if (shell.ls('-A', `${testFilePath}/output/`).length > 0) { 43 | // Remove the files. 44 | shell.rm(`${testFilePath}/output/*`); 45 | } 46 | // Create one file to prevent git from ignoring the folder. 47 | shell.touch(`${testFilePath}/output/test.twee`); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | # MacOS X 119 | .DS_Store 120 | 121 | # VS Code Settings 122 | .vscode -------------------------------------------------------------------------------- /src/Twine1HTML/compile.js: -------------------------------------------------------------------------------- 1 | import { Story } from '../Story.js'; 2 | 3 | /** 4 | * Write a combination of Story object, `engine.js` (from Twine 1), `header.html`, and optional `code.js`. 5 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 6 | * @function compile 7 | * @param {Story} story - Story object to write. 8 | * @param {string} engine - Source of `engine.js` file from Twine 1. 9 | * @param {string} header - `header.html` content for Twine 1 story format. 10 | * @param {string} name - Name of the story format (needed for `code.js` inclusion). 11 | * @param {string} codeJS - `code.js` content with additional JavaScript. 12 | * @param {object} config - Limited configuration object acting in place of `StorySettings`. 13 | * @param {string} config.jquery - jQuery source. 14 | * @param {string} config.modernizr - Modernizr source. 15 | * @returns {string} Twine 1 HTML. 16 | */ 17 | function compile (story, engine = '', header = '', name = '', codeJS = '', config = { jquery: '', modernizr: '' }) { 18 | // We must have a Story object. 19 | if (!(story instanceof Story)) { 20 | throw new TypeError('Error: story must be a Story object!'); 21 | } 22 | 23 | // Replace the "VERSION" with story.creator. 24 | header = header.replaceAll(/"VERSION"/gm, story.creator); 25 | 26 | // Replace the "TIME" with new Date(). 27 | header = header.replaceAll(/"TIME"/gm, new Date()); 28 | 29 | // Replace the ENGINE with `engine.js` code. 30 | header = header.replaceAll(/"ENGINE"/gm, engine); 31 | 32 | // Replace the NAME (e.g. "JONAH") with `engine.js` code. 33 | header = header.replaceAll(`"${name.toUpperCase()}"`, codeJS); 34 | 35 | // Replace "STORY_SIZE". 36 | header = header.replaceAll(/"STORY_SIZE"/gm, `"${story.size()}"`); 37 | 38 | // Replace "STORY" with Twine 1 HTML. 39 | header = header.replaceAll(/"STORY"/gm, story.toTwine1HTML()); 40 | 41 | // Replace START_AT with ''. 42 | header = header.replaceAll(/"START_AT"/gm, '\'\''); 43 | 44 | // Does 'jquery' exist? 45 | if (Object.prototype.hasOwnProperty.call(config, 'jquery')) { 46 | // Replace JQUERY with jQuery. 47 | header = header.replaceAll(/"JQUERY"/gm, config.jquery); 48 | } 49 | 50 | // Does 'modernizr' exist? 51 | if (Object.prototype.hasOwnProperty.call(config, 'modernizr')) { 52 | // Replace "MODERNIZR" with Modernizr. 53 | header = header.replaceAll(/"MODERNIZR"/gm, config.modernizr); 54 | } 55 | 56 | // Return code. 57 | return header; 58 | } 59 | 60 | export { compile }; 61 | -------------------------------------------------------------------------------- /test/Web/web-tws.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | /** 6 | * Tests for web-tws.js module 7 | * Tests module exports and functionality 8 | */ 9 | 10 | import { describe, expect, it } from '@jest/globals'; 11 | 12 | // Import to test basic functionality 13 | import { parse } from '../../src/Web/web-tws.js'; 14 | import Extwee from '../../src/Web/web-tws.js'; 15 | 16 | describe('web-tws.js module tests', () => { 17 | 18 | describe('ES6 module exports', () => { 19 | it('should export parse function', () => { 20 | expect(parse).toBeDefined(); 21 | expect(typeof parse).toBe('function'); 22 | }); 23 | 24 | it('should export default object with parseTWS', () => { 25 | expect(Extwee.parseTWS).toBeDefined(); 26 | expect(Extwee.parse).toBeDefined(); 27 | expect(typeof Extwee.parseTWS).toBe('function'); 28 | expect(typeof Extwee.parse).toBe('function'); 29 | }); 30 | }); 31 | 32 | describe('Global object assignment', () => { 33 | it('should assign functions to global object when available', () => { 34 | // In Node.js environment, should assign to globalThis 35 | expect(globalThis.Extwee).toBeDefined(); 36 | expect(globalThis.Extwee.parseTWS).toBeDefined(); 37 | expect(typeof globalThis.Extwee.parseTWS).toBe('function'); 38 | }); 39 | 40 | it('should preserve existing Extwee properties', () => { 41 | // Should not overwrite the entire object, just add properties 42 | if (globalThis.Extwee && globalThis.Extwee.version) { 43 | expect(globalThis.Extwee.version).toBeDefined(); 44 | } 45 | expect(globalThis.Extwee.parseTWS).toBeDefined(); 46 | }); 47 | }); 48 | 49 | describe('Functional integration tests', () => { 50 | it('should have working parseTWS function', () => { 51 | // Create a minimal valid TWS buffer (pickled data) 52 | // This is a very basic test - TWS parsing is complex 53 | const validBuffer = Buffer.from([ 54 | 0x80, 0x02, // Python pickle protocol version 2 55 | 0x7d, 0x71, 0x00, // Empty dict 56 | 0x2e // STOP 57 | ]); 58 | 59 | expect(() => { 60 | const result = parse(validBuffer); 61 | expect(result).toBeDefined(); 62 | }).not.toThrow(); 63 | }); 64 | 65 | it('should throw error for invalid input', () => { 66 | expect(() => { 67 | parse("not a buffer"); 68 | }).toThrow(); 69 | }); 70 | 71 | it('should have same functions in exports and global', () => { 72 | // Test that parse is the same function 73 | expect(parse).toBe(Extwee.parse); 74 | expect(parse).toBe(Extwee.parseTWS); 75 | }); 76 | }); 77 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "extwee", 3 | "version": "2.3.12", 4 | "description": "A story compiler tool using Twine-compatible formats", 5 | "author": "Dan Cox", 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 | }, 15 | "bin": { 16 | "extwee": "src/extwee.js" 17 | }, 18 | "scripts": { 19 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand", 20 | "lint": "eslint ./src/**/*.js --fix", 21 | "lint:test": "eslint ./test/**/*.test.js --fix", 22 | "build:web": "webpack", 23 | "analyze:web": "webpack-bundle-analyzer build/extwee.web.min.js", 24 | "gen-types": "npx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly --outDir types", 25 | "copy:build": "cp build/*.js docs/build", 26 | "all": "npm run lint && npm run lint:test && npm run test && npm run build:web && npm run gen-types && npm run copy:build" 27 | }, 28 | "keywords": [ 29 | "twine", 30 | "twee", 31 | "parser", 32 | "compiler" 33 | ], 34 | "license": "MIT", 35 | "dependencies": { 36 | "commander": "^14.0.2", 37 | "graphemer": "^1.4.0", 38 | "html-entities": "^2.6.0", 39 | "node-html-parser": "^7.0.1", 40 | "pickleparser": "^0.2.1", 41 | "semver": "^7.7.3", 42 | "shelljs": "^0.10.0" 43 | }, 44 | "devDependencies": { 45 | "@babel/cli": "^7.28.3", 46 | "@babel/core": "^7.28.5", 47 | "@babel/preset-env": "^7.28.5", 48 | "@eslint/js": "^9.39.1", 49 | "@inquirer/prompts": "^8.0.1", 50 | "@types/node": "^24.10.1", 51 | "@types/semver": "^7.7.1", 52 | "babel-loader": "^10.0.0", 53 | "clean-jsdoc-theme": "^4.3.0", 54 | "core-js": "^3.47.0", 55 | "eslint": "^9.39.1", 56 | "eslint-plugin-jest": "^29.2.1", 57 | "eslint-plugin-jsdoc": "^61.4.1", 58 | "globals": "^16.5.0", 59 | "jest": "^30.2.0", 60 | "jest-environment-jsdom": "^30.2.0", 61 | "regenerator-runtime": "^0.14.1", 62 | "typescript": "^5.9.3", 63 | "typescript-eslint": "^8.48.0", 64 | "webpack": "^5.103.0", 65 | "webpack-bundle-analyzer": "^5.0.1", 66 | "webpack-cli": "^6.0.1" 67 | }, 68 | "repository": { 69 | "type": "git", 70 | "url": "git+https://github.com/videlais/extwee.git" 71 | }, 72 | "bugs": { 73 | "url": "https://github.com/videlais/extwee/issues" 74 | }, 75 | "type": "module", 76 | "types": "./types/index.d.ts", 77 | "engines": { 78 | "node": ">=18.18.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/TWS/parse.js: -------------------------------------------------------------------------------- 1 | import { Story } from '../Story.js'; 2 | import Passage from '../Passage.js'; 3 | import { Parser } from 'pickleparser'; 4 | 5 | /** 6 | * Parse TWS file (as Buffer) into Story. 7 | * Unless it throws an error, it will return a Story object. 8 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 9 | * @function parse 10 | * @param {Buffer} binaryFileContents - File contents to parse as Buffer. 11 | * @returns {Story} Story object. 12 | */ 13 | function parse (binaryFileContents) { 14 | // Is this Buffer? 15 | if (!Buffer.isBuffer(binaryFileContents)) { 16 | // Throw an error. We cannot proceed. 17 | throw new Error('Only parsing of Buffer is allowed!'); 18 | } 19 | 20 | // Create a new PickleParser. 21 | const parser = new Parser(); 22 | 23 | // Set default value. 24 | let pythonObject = null; 25 | 26 | // Does the Buffer contain pickle data? 27 | try { 28 | // Try to parse the pickle data, assuming it is pickle data. 29 | pythonObject = parser.parse(binaryFileContents); 30 | } catch (error) { 31 | // This is a Buffer, but not pickle data. 32 | throw new TypeError(`Error: Buffer does not contain Python pickle data! ${error}`); 33 | } 34 | 35 | // Create Story object. 36 | const result = new Story(); 37 | 38 | // Does 'storyPanel' exist? 39 | // (While Twine 1 will always generate it, we must verify.) 40 | if (Object.prototype.hasOwnProperty.call(pythonObject, 'storyPanel')) { 41 | // Check and possibly override Zoom level. 42 | if (Object.prototype.hasOwnProperty.call(pythonObject.storyPanel, 'scale')) { 43 | // Save Zoom level from TWS. 44 | result.zoom = pythonObject.storyPanel.scale; 45 | } 46 | 47 | // Parse storyPanel.widgets. 48 | if (Object.prototype.hasOwnProperty.call(pythonObject.storyPanel, 'widgets')) { 49 | // Parse `widgets` for passages. 50 | for (const widget of pythonObject.storyPanel.widgets) { 51 | // Create a passage. 52 | const passage = new Passage(); 53 | 54 | // Get title. 55 | passage.name = widget.passage.title; 56 | 57 | // Get position. 58 | // passage.attributes.position = `${widget.pos[0]},${widget.pos[1]}`; 59 | 60 | // Get tags. 61 | passage.tags = widget.passage.tags; 62 | 63 | // Get source. 64 | passage.text = widget.passage.text; 65 | 66 | // Set startingPassage (if found). 67 | if (passage.name === 'Start') { 68 | result.start = passage.name; 69 | } 70 | 71 | // Set the story name (if found). 72 | if (passage.name === 'StoryTitle') { 73 | result.name = passage.text; 74 | } 75 | 76 | // Add the passage. 77 | result.addPassage(passage); 78 | } 79 | } 80 | } 81 | 82 | // Return Story object. 83 | return result; 84 | } 85 | 86 | export { parse }; 87 | -------------------------------------------------------------------------------- /test/Config/readDirectories.test.js: -------------------------------------------------------------------------------- 1 | import {jest} from '@jest/globals'; 2 | 3 | // Mock the fs module and isDirectory before importing anything that uses them 4 | const mockReaddirSync = jest.fn(); 5 | const mockIsDirectory = jest.fn(); 6 | 7 | jest.unstable_mockModule('node:fs', () => ({ 8 | readdirSync: mockReaddirSync 9 | })); 10 | 11 | jest.unstable_mockModule('../../src/CLI/isDirectory.js', () => ({ 12 | isDirectory: mockIsDirectory 13 | })); 14 | 15 | // Now import the modules that depend on the mocked modules 16 | const { readDirectories } = await import('../../src/CLI/ProcessConfig/readDirectories.js'); 17 | 18 | describe('readDirectories', () => { 19 | afterEach(() => { 20 | jest.clearAllMocks(); 21 | }); 22 | 23 | it('should return an empty array and log an error if the directory does not exist', () => { 24 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 25 | mockIsDirectory.mockReturnValue(false); 26 | 27 | const result = readDirectories('/nonexistent'); 28 | 29 | expect(result).toEqual([]); 30 | expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Directory /nonexistent does not exist.'); 31 | consoleErrorSpy.mockRestore(); 32 | }); 33 | 34 | it('should return an empty array and log an error if readdirSync throws an error', () => { 35 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 36 | mockIsDirectory.mockReturnValue(true); 37 | mockReaddirSync.mockImplementation(() => { 38 | throw new Error('Permission denied'); 39 | }); 40 | 41 | const result = readDirectories('/restricted'); 42 | 43 | expect(result).toEqual([]); 44 | expect(consoleErrorSpy).toHaveBeenCalledWith('Error reading directory /restricted:', expect.any(Error)); 45 | consoleErrorSpy.mockRestore(); 46 | }); 47 | 48 | it('should return an empty array if the directory is empty', () => { 49 | mockIsDirectory.mockReturnValue(true); 50 | mockReaddirSync.mockReturnValue([]); 51 | 52 | const result = readDirectories('/empty'); 53 | 54 | expect(result).toEqual([]); 55 | }); 56 | 57 | it('should return an array of directories', () => { 58 | mockIsDirectory.mockReturnValue(true); 59 | mockReaddirSync.mockReturnValue(['dir1', 'file1', 'dir2']); 60 | 61 | mockIsDirectory.mockImplementation((path) => { 62 | return path === '/test/dir1' || path === '/test/dir2'; 63 | }); 64 | 65 | const result = readDirectories('/test'); 66 | 67 | expect(result).toEqual(['dir1', 'dir2']); 68 | }); 69 | 70 | it('should return an empty array if the result is not an array', () => { 71 | mockIsDirectory.mockReturnValue(true); 72 | mockReaddirSync.mockReturnValue('not an array'); 73 | 74 | const result = readDirectories('/test'); 75 | 76 | expect(result).toEqual([]); 77 | }); 78 | }); -------------------------------------------------------------------------------- /src/Twine2ArchiveHTML/parse.js: -------------------------------------------------------------------------------- 1 | import { parse as HtmlParser } from 'node-html-parser'; 2 | import { parse as parseTwine2HTML } from '../Twine2HTML/parse.js'; 3 | 4 | /** 5 | * Parse Twine 2 Archive HTML and returns an array of story objects. 6 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-archive-spec.md Twine 2 Archive Specification} 7 | * @function parse 8 | * @param {string} content - Content to parse for Twine 2 HTML elements. 9 | * @throws {TypeError} - Content is not a string! 10 | * @returns {Array} Array of stories found in content. 11 | * @example 12 | * const content = ''; 13 | * console.log(parse(content)); 14 | * // => [ 15 | * // Story { 16 | * // name: 'Untitled', 17 | * // startnode: '1', 18 | * // creator: 'Twine', 19 | * // creatorVersion: '2.3.9', 20 | * // ifid: 'A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6', 21 | * // zoom: '1', 22 | * // format: 'Harlowe', 23 | * // formatVersion: '3.1.0', 24 | * // options: '', 25 | * // hidden: '', 26 | * // passages: [ 27 | * // Passage { 28 | * // pid: '1', 29 | * // name: 'Untitled Passage', 30 | * // tags: '', 31 | * // position: '0,0', 32 | * // size: '100,100', 33 | * // text: '' 34 | * // } 35 | * // ] 36 | * // } 37 | * // ] 38 | */ 39 | function parse (content) { 40 | // Can only parse string values. 41 | if (typeof content !== 'string') { 42 | throw new TypeError('Content is not a string!'); 43 | } 44 | 45 | // Send to node-html-parser. 46 | // Enable getting the content of 'script', 'style', and 'pre' elements. 47 | // Get back a DOM. 48 | const dom = new HtmlParser( 49 | content, 50 | { 51 | lowerCaseTagName: false, 52 | script: true, 53 | style: true, 54 | pre: true 55 | }); 56 | 57 | // Array of possible story elements. 58 | const outputArray = []; 59 | 60 | // Pull out the `` element. 61 | const storyDataElements = dom.getElementsByTagName('tw-storydata'); 62 | 63 | // Did we find any elements? 64 | if (storyDataElements.length === 0) { 65 | // Produce a warning if no Twine 2 HTML content is found. 66 | console.warn('Warning: No Twine 2 HTML content found!'); 67 | } 68 | 69 | // Iterate through all `` elements. 70 | for (const storyElement of storyDataElements) { 71 | // Convert element back into HTML text and parse. 72 | outputArray.push(parseTwine2HTML(storyElement.outerHTML)); 73 | } 74 | 75 | // Return array. 76 | return outputArray; 77 | } 78 | 79 | export { parse }; 80 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Type of Change 6 | 7 | 8 | 9 | - [ ] Bug fix (non-breaking change which fixes an issue) 10 | - [ ] New feature (non-breaking change which adds functionality) 11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] Documentation update 13 | - [ ] Performance improvement 14 | - [ ] Code refactoring 15 | - [ ] Test improvement 16 | - [ ] Dependency update 17 | 18 | ## Related Issues 19 | 20 | 21 | 22 | Fixes # 23 | Related to # 24 | 25 | ## Motivation and Context 26 | 27 | 28 | 29 | ## Changes Made 30 | 31 | 32 | 33 | - 34 | - 35 | - 36 | 37 | ## Testing 38 | 39 | 40 | 41 | ### Test Coverage 42 | 43 | - [ ] All existing tests pass 44 | - [ ] New tests added for new functionality 45 | - [ ] Code coverage maintained or improved 46 | - [ ] Tested on multiple Node.js versions 47 | 48 | ### Manual Testing 49 | 50 | 51 | 52 | **Test scenario 1:** 53 | 54 | ```bash 55 | # Commands run 56 | ``` 57 | 58 | **Expected result:** 59 | 60 | 61 | **Actual result:** 62 | 63 | 64 | ## Breaking Changes 65 | 66 | 67 | 68 | - [ ] No breaking changes 69 | - [ ] Breaking changes documented below 70 | 71 | **Breaking changes:** 72 | - 73 | 74 | **Migration guide:** 75 | - 76 | 77 | ## Documentation 78 | 79 | - [ ] README.md updated (if needed) 80 | - [ ] CHANGELOG.md updated 81 | - [ ] JSDoc comments added/updated 82 | - [ ] API documentation updated (if applicable) 83 | - [ ] Examples added/updated (if applicable) 84 | 85 | ## Checklist 86 | 87 | 88 | 89 | - [ ] My code follows the project's style guidelines 90 | - [ ] I have performed a self-review of my own code 91 | - [ ] I have commented my code, particularly in hard-to-understand areas 92 | - [ ] I have made corresponding changes to the documentation 93 | - [ ] My changes generate no new warnings 94 | - [ ] I have added tests that prove my fix is effective or that my feature works 95 | - [ ] New and existing unit tests pass locally with my changes 96 | - [ ] Any dependent changes have been merged and published 97 | 98 | ## Screenshots (if appropriate) 99 | 100 | 101 | 102 | ## Additional Notes 103 | 104 | 105 | 106 | ## Reviewer Guidance 107 | 108 | 109 | 110 | **Focus areas:** 111 | - 112 | - 113 | 114 | **Questions for reviewers:** 115 | - 116 | - 117 | -------------------------------------------------------------------------------- /docs/objects/story.md: -------------------------------------------------------------------------------- 1 | # Story 2 | 3 | A *story* is a collection of [passages](/guide/passage.md) with its own metadata. 4 | 5 | ## Properties 6 | 7 | Depending on the incoming format or creation method, many possible properties can be populated. 8 | 9 | - name ( string ) Name of the story. 10 | - start ( string ) Starting passage for Twine 2 HTML or Twee 3. 11 | - IFID ( string ) When converting to multiple formats, a new IFID will be generated if it does not exist. 12 | - format ( string ) Name of the story format for Twine 2 HTML. 13 | - formatVersion ( string ) Semantic version of the named story format for Twine 2 HTML or Twee 3. 14 | - zoom ( float ) Zoom level for Twine 2 HTML or Twee 3. 15 | - passages ( array(Passage) ) Collection of internal passages. 16 | - creator ( string ) Name of story creation tool. (Defaults to "Extwee"). 17 | - creatorVersion ( string ) Semantic version of named creation tool. 18 | - metadata ( object ) Key-value pairs of metadata values. 19 | - tagColors ( object ) Key-value pairs of tags and their named colors. 20 | 21 | ## Creation Example 22 | 23 | ```javascript 24 | import { Story } from 'extwee'; 25 | 26 | // Create a story. 27 | const example = new Story('Example'); 28 | 29 | // Show story name 30 | console.log ( example.name ); 31 | ``` 32 | 33 | **Note:** All properties have protections. Attempting to assign the wrong type will result in an error. 34 | 35 | ## Passage Methods 36 | 37 | As collections of passages, each **Story** has multiple methods for accessing and mutating its data: 38 | 39 | - `addPassage(Passage)`: Adds a new passage to the collection. (In Twine, passage names must be unique. Extwee will produce a console warning and ignore any repeating names.) 40 | - `removePassageByName(string)`: Looks for an removes a passage based on its name. If the passage does not exist, nothing happens. 41 | - `getPassagesByTags(string)`: Returns an array of any passages containing a particular tag value. 42 | - `getPassageByName(string)`: Returns either `null`` or the named passage. 43 | - `size()`: Returns the number of passages in the collection. 44 | 45 | ## Passage Creation Example 46 | 47 | ```javascript 48 | import { Story, Passage } from 'extwee'; 49 | 50 | // Create the story. 51 | const example = new Story( 'Example' ); 52 | // Add a new passage. 53 | example.addPassage(new Passage( 'Test', 'Some Text') ); 54 | 55 | // Confirm size change. 56 | // (Should produce 1). 57 | console.log ( example.size() ); 58 | ``` 59 | 60 | **Note:** It is not possible to directly access and act on the *passages* property. All actions must take place through available methods. 61 | 62 | Like passages, each **Story** can generate multiple formats based on its data: 63 | 64 | - `toTwee()`: Convert the story, its properties, and all its passages into Twee 3. 65 | - `toJSON()`: Converts the story, its properties, and all its passages into Twine 2 JSON. 66 | - `toTwine1HTML()`: Converts the story, its properties, and all its passages into Twine 1 HTML. 67 | - `toTwine2HTML()`: Converts the story, its properties, and all its passages into Twine 2 HTML. 68 | 69 | **Note:** While stories can create different representations of its data, most conversions are considered partial without the corresponding story format to create the playable form. 70 | -------------------------------------------------------------------------------- /docs/demos/compiler/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f8f9fa; 3 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 4 | } 5 | 6 | .demo-container { 7 | max-width: 1200px; 8 | margin: 0 auto; 9 | padding: 20px; 10 | } 11 | 12 | .demo-header { 13 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 14 | color: white; 15 | padding: 40px 20px; 16 | border-radius: 10px; 17 | margin-bottom: 30px; 18 | text-align: center; 19 | } 20 | 21 | .demo-section { 22 | background: white; 23 | border-radius: 10px; 24 | padding: 30px; 25 | margin-bottom: 20px; 26 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); 27 | } 28 | 29 | .form-control, .form-select { 30 | border-radius: 8px; 31 | border: 2px solid #e9ecef; 32 | transition: border-color 0.3s ease; 33 | } 34 | 35 | .form-control:focus, .form-select:focus { 36 | border-color: #667eea; 37 | box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25); 38 | } 39 | 40 | .btn-primary { 41 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 42 | border: none; 43 | border-radius: 8px; 44 | padding: 12px 30px; 45 | font-weight: 600; 46 | transition: all 0.3s ease; 47 | } 48 | 49 | .btn-primary:hover { 50 | transform: translateY(-2px); 51 | box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); 52 | } 53 | 54 | .output-container { 55 | background-color: #f8f9fa; 56 | border: 2px solid #e9ecef; 57 | border-radius: 8px; 58 | padding: 20px; 59 | margin-top: 20px; 60 | min-height: 200px; 61 | font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; 62 | font-size: 14px; 63 | white-space: pre-wrap; 64 | overflow-x: auto; 65 | } 66 | 67 | .loading { 68 | text-align: center; 69 | color: #6c757d; 70 | font-style: italic; 71 | } 72 | 73 | .error { 74 | color: #dc3545; 75 | background-color: #f8d7da; 76 | border: 1px solid #f5c6cb; 77 | border-radius: 5px; 78 | padding: 10px; 79 | margin-top: 10px; 80 | } 81 | 82 | .success { 83 | color: #155724; 84 | background-color: #d4edda; 85 | border: 1px solid #c3e6cb; 86 | border-radius: 5px; 87 | padding: 10px; 88 | margin-top: 10px; 89 | } 90 | 91 | .example-twee { 92 | background-color: #f1f3f4; 93 | border-left: 4px solid #667eea; 94 | padding: 15px; 95 | margin: 15px 0; 96 | border-radius: 0 8px 8px 0; 97 | } 98 | 99 | .format-info { 100 | background-color: #e7f3ff; 101 | border: 1px solid #b8daff; 102 | border-radius: 8px; 103 | padding: 15px; 104 | margin-top: 15px; 105 | } -------------------------------------------------------------------------------- /test/Config/Config.test.js: -------------------------------------------------------------------------------- 1 | import { reader as ConfigReader } from '../../src/Config/reader.js'; 2 | import {parser as ConfigParser} from '../../src/Config/parser.js'; 3 | 4 | describe('src/Config/reader.js', () => { 5 | describe('reader()', () => { 6 | it('should throw an error if the file does not exist', () => { 7 | expect(() => ConfigReader('non-existent-file.json')).toThrow('Error: File non-existent-file.json not found'); 8 | }); 9 | 10 | it('should throw an error if the file is not a valid JSON file', () => { 11 | expect(() => ConfigReader('test/Config/files/invalid.json')).toThrow(); 12 | }); 13 | 14 | it('should return the parsed JSON contents of the file', () => { 15 | const contents = ConfigReader('test/Config/files/valid.json'); 16 | expect(contents).toEqual({ 17 | "story-format": 'harlowe', 18 | "mode": "decompile", 19 | "input": "index.html", 20 | "output": "index.twee" 21 | }); 22 | }); 23 | }); 24 | 25 | describe('parser()', () => { 26 | 27 | it('should throw an error if the object is not a valid JSON object', () => { 28 | expect(() => ConfigParser('{')).toThrow(); 29 | }); 30 | 31 | it('should extract the StoryFormat and StoryFormatVersion from the JSON object', () => { 32 | const jsonObject = ConfigReader('test/Config/files/valid.json'); 33 | const contents = ConfigParser(jsonObject); 34 | expect(contents.StoryFormat).toEqual('harlowe'); 35 | expect(contents.StoryFormatVersion).toEqual('latest'); 36 | expect(contents.Input).toEqual('index.html'); 37 | expect(contents.Output).toEqual('index.twee'); 38 | expect(contents.Mode).toEqual('decompile'); 39 | expect(contents.Twine1Project).toEqual(false); 40 | }); 41 | 42 | it('should not extract options if they do not exist in the JSON object', () => { 43 | const jsonObject = ConfigReader('test/Config/files/empty.json'); 44 | const contents = ConfigParser(jsonObject); 45 | expect(contents.StoryFormat).toBeNull(); 46 | expect(contents.StoryFormatVersion).toBe('latest'); 47 | expect(contents.Input).toBeNull(); 48 | expect(contents.Output).toBeNull(); 49 | expect(contents.Mode).toBeNull(); 50 | expect(contents.Twine1Project).toBe(false); 51 | }); 52 | 53 | it('should set StoryFormatVersion to "latest" if it is not present in the JSON object', () => { 54 | const jsonObject = ConfigReader('test/Config/files/valid.json'); 55 | const contents = ConfigParser(jsonObject); 56 | expect(contents.StoryFormatVersion).toEqual('latest'); 57 | }); 58 | 59 | it('should set Twine1Project to false if it is not present in the JSON object', () => { 60 | const jsonObject = ConfigReader('test/Config/files/valid.json'); 61 | const contents = ConfigParser(jsonObject); 62 | expect(contents.Twine1Project).toEqual(false); 63 | }); 64 | 65 | it('Should read story-format, story-format-version, and twine1-project if present', () => { 66 | const jsonObject = ConfigReader('test/Config/files/full.json'); 67 | const contents = ConfigParser(jsonObject); 68 | expect(contents.StoryFormat).toEqual('harlowe'); 69 | expect(contents.StoryFormatVersion).toEqual('3.2.0'); 70 | expect(contents.Input).toEqual('index.twee'); 71 | expect(contents.Output).toEqual('index.html'); 72 | expect(contents.Mode).toEqual('compile'); 73 | expect(contents.Twine1Project).toEqual(false); 74 | }); 75 | }); 76 | }); -------------------------------------------------------------------------------- /test/Web/web-twine2archive.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | /** 6 | * Tests for web-twine2archive.js module 7 | * Tests module exports and functionality 8 | */ 9 | 10 | import { describe, expect, it } from '@jest/globals'; 11 | 12 | // Import to test basic functionality 13 | import { parse, compile } from '../../src/Web/web-twine2archive.js'; 14 | import Extwee from '../../src/Web/web-twine2archive.js'; 15 | 16 | describe('web-twine2archive.js module tests', () => { 17 | 18 | describe('ES6 module exports', () => { 19 | it('should export parse and compile functions', () => { 20 | expect(parse).toBeDefined(); 21 | expect(compile).toBeDefined(); 22 | expect(typeof parse).toBe('function'); 23 | expect(typeof compile).toBe('function'); 24 | }); 25 | 26 | it('should export default object with parseTwine2ArchiveHTML and compileTwine2ArchiveHTML', () => { 27 | expect(Extwee.parseTwine2ArchiveHTML).toBeDefined(); 28 | expect(Extwee.compileTwine2ArchiveHTML).toBeDefined(); 29 | expect(Extwee.parse).toBeDefined(); 30 | expect(Extwee.compile).toBeDefined(); 31 | expect(typeof Extwee.parseTwine2ArchiveHTML).toBe('function'); 32 | expect(typeof Extwee.compileTwine2ArchiveHTML).toBe('function'); 33 | }); 34 | }); 35 | 36 | describe('Global object assignment', () => { 37 | it('should assign functions to global object when available', () => { 38 | // In Node.js environment, should assign to globalThis 39 | expect(globalThis.Extwee).toBeDefined(); 40 | expect(globalThis.Extwee.parseTwine2ArchiveHTML).toBeDefined(); 41 | expect(globalThis.Extwee.compileTwine2ArchiveHTML).toBeDefined(); 42 | expect(typeof globalThis.Extwee.parseTwine2ArchiveHTML).toBe('function'); 43 | expect(typeof globalThis.Extwee.compileTwine2ArchiveHTML).toBe('function'); 44 | }); 45 | 46 | it('should preserve existing Extwee properties', () => { 47 | // Should not overwrite the entire object, just add properties 48 | if (globalThis.Extwee && globalThis.Extwee.version) { 49 | expect(globalThis.Extwee.version).toBeDefined(); 50 | } 51 | expect(globalThis.Extwee.parseTwine2ArchiveHTML).toBeDefined(); 52 | expect(globalThis.Extwee.compileTwine2ArchiveHTML).toBeDefined(); 53 | }); 54 | }); 55 | 56 | describe('Functional integration tests', () => { 57 | it('should have working parseTwine2ArchiveHTML function', () => { 58 | // Test with valid Twine 2 Archive HTML 59 | const sampleHtml = ` 60 | 61 | Start passage 62 | 63 | `; 64 | 65 | expect(() => { 66 | const result = parse(sampleHtml); 67 | expect(result).toBeDefined(); 68 | expect(Array.isArray(result)).toBe(true); 69 | }).not.toThrow(); 70 | }); 71 | 72 | it('should have working compileTwine2ArchiveHTML function', async () => { 73 | // Import required classes 74 | const { Story } = await import('../../src/Story.js'); 75 | const { default: Passage } = await import('../../src/Passage.js'); 76 | 77 | const story = new Story(); 78 | story.name = "Test Story"; 79 | story.IFID = "12345678-1234-5678-9012-123456789012"; 80 | story.addPassage(new Passage("Start", "This is the start", [], {})); 81 | 82 | expect(() => { 83 | const result = compile([story]); 84 | expect(typeof result).toBe('string'); 85 | }).not.toThrow(); 86 | }); 87 | 88 | it('should have same functions in exports and global', () => { 89 | // Test that parse and compile are the same functions 90 | expect(parse).toBe(Extwee.parse); 91 | expect(compile).toBe(Extwee.compile); 92 | expect(parse).toBe(Extwee.parseTwine2ArchiveHTML); 93 | expect(compile).toBe(Extwee.compileTwine2ArchiveHTML); 94 | }); 95 | }); 96 | }); -------------------------------------------------------------------------------- /test/Web/web-core-global.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | // Import the module to test global assignment in jsdom environment 6 | import '../../src/Web/web-core.js'; 7 | 8 | describe('web-core.js global assignment in browser environment', () => { 9 | it('should assign Extwee to window in jsdom environment', () => { 10 | // Should have assigned to window automatically on import 11 | expect(window.Extwee).toBeDefined(); 12 | expect(window.Extwee.version).toBe('2.3.3'); 13 | expect(typeof window.Extwee).toBe('object'); 14 | }); 15 | 16 | it('should have all expected properties on window.Extwee', () => { 17 | expect(window.Extwee.parseTwee).toBeDefined(); 18 | expect(window.Extwee.parseJSON).toBeDefined(); 19 | expect(window.Extwee.parseStoryFormat).toBeDefined(); 20 | expect(window.Extwee.parseTwine2HTML).toBeDefined(); 21 | expect(window.Extwee.compileTwine2HTML).toBeDefined(); 22 | expect(window.Extwee.generateIFID).toBeDefined(); 23 | expect(window.Extwee.Story).toBeDefined(); 24 | expect(window.Extwee.Passage).toBeDefined(); 25 | expect(window.Extwee.StoryFormat).toBeDefined(); 26 | }); 27 | 28 | it('should verify global object detection logic ran (window branch)', () => { 29 | // This test verifies that the globalObject detection function found window 30 | // and assigned Extwee to it 31 | expect(typeof window).toBe('object'); 32 | expect(window).not.toBeNull(); 33 | expect(window.Extwee).toBeDefined(); 34 | }); 35 | 36 | it('should have working functions on window.Extwee', () => { 37 | // Test that the assigned functions work correctly 38 | const ifid = window.Extwee.generateIFID(); 39 | expect(ifid).toMatch(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/); 40 | 41 | const story = new window.Extwee.Story(); 42 | expect(story).toBeDefined(); 43 | expect(Array.isArray(story.passages)).toBe(true); 44 | }); 45 | 46 | // Test to exercise the globalObject detection function 47 | it('should test global object detection function directly', () => { 48 | // This is a test to simulate what the global object detection function does 49 | // We can't easily mock the environment during module loading, but we can 50 | // verify the logic by recreating it 51 | 52 | const globalObjectDetectionLogic = function() { 53 | if (typeof globalThis !== 'undefined') return 'globalThis'; 54 | if (typeof window !== 'undefined') return 'window'; 55 | if (typeof global !== 'undefined') return 'global'; 56 | if (typeof self !== 'undefined') return 'self'; 57 | return null; 58 | }; 59 | 60 | // In jsdom environment, could return 'globalThis' or 'window' depending on Node.js version 61 | const result = globalObjectDetectionLogic(); 62 | expect(['globalThis', 'window']).toContain(result); 63 | 64 | // Verify window exists and is truthy 65 | expect(typeof window).toBe('object'); 66 | expect(window).toBeTruthy(); 67 | }); 68 | 69 | // Test for globalThis availability (Modern browsers/Node.js 12+) 70 | it('should handle globalThis when available', () => { 71 | // Test the globalThis branch logic 72 | if (typeof globalThis !== 'undefined') { 73 | expect(globalThis).toBeDefined(); 74 | expect(typeof globalThis).toBe('object'); 75 | 76 | // In environments with globalThis, it should be preferred 77 | const mockGlobalDetection = function() { 78 | if (typeof globalThis !== 'undefined') return 'globalThis'; 79 | if (typeof window !== 'undefined') return 'window'; 80 | if (typeof global !== 'undefined') return 'global'; 81 | if (typeof self !== 'undefined') return 'self'; 82 | return null; 83 | }; 84 | 85 | // Should prefer globalThis if available 86 | const detectionResult = mockGlobalDetection(); 87 | expect(['globalThis', 'window']).toContain(detectionResult); 88 | } else { 89 | // If globalThis not available, should fall back to window in jsdom 90 | expect(typeof window).toBe('object'); 91 | } 92 | }); 93 | }); -------------------------------------------------------------------------------- /types/StoryFormat.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * StoryFormat representing a Twine 2 story format. 3 | * 4 | * This class has type checking on all of its properties. 5 | * If a property is set to a value of the wrong type, a TypeError will be thrown. 6 | * 7 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-storyformats-spec.md Twine 2 Story Formats Specification} 8 | * 9 | * @class 10 | * @classdesc A class representing a Twine 2 story format. 11 | * @property {string} name - The name of the story format. 12 | * @property {string} version - The semantic version of the story format. 13 | * @property {string} description - The description of the story format. 14 | * @property {string} author - The author of the story format. 15 | * @property {string} image - The image of the story format. 16 | * @property {string} url - The URL of the story format. 17 | * @property {string} license - The license of the story format. 18 | * @property {boolean} proofing - The proofing of the story format. 19 | * @property {string} source - The source of the story format. 20 | * @example 21 | * const sf = new StoryFormat(); 22 | * sf.name = 'New'; 23 | * sf.version = '1.0.0'; 24 | * sf.description = 'New'; 25 | * sf.author = 'New'; 26 | * sf.image = 'New'; 27 | * sf.url = 'New'; 28 | * sf.license = 'New'; 29 | * sf.proofing = true; 30 | * sf.source = 'New'; 31 | */ 32 | export default class StoryFormat { 33 | constructor(name?: string, version?: string, description?: string, author?: string, image?: string, url?: string, license?: string, proofing?: boolean, source?: string); 34 | /** 35 | * @param {string} n - Replacement name. 36 | */ 37 | set name(n: string); 38 | /** 39 | * Name 40 | * @returns {string} Name. 41 | */ 42 | get name(): string; 43 | /** 44 | * @param {string} n - Replacement version. 45 | */ 46 | set version(n: string); 47 | /** 48 | * Version. 49 | * @returns {string} Version. 50 | */ 51 | get version(): string; 52 | /** 53 | * @param {string} d - Replacement description. 54 | */ 55 | set description(d: string); 56 | /** 57 | * Description. 58 | * @returns {string} Description. 59 | */ 60 | get description(): string; 61 | /** 62 | * @param {string} a - Replacement author. 63 | */ 64 | set author(a: string); 65 | /** 66 | * Author. 67 | * @returns {string} Author. 68 | */ 69 | get author(): string; 70 | /** 71 | * @param {string} i - Replacement image. 72 | */ 73 | set image(i: string); 74 | /** 75 | * Image. 76 | * @returns {string} Image. 77 | */ 78 | get image(): string; 79 | /** 80 | * @param {string} u - Replacement URL. 81 | */ 82 | set url(u: string); 83 | /** 84 | * URL. 85 | * @returns {string} URL. 86 | */ 87 | get url(): string; 88 | /** 89 | * @param {string} l - Replacement license. 90 | */ 91 | set license(l: string); 92 | /** 93 | * License. 94 | * @returns {string} License. 95 | */ 96 | get license(): string; 97 | /** 98 | * @param {boolean} p - Replacement proofing. 99 | */ 100 | set proofing(p: boolean); 101 | /** 102 | * Proofing. 103 | * @returns {boolean} Proofing. 104 | */ 105 | get proofing(): boolean; 106 | /** 107 | * @param {string} s - Replacement source. 108 | */ 109 | set source(s: string); 110 | /** 111 | * Source. 112 | * @returns {string} Source. 113 | */ 114 | get source(): string; 115 | /** 116 | * Produces a string representation of the story format object. 117 | * @method toString 118 | * @returns {string} - A string representation of the story format. 119 | */ 120 | toString(): string; 121 | /** 122 | * Produces a JSON representation of the story format object. 123 | * @method toJSON 124 | * @returns {object} - A JSON representation of the story format. 125 | */ 126 | toJSON(): object; 127 | #private; 128 | } 129 | -------------------------------------------------------------------------------- /types/src/StoryFormat.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * StoryFormat representing a Twine 2 story format. 3 | * 4 | * This class has type checking on all of its properties. 5 | * If a property is set to a value of the wrong type, a TypeError will be thrown. 6 | * 7 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-2-storyformats-spec.md Twine 2 Story Formats Specification} 8 | * 9 | * @class 10 | * @classdesc A class representing a Twine 2 story format. 11 | * @property {string} name - The name of the story format. 12 | * @property {string} version - The semantic version of the story format. 13 | * @property {string} description - The description of the story format. 14 | * @property {string} author - The author of the story format. 15 | * @property {string} image - The image of the story format. 16 | * @property {string} url - The URL of the story format. 17 | * @property {string} license - The license of the story format. 18 | * @property {boolean} proofing - The proofing of the story format. 19 | * @property {string} source - The source of the story format. 20 | * @example 21 | * const sf = new StoryFormat(); 22 | * sf.name = 'New'; 23 | * sf.version = '1.0.0'; 24 | * sf.description = 'New'; 25 | * sf.author = 'New'; 26 | * sf.image = 'New'; 27 | * sf.url = 'New'; 28 | * sf.license = 'New'; 29 | * sf.proofing = true; 30 | * sf.source = 'New'; 31 | */ 32 | export default class StoryFormat { 33 | constructor(name?: string, version?: string, description?: string, author?: string, image?: string, url?: string, license?: string, proofing?: boolean, source?: string); 34 | /** 35 | * @param {string} n - Replacement name. 36 | */ 37 | set name(n: string); 38 | /** 39 | * Name 40 | * @returns {string} Name. 41 | */ 42 | get name(): string; 43 | /** 44 | * @param {string} n - Replacement version. 45 | */ 46 | set version(n: string); 47 | /** 48 | * Version. 49 | * @returns {string} Version. 50 | */ 51 | get version(): string; 52 | /** 53 | * @param {string} d - Replacement description. 54 | */ 55 | set description(d: string); 56 | /** 57 | * Description. 58 | * @returns {string} Description. 59 | */ 60 | get description(): string; 61 | /** 62 | * @param {string} a - Replacement author. 63 | */ 64 | set author(a: string); 65 | /** 66 | * Author. 67 | * @returns {string} Author. 68 | */ 69 | get author(): string; 70 | /** 71 | * @param {string} i - Replacement image. 72 | */ 73 | set image(i: string); 74 | /** 75 | * Image. 76 | * @returns {string} Image. 77 | */ 78 | get image(): string; 79 | /** 80 | * @param {string} u - Replacement URL. 81 | */ 82 | set url(u: string); 83 | /** 84 | * URL. 85 | * @returns {string} URL. 86 | */ 87 | get url(): string; 88 | /** 89 | * @param {string} l - Replacement license. 90 | */ 91 | set license(l: string); 92 | /** 93 | * License. 94 | * @returns {string} License. 95 | */ 96 | get license(): string; 97 | /** 98 | * @param {boolean} p - Replacement proofing. 99 | */ 100 | set proofing(p: boolean); 101 | /** 102 | * Proofing. 103 | * @returns {boolean} Proofing. 104 | */ 105 | get proofing(): boolean; 106 | /** 107 | * @param {string} s - Replacement source. 108 | */ 109 | set source(s: string); 110 | /** 111 | * Source. 112 | * @returns {string} Source. 113 | */ 114 | get source(): string; 115 | /** 116 | * Produces a string representation of the story format object. 117 | * @method toString 118 | * @returns {string} - A string representation of the story format. 119 | */ 120 | toString(): string; 121 | /** 122 | * Produces a JSON representation of the story format object. 123 | * @method toJSON 124 | * @returns {object} - A JSON representation of the story format. 125 | */ 126 | toJSON(): object; 127 | #private; 128 | } 129 | -------------------------------------------------------------------------------- /test/Objects/SnowmanCompatibility.test.js: -------------------------------------------------------------------------------- 1 | import Passage from '../../src/Passage.js'; 2 | import { Story } from '../../src/Story.js'; 3 | 4 | describe('Snowman Compatibility Tests', function () { 5 | describe('JavaScript Code in Passages', function () { 6 | it('Should not HTML-encode JavaScript with quotes', function () { 7 | const p = new Passage('External', '<%\n$.getScript("https://code.jquery.com/jquery-3.6.0.min.js");\n%>'); 8 | const html = p.toTwine2HTML(); 9 | 10 | // Should NOT have HTML entities 11 | expect(html.includes('"')).toBe(true); 12 | expect(html.includes('<')).toBe(true); 13 | expect(html.includes('>')).toBe(true); 14 | }); 15 | 16 | it('Should preserve complex JavaScript code', function () { 17 | const code = `<% 18 | var data = {"key": "value", "test": true}; 19 | if (data.test) { 20 | console.log("This is a test"); 21 | } 22 | %>`; 23 | const p = new Passage('Test', code); 24 | const html = p.toTwine2HTML(); 25 | 26 | expect(html.includes('"')).toBe(true); 27 | expect(html.includes('<')).toBe(true); 28 | expect(html.includes('>')).toBe(true); 29 | }); 30 | }); 31 | 32 | describe('HTML Content in Passages', function () { 33 | it('Should not HTML-encode HTML tags', function () { 34 | const p = new Passage('HUD', '

This is the HUD!

\n

Status: Active

'); 35 | const html = p.toTwine2HTML(); 36 | 37 | // Should NOT have HTML entities 38 | expect(html.includes('<h1>')).toBe(true); 39 | expect(html.includes('<strong>')).toBe(true); 40 | }); 41 | 42 | it('Should preserve nested HTML structures', function () { 43 | const htmlContent = `
44 |

Title

45 |
    46 |
  • Item 1
  • 47 |
  • Item 2
  • 48 |
49 |
`; 50 | const p = new Passage('Container', htmlContent); 51 | const html = p.toTwine2HTML(); 52 | 53 | expect(html.includes('<')).toBe(true); 54 | expect(html.includes('>')).toBe(true); 55 | }); 56 | }); 57 | 58 | describe('Round-trip Compatibility', function () { 59 | it('Should round-trip JavaScript code correctly', function () { 60 | const code = '$.getScript("test.js");'; 61 | const p1 = new Passage('Test', code); 62 | const html = p1.toTwine2HTML(); 63 | 64 | // Verify that the quotation marks is encoded. 65 | expect(html.includes('"')).toBe(true); 66 | }); 67 | 68 | it('Should generate valid Story HTML with unencoded content', function () { 69 | const story = new Story(); 70 | story.name = 'Test Story'; 71 | story.IFID = 'TEST-IFID-1234'; 72 | 73 | const p1 = new Passage('Start', '<%\nconsole.log("test");\n%>'); 74 | const p2 = new Passage('HUD', '

Header

'); 75 | 76 | story.passages = [p1, p2]; 77 | 78 | const html = story.toTwine2HTML(); 79 | 80 | // Verify both passages have encoded content. 81 | expect(html.includes('"')).toBe(true); 82 | expect(html.includes('<h1>')).toBe(true); 83 | }); 84 | }); 85 | 86 | describe('Edge Cases', function () { 87 | it('Should handle CDATA-like content', function () { 88 | const content = ''; 89 | const p = new Passage('Test', content); 90 | const html = p.toTwine2HTML(); 91 | 92 | expect(html.includes('<![CDATA[Some data]]>')).toBe(true); 93 | }); 94 | 95 | it('Should handle mixed quotes', function () { 96 | const content = `var str = "She said 'hello' to me";`; 97 | const p = new Passage('Test', content); 98 | const html = p.toTwine2HTML(); 99 | 100 | expect(html.includes('"')).toBe(true); 101 | }); 102 | 103 | it('Should handle template literals', function () { 104 | const content = 'const msg = `Hello ${name}`;'; 105 | const p = new Passage('Test', content); 106 | const html = p.toTwine2HTML(); 107 | 108 | expect(html.includes(content)).toBe(true); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /test/Web/web-twine1html.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | /** 6 | * Tests for web-twine1html.js module 7 | * Tests module exports and functionality 8 | */ 9 | 10 | import { describe, expect, it } from '@jest/globals'; 11 | 12 | // Import to test basic functionality 13 | import { parse, compile } from '../../src/Web/web-twine1html.js'; 14 | import Extwee from '../../src/Web/web-twine1html.js'; 15 | 16 | describe('web-twine1html.js module tests', () => { 17 | 18 | describe('ES6 module exports', () => { 19 | it('should export parse and compile functions', () => { 20 | expect(parse).toBeDefined(); 21 | expect(compile).toBeDefined(); 22 | expect(typeof parse).toBe('function'); 23 | expect(typeof compile).toBe('function'); 24 | }); 25 | 26 | it('should export default object with parseTwine1HTML and compileTwine1HTML', () => { 27 | expect(Extwee.parseTwine1HTML).toBeDefined(); 28 | expect(Extwee.compileTwine1HTML).toBeDefined(); 29 | expect(Extwee.parse).toBeDefined(); 30 | expect(Extwee.compile).toBeDefined(); 31 | expect(typeof Extwee.parseTwine1HTML).toBe('function'); 32 | expect(typeof Extwee.compileTwine1HTML).toBe('function'); 33 | }); 34 | }); 35 | 36 | describe('Global object assignment', () => { 37 | it('should assign functions to global object when available', () => { 38 | // In Node.js environment, should assign to globalThis 39 | expect(globalThis.Extwee).toBeDefined(); 40 | expect(globalThis.Extwee.parseTwine1HTML).toBeDefined(); 41 | expect(globalThis.Extwee.compileTwine1HTML).toBeDefined(); 42 | expect(typeof globalThis.Extwee.parseTwine1HTML).toBe('function'); 43 | expect(typeof globalThis.Extwee.compileTwine1HTML).toBe('function'); 44 | }); 45 | 46 | it('should preserve existing Extwee properties', () => { 47 | // Should not overwrite the entire object, just add properties 48 | if (globalThis.Extwee && globalThis.Extwee.version) { 49 | expect(globalThis.Extwee.version).toBeDefined(); 50 | } 51 | expect(globalThis.Extwee.parseTwine1HTML).toBeDefined(); 52 | expect(globalThis.Extwee.compileTwine1HTML).toBeDefined(); 53 | }); 54 | }); 55 | 56 | describe('Functional integration tests', () => { 57 | it('should have working parseTwine1HTML function', () => { 58 | // Test with valid Twine 1 HTML 59 | const sampleHtml = ` 60 | 61 | Test 62 | 63 |
64 |
Start passage
65 |
66 | 67 | 68 | `; 69 | 70 | expect(() => { 71 | const result = parse(sampleHtml); 72 | expect(result).toBeDefined(); 73 | expect(result.passages).toBeDefined(); 74 | }).not.toThrow(); 75 | }); 76 | 77 | it('should have working compileTwine1HTML function', async () => { 78 | // Import required classes dynamically to avoid circular imports 79 | const { Story } = await import('../../src/Story.js'); 80 | const { default: Passage } = await import('../../src/Passage.js'); 81 | const { default: StoryFormat } = await import('../../src/StoryFormat.js'); 82 | 83 | const story = new Story(); 84 | story.name = "Test Story"; 85 | story.addPassage(new Passage("Start", "This is the start", [], {})); 86 | 87 | const storyFormat = new StoryFormat(); 88 | storyFormat.source = "window.story = STORY;"; 89 | storyFormat.version = "1.0.0"; 90 | 91 | expect(() => { 92 | const result = compile(story, storyFormat, '', '', ''); 93 | expect(typeof result).toBe('string'); 94 | }).not.toThrow(); 95 | }); 96 | 97 | it('should have same functions in exports and global', () => { 98 | // Test that parse and compile are the same functions 99 | expect(parse).toBe(Extwee.parse); 100 | expect(compile).toBe(Extwee.compile); 101 | expect(parse).toBe(Extwee.parseTwine1HTML); 102 | expect(compile).toBe(Extwee.compileTwine1HTML); 103 | }); 104 | }); 105 | }); -------------------------------------------------------------------------------- /src/Twine1HTML/parse.js: -------------------------------------------------------------------------------- 1 | import { parse as HtmlParser } from 'node-html-parser'; 2 | import Passage from '../Passage.js'; 3 | import { Story } from '../Story.js'; 4 | 5 | /** 6 | * Parses Twine 1 HTML into a Story object. 7 | * @see {@link https://github.com/iftechfoundation/twine-specs/blob/master/twine-1-htmloutput-doc.md Twine 1 HTML Documentation} 8 | * @function parse 9 | * @param {string} content - Twine 1 HTML content to parse. 10 | * @returns {Story} Story object 11 | */ 12 | function parse (content) { 13 | // Create a default Story. 14 | const s = new Story(); 15 | 16 | // Send to node-html-parser. 17 | // Enable getting the content of 'script', 'style', and 'pre' elements. 18 | // Get back a DOM. 19 | const dom = new HtmlParser( 20 | content, 21 | { 22 | lowerCaseTagName: false, 23 | script: true, 24 | style: true, 25 | pre: true 26 | }); 27 | 28 | // Look for `
`. 29 | let storyData = dom.querySelector('#storeArea'); 30 | 31 | // Does the `
` element exist? 32 | if (storyData === null) { 33 | // Look for `
`. 34 | storyData = dom.querySelector('#store-area'); 35 | // Check for null 36 | if (storyData == null) { 37 | // Can't find any story data. 38 | throw new Error('Cannot find #storeArea or #store-area!'); 39 | } 40 | } 41 | 42 | // Pull out the `[tiddler]` elements. 43 | const storyPassages = dom.querySelectorAll('[tiddler]'); 44 | 45 | // Move through the passages. 46 | for (const passage in storyPassages) { 47 | // Get the passage attributes. 48 | const attr = storyPassages[passage].attributes; 49 | // Get the passage text. 50 | const text = storyPassages[passage].rawText; 51 | 52 | /** 53 | * twine-position: (string) Required. 54 | * Comma-separated X and Y coordinates of the passage within Twine 1. 55 | */ 56 | // Set a default position. 57 | let position = null; 58 | // Does position exist? 59 | if (Object.prototype.hasOwnProperty.call(attr, 'twine-position')) { 60 | // Update position. 61 | position = attr['twine-position']; 62 | } 63 | 64 | /** 65 | * tiddler: (string) Required. 66 | * The name of the passage. 67 | */ 68 | // Create a default value. 69 | const name = attr.tiddler; 70 | // Is this `StoryTitle`? 71 | if (name === 'StoryTitle') { 72 | // If StoryTitle exists, we accept the story name. 73 | s.name = text; 74 | } 75 | 76 | /** 77 | * tags: (string) Required. 78 | * Space-separated list of passages tags, if any. 79 | */ 80 | // Create empty tag array. 81 | let tags = []; 82 | // Does the tags attribute exist? 83 | if (Object.prototype.hasOwnProperty.call(attr, 'tags')) { 84 | // Escape any tags 85 | // (Attributes can, themselves, be empty strings.) 86 | if (attr.tags.length > 0 && attr.tags !== '""') { 87 | // Escape the tags. 88 | tags = attr.tags; 89 | // Split by spaces into an array. 90 | tags = tags.split(' '); 91 | } 92 | 93 | // Remove any empty strings. 94 | tags = tags.filter(tag => tag !== ''); 95 | } 96 | 97 | // Create metadata for passage. 98 | // We translate Twine 1 attribute into Twine 2 metadata. 99 | const metadata = {}; 100 | 101 | // Does position exist? 102 | if (position !== null) { 103 | // Add the property to metadata 104 | metadata.position = position; 105 | } 106 | 107 | /** 108 | * modifier: (string) Optional. 109 | * Name of the tool that last edited the passage. 110 | * Generally, for versions of Twine 1, this value will be "twee". 111 | * Twee compilers may place their own name (e.g. "tweego" for Tweego). 112 | */ 113 | if (Object.prototype.hasOwnProperty.call(attr, 'modifier')) { 114 | // In Twine 2, `creator` maps to Twine 1's `modifier`. 115 | s.creator = attr.modifier; 116 | } 117 | 118 | // Add the passage. 119 | s.addPassage( 120 | new Passage( 121 | name, 122 | text, 123 | tags, 124 | metadata 125 | ) 126 | ); 127 | } 128 | 129 | // Return story object. 130 | return s; 131 | } 132 | 133 | export { parse }; 134 | --------------------------------------------------------------------------------