├── 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 |
2 |
3 |
4 | Work
5 |
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 |
10 |
11 | Double-click this passage to edit it.
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/test/Twine2HTML/Twine2HTMLParser/missingStyle.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Tags
6 |
7 |
8 |
9 |
10 |
11 | Double-click this passage to edit it.
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/test/Twine2HTML/Twine2HTMLParser/missingIFID.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Tags
6 |
7 |
8 |
9 | Double-click this passage to edit it.
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 | Double-click this passage to edit it.
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test/Twine2HTML/Twine2HTMLParser/missingCreatorVersion.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Tags
6 |
7 |
8 |
9 | Double-click this passage to edit it.
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test/Twine2HTML/Twine2HTMLParser/missingFormat.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Tags
6 |
7 |
8 |
9 | Double-click this passage to edit it.
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test/Twine2HTML/Twine2HTMLParser/missingFormatVersion.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Tags
6 |
7 |
8 |
9 | Double-click this passage to edit it.
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test/Twine2HTML/Twine2HTMLParser/missingStartnode.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Tags
6 |
7 |
8 |
9 | Double-click this passage to edit it.
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test/Twine2HTML/Twine2HTMLParser/missingZoom.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Tags
6 |
7 |
8 |
9 | Double-click this passage to edit it.
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 |
10 |
11 |
12 | Double-click this passage to edit it.
13 |
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 |
10 |
11 |
12 | Double-click this passage to edit it.
13 |
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 |
10 |
11 |
12 | Double-click this passage to edit it.
13 |
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 |
20 |
24 |
28 | Double-click this passage to edit it.
33 |
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 |
10 | [[Another passage]]
11 |
12 | [[A third passage]][[A fourth passage]]
13 |
14 | [[A third passage]] [[Start]][[A fifth passage]]Double-click this passage to edit it.
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 | [[Another passage]]
10 |
11 | [[A third passage]][[A fourth passage]]
12 |
13 | [[A third passage]] [[Start]][[A fifth passage]]Double-click this passage to edit it.
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 | [[Another passage]]
10 |
11 | [[A third passage]][[A fourth passage]]
12 |
13 | [[A third passage]] [[Start]][[A fifth passage]]Double-click this passage to edit it.
14 |
15 |
16 |
--------------------------------------------------------------------------------
/test/Twine2HTML/Twine2HTMLParser/twineExample3.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | twineExample
6 |
7 |
8 |
9 | [[Another passage]]
10 |
11 | [[A third passage]][[A fourth passage]]
12 |
13 | [[A third passage]] [[Start]][[A fifth passage]]Double-click this passage to edit it.
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 |
10 |
11 |
12 | [[Another passage]]
13 |
14 | [[A third passage]]
15 | [[A fourth passage]]
16 |
17 | [[A third passage]]
18 | [[Start]]
19 | [[A fifth passage]]
20 | Double-click this passage to edit it.
21 |
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 |
19 |
27 |
33 | [[Another passage]]
39 | [[A third]]
45 | Double-click this passage to edit it.
51 |
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!
\nStatus: 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 |
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 |
--------------------------------------------------------------------------------