playable-lessons 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/out/cli/index.js +77 -0
- package/out/shared/storyExport.js +204 -0
- package/package.json +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 michael-borck
|
|
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.
|
package/out/cli/index.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const yargs_1 = __importDefault(require("yargs"));
|
|
8
|
+
const helpers_1 = require("yargs/helpers");
|
|
9
|
+
const promises_1 = require("fs/promises");
|
|
10
|
+
const path_1 = require("path");
|
|
11
|
+
const storyExport_js_1 = require("../shared/storyExport.js");
|
|
12
|
+
(0, yargs_1.default)((0, helpers_1.hideBin)(process.argv))
|
|
13
|
+
.scriptName('playable-lessons')
|
|
14
|
+
.usage('$0 <command> [options]')
|
|
15
|
+
.command('validate', 'Validate an Ink source file', (yargs) => {
|
|
16
|
+
return yargs.option('input', { alias: 'i', type: 'string', demandOption: true, describe: 'Path to .ink file' });
|
|
17
|
+
}, async (argv) => {
|
|
18
|
+
try {
|
|
19
|
+
const source = await (0, promises_1.readFile)(argv.input, 'utf-8');
|
|
20
|
+
await (0, storyExport_js_1.compileInk)(source);
|
|
21
|
+
console.log('Valid! Story compiled successfully.');
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.error('Validation failed:', err instanceof Error ? err.message : err);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
.command('export', 'Export an Ink file to another format', (yargs) => {
|
|
29
|
+
return yargs
|
|
30
|
+
.option('input', { alias: 'i', type: 'string', demandOption: true, describe: 'Path to .ink file' })
|
|
31
|
+
.option('output', { alias: 'o', type: 'string', demandOption: true, describe: 'Output directory' })
|
|
32
|
+
.option('format', { alias: 'f', type: 'string', default: 'html', describe: 'Export format: html, ink, json' })
|
|
33
|
+
.option('title', { alias: 't', type: 'string', default: 'Story', describe: 'Story title' });
|
|
34
|
+
}, async (argv) => {
|
|
35
|
+
try {
|
|
36
|
+
const source = await (0, promises_1.readFile)(argv.input, 'utf-8');
|
|
37
|
+
const outputDir = argv.output;
|
|
38
|
+
await (0, promises_1.mkdir)(outputDir, { recursive: true });
|
|
39
|
+
const formats = argv.format.split(',');
|
|
40
|
+
const title = argv.title;
|
|
41
|
+
const baseName = (0, path_1.basename)(argv.input, '.ink');
|
|
42
|
+
for (const format of formats) {
|
|
43
|
+
switch (format.trim()) {
|
|
44
|
+
case 'html': {
|
|
45
|
+
const html = await (0, storyExport_js_1.exportStandaloneHTML)(source, title);
|
|
46
|
+
const outPath = (0, path_1.join)(outputDir, `${baseName}.html`);
|
|
47
|
+
await (0, promises_1.writeFile)(outPath, html);
|
|
48
|
+
console.log(`Exported HTML: ${outPath}`);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case 'json': {
|
|
52
|
+
const json = await (0, storyExport_js_1.compileInk)(source);
|
|
53
|
+
const outPath = (0, path_1.join)(outputDir, `${baseName}.ink.json`);
|
|
54
|
+
await (0, promises_1.writeFile)(outPath, json);
|
|
55
|
+
console.log(`Exported JSON: ${outPath}`);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case 'ink': {
|
|
59
|
+
const outPath = (0, path_1.join)(outputDir, `${baseName}.ink`);
|
|
60
|
+
await (0, promises_1.writeFile)(outPath, source);
|
|
61
|
+
console.log(`Exported Ink: ${outPath}`);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
default:
|
|
65
|
+
console.warn(`Unknown format: ${format}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error('Export failed:', err instanceof Error ? err.message : err);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
.demandCommand(1, 'You must specify a command')
|
|
75
|
+
.help()
|
|
76
|
+
.version('0.1.0')
|
|
77
|
+
.parse();
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared, dependency-light story logic used by BOTH the renderer (via Vite)
|
|
4
|
+
* and the CLI (via tsc/NodeNext). Keep this file self-contained — it must not
|
|
5
|
+
* import other local modules, so it resolves cleanly under both bundler and
|
|
6
|
+
* NodeNext module resolution.
|
|
7
|
+
*
|
|
8
|
+
* Only side dependency is a dynamic import of inkjs's Compiler.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.escapeHtml = escapeHtml;
|
|
12
|
+
exports.ensureStartDivert = ensureStartDivert;
|
|
13
|
+
exports.compileInk = compileInk;
|
|
14
|
+
exports.exportStandaloneHTML = exportStandaloneHTML;
|
|
15
|
+
// inkjs runtime loaded by exported HTML. Pin to the same major/minor the app
|
|
16
|
+
// compiles with (see package.json `inkjs`) so the compiled JSON format matches
|
|
17
|
+
// the runtime that plays it, and lock it down with a Subresource Integrity hash.
|
|
18
|
+
const INKJS_RUNTIME_URL = 'https://cdn.jsdelivr.net/npm/inkjs@2.4.0/dist/ink.js';
|
|
19
|
+
const INKJS_RUNTIME_SRI = 'sha384-wR7zU2DW6Ji/8kGVhTaLk0TSOucJhD4Zhv7lR5G1k/HoXtcWhEw5nIlcyE/+GtMb';
|
|
20
|
+
/**
|
|
21
|
+
* Escape a string for safe interpolation into HTML text or double-quoted
|
|
22
|
+
* attribute values. Escapes &, <, >, ", and ' (the single quote matters for
|
|
23
|
+
* single-quoted attributes and is cheap to include).
|
|
24
|
+
*/
|
|
25
|
+
function escapeHtml(text) {
|
|
26
|
+
return String(text)
|
|
27
|
+
.replace(/&/g, '&')
|
|
28
|
+
.replace(/</g, '<')
|
|
29
|
+
.replace(/>/g, '>')
|
|
30
|
+
.replace(/"/g, '"')
|
|
31
|
+
.replace(/'/g, ''');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Ensures the Ink source has a top-level divert to the first knot.
|
|
35
|
+
* Without this, the story starts at the root and immediately ends.
|
|
36
|
+
*/
|
|
37
|
+
function ensureStartDivert(source) {
|
|
38
|
+
const trimmed = source.trim();
|
|
39
|
+
// Check if there's already a top-level divert before the first knot
|
|
40
|
+
const firstKnotIndex = trimmed.search(/^===\s*\w+\s*===/m);
|
|
41
|
+
if (firstKnotIndex === -1)
|
|
42
|
+
return source; // no knots at all
|
|
43
|
+
const beforeFirstKnot = trimmed.substring(0, firstKnotIndex);
|
|
44
|
+
if (/^->\s*\w+/m.test(beforeFirstKnot))
|
|
45
|
+
return source; // already has a divert
|
|
46
|
+
// Find the name of the first knot
|
|
47
|
+
const knotMatch = trimmed.match(/^===\s*(\w+)\s*===/m);
|
|
48
|
+
if (!knotMatch)
|
|
49
|
+
return source;
|
|
50
|
+
const firstKnotName = knotMatch[1];
|
|
51
|
+
// Insert a divert just before the first knot
|
|
52
|
+
return trimmed.substring(0, firstKnotIndex) + `-> ${firstKnotName}\n` + trimmed.substring(firstKnotIndex);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Compiles Ink source to JSON using inkjs's Compiler.
|
|
56
|
+
* Returns the compiled JSON string.
|
|
57
|
+
*/
|
|
58
|
+
async function compileInk(inkSource) {
|
|
59
|
+
const { Compiler } = await import('inkjs/compiler/Compiler');
|
|
60
|
+
const source = ensureStartDivert(inkSource);
|
|
61
|
+
const compiler = new Compiler(source);
|
|
62
|
+
const story = compiler.Compile();
|
|
63
|
+
if (!story) {
|
|
64
|
+
throw new Error('Ink compilation failed: no story produced');
|
|
65
|
+
}
|
|
66
|
+
const json = story.ToJson();
|
|
67
|
+
if (!json) {
|
|
68
|
+
throw new Error('Ink compilation failed: could not serialize to JSON');
|
|
69
|
+
}
|
|
70
|
+
return json;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Exports a standalone HTML file that plays the story.
|
|
74
|
+
* The inkjs runtime is loaded from a pinned CDN URL (with SRI) in the export.
|
|
75
|
+
*
|
|
76
|
+
* NOTE: all interpolated values are escaped — `title` is user/AI supplied and
|
|
77
|
+
* the compiled JSON is embedded via JSON.stringify (which is safe inside a
|
|
78
|
+
* <script> body as it cannot contain an unescaped `</script>` sequence for
|
|
79
|
+
* ordinary story text).
|
|
80
|
+
*/
|
|
81
|
+
async function exportStandaloneHTML(inkSource, title) {
|
|
82
|
+
const compiledJson = await compileInk(inkSource);
|
|
83
|
+
const escapedJson = JSON.stringify(compiledJson);
|
|
84
|
+
const safeTitle = escapeHtml(title);
|
|
85
|
+
return `<!DOCTYPE html>
|
|
86
|
+
<html lang="en">
|
|
87
|
+
<head>
|
|
88
|
+
<meta charset="UTF-8">
|
|
89
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
90
|
+
<title>${safeTitle} — Playable Lessons</title>
|
|
91
|
+
<style>
|
|
92
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
93
|
+
body {
|
|
94
|
+
font-family: Georgia, 'Times New Roman', serif;
|
|
95
|
+
background: #1a1a2e;
|
|
96
|
+
color: #e0e0e0;
|
|
97
|
+
min-height: 100vh;
|
|
98
|
+
display: flex;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
padding: 40px 20px;
|
|
101
|
+
}
|
|
102
|
+
#story { max-width: 650px; width: 100%; }
|
|
103
|
+
h1 { font-size: 28px; margin-bottom: 32px; color: #a78bfa; text-align: center; }
|
|
104
|
+
.text p { font-size: 18px; line-height: 1.8; margin-bottom: 16px; }
|
|
105
|
+
.choices { display: flex; flex-direction: column; gap: 10px; margin-top: 24px; }
|
|
106
|
+
.choice {
|
|
107
|
+
padding: 14px 20px;
|
|
108
|
+
background: #252547;
|
|
109
|
+
border: 1px solid #3a3a5c;
|
|
110
|
+
border-radius: 8px;
|
|
111
|
+
color: #e0e0e0;
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
font-size: 16px;
|
|
114
|
+
font-family: inherit;
|
|
115
|
+
text-align: left;
|
|
116
|
+
transition: all 0.15s;
|
|
117
|
+
}
|
|
118
|
+
.choice:hover { border-color: #a78bfa; background: rgba(167, 139, 250, 0.1); }
|
|
119
|
+
.ending { text-align: center; padding: 32px; color: #888; font-style: italic; font-size: 18px; }
|
|
120
|
+
.restart {
|
|
121
|
+
display: block;
|
|
122
|
+
margin: 24px auto;
|
|
123
|
+
padding: 12px 28px;
|
|
124
|
+
background: #a78bfa;
|
|
125
|
+
color: white;
|
|
126
|
+
border: none;
|
|
127
|
+
border-radius: 8px;
|
|
128
|
+
font-size: 16px;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
font-family: inherit;
|
|
131
|
+
}
|
|
132
|
+
.restart:hover { background: #8b6fdf; }
|
|
133
|
+
.meta { text-align: center; color: #555; font-size: 12px; margin-top: 48px; }
|
|
134
|
+
</style>
|
|
135
|
+
</head>
|
|
136
|
+
<body>
|
|
137
|
+
<div id="story">
|
|
138
|
+
<h1>${safeTitle}</h1>
|
|
139
|
+
<div id="content"></div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<script src="${INKJS_RUNTIME_URL}" integrity="${INKJS_RUNTIME_SRI}" crossorigin="anonymous"></script>
|
|
143
|
+
<script>
|
|
144
|
+
(function() {
|
|
145
|
+
var storyJson = ${escapedJson};
|
|
146
|
+
var story = new inkjs.Story(storyJson);
|
|
147
|
+
var content = document.getElementById('content');
|
|
148
|
+
|
|
149
|
+
function continueStory() {
|
|
150
|
+
var textDiv = document.createElement('div');
|
|
151
|
+
textDiv.className = 'text';
|
|
152
|
+
while (story.canContinue) {
|
|
153
|
+
var line = story.Continue().trim();
|
|
154
|
+
if (line) {
|
|
155
|
+
var p = document.createElement('p');
|
|
156
|
+
p.textContent = line;
|
|
157
|
+
textDiv.appendChild(p);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
content.appendChild(textDiv);
|
|
161
|
+
|
|
162
|
+
if (story.currentChoices.length > 0) {
|
|
163
|
+
var choicesDiv = document.createElement('div');
|
|
164
|
+
choicesDiv.className = 'choices';
|
|
165
|
+
story.currentChoices.forEach(function(choice, i) {
|
|
166
|
+
var btn = document.createElement('button');
|
|
167
|
+
btn.className = 'choice';
|
|
168
|
+
btn.textContent = choice.text;
|
|
169
|
+
btn.addEventListener('click', function() {
|
|
170
|
+
choicesDiv.remove();
|
|
171
|
+
story.ChooseChoiceIndex(i);
|
|
172
|
+
continueStory();
|
|
173
|
+
});
|
|
174
|
+
choicesDiv.appendChild(btn);
|
|
175
|
+
});
|
|
176
|
+
content.appendChild(choicesDiv);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!story.canContinue && story.currentChoices.length === 0) {
|
|
180
|
+
var endDiv = document.createElement('div');
|
|
181
|
+
endDiv.className = 'ending';
|
|
182
|
+
endDiv.textContent = '\\u2014 End of Story \\u2014';
|
|
183
|
+
content.appendChild(endDiv);
|
|
184
|
+
var restartBtn = document.createElement('button');
|
|
185
|
+
restartBtn.className = 'restart';
|
|
186
|
+
restartBtn.textContent = 'Play Again';
|
|
187
|
+
restartBtn.addEventListener('click', function() { location.reload(); });
|
|
188
|
+
content.appendChild(restartBtn);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
continueStory();
|
|
195
|
+
|
|
196
|
+
var meta = document.createElement('div');
|
|
197
|
+
meta.className = 'meta';
|
|
198
|
+
meta.textContent = 'Created with Playable Lessons';
|
|
199
|
+
document.getElementById('story').appendChild(meta);
|
|
200
|
+
})();
|
|
201
|
+
</script>
|
|
202
|
+
</body>
|
|
203
|
+
</html>`;
|
|
204
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "playable-lessons",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive Fiction Authoring Tool for Education — Generate playable branching stories from source material using AI + Ink",
|
|
5
|
+
"main": "./out/main/index.mjs",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "electron-vite dev",
|
|
8
|
+
"build": "electron-vite build",
|
|
9
|
+
"preview": "electron-vite preview",
|
|
10
|
+
"start": "electron-vite preview",
|
|
11
|
+
"postinstall": "electron-builder install-app-deps || true",
|
|
12
|
+
"build:unpack": "npm run build && electron-builder --dir",
|
|
13
|
+
"build:win": "npm run build && electron-builder --win",
|
|
14
|
+
"build:mac": "npm run build && electron-builder --mac",
|
|
15
|
+
"build:linux": "npm run build && electron-builder --linux",
|
|
16
|
+
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
|
17
|
+
"typecheck:web": "tsc --noEmit -p tsconfig.web.json",
|
|
18
|
+
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
|
19
|
+
"lint": "eslint . --ext .ts,.tsx",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"build:cli": "tsc -p tsconfig.cli.json",
|
|
23
|
+
"prepublishOnly": "npm run build:cli",
|
|
24
|
+
"cli": "npx tsx src/cli/index.ts"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"playable-lessons": "./out/cli/index.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"out/cli/",
|
|
31
|
+
"out/shared/",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"keywords": [
|
|
36
|
+
"interactive-fiction",
|
|
37
|
+
"education",
|
|
38
|
+
"ink",
|
|
39
|
+
"electron",
|
|
40
|
+
"storytelling",
|
|
41
|
+
"narrative",
|
|
42
|
+
"authoring",
|
|
43
|
+
"cli"
|
|
44
|
+
],
|
|
45
|
+
"author": "michael-borck",
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/michael-borck/playable-lessons.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/michael-borck/playable-lessons",
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@xyflow/react": "^12.10.2",
|
|
54
|
+
"inkjs": "^2.4.0",
|
|
55
|
+
"jszip": "^3.10.1",
|
|
56
|
+
"marked": "^17.0.5",
|
|
57
|
+
"openai": "^6.33.0",
|
|
58
|
+
"react": "^19.2.4",
|
|
59
|
+
"react-dom": "^19.2.4",
|
|
60
|
+
"yargs": "^18.0.0",
|
|
61
|
+
"zustand": "^5.0.12"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/node": "^25.5.0",
|
|
65
|
+
"@types/react": "^19.2.14",
|
|
66
|
+
"@types/react-dom": "^19.2.3",
|
|
67
|
+
"@types/yargs": "^17.0.35",
|
|
68
|
+
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
|
69
|
+
"@typescript-eslint/parser": "^8.57.2",
|
|
70
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
71
|
+
"electron": "^41.1.0",
|
|
72
|
+
"electron-builder": "^26.8.1",
|
|
73
|
+
"electron-vite": "^5.0.0",
|
|
74
|
+
"eslint": "^10.1.0",
|
|
75
|
+
"tsx": "^4.21.0",
|
|
76
|
+
"typescript": "^6.0.2",
|
|
77
|
+
"vite": "^8.0.3",
|
|
78
|
+
"vitest": "^4.1.8"
|
|
79
|
+
}
|
|
80
|
+
}
|