playable-lessons 0.2.2 → 0.6.3

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.
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ /**
3
+ * Parse Ink source into structured data for the node editor and other consumers.
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseInkSource = parseInkSource;
7
+ exports.updateKnotContent = updateKnotContent;
8
+ exports.addKnot = addKnot;
9
+ exports.removeKnot = removeKnot;
10
+ function parseInkSource(source) {
11
+ const lines = source.split('\n');
12
+ const variables = [];
13
+ let topLevelDivert = null;
14
+ const knots = [];
15
+ // First pass: find variables and top-level divert
16
+ for (let i = 0; i < lines.length; i++) {
17
+ const line = lines[i].trim();
18
+ // Variable declarations
19
+ const varMatch = line.match(/^VAR\s+(\w+)\s*=\s*(.+)$/);
20
+ if (varMatch) {
21
+ const [, name, rawValue] = varMatch;
22
+ const value = rawValue.trim();
23
+ let type = 'string';
24
+ let initialValue = value;
25
+ if (value === 'true' || value === 'false') {
26
+ type = 'boolean';
27
+ initialValue = value === 'true';
28
+ }
29
+ else if (/^-?\d+(\.\d+)?$/.test(value)) {
30
+ type = 'number';
31
+ initialValue = parseFloat(value);
32
+ }
33
+ else if (value.startsWith('"') && value.endsWith('"')) {
34
+ type = 'string';
35
+ initialValue = value.slice(1, -1);
36
+ }
37
+ variables.push({ name, type, initialValue, line: i });
38
+ continue;
39
+ }
40
+ // Top-level divert (before first knot)
41
+ const divertMatch = line.match(/^->\s*(\w+)\s*$/);
42
+ if (divertMatch && !knots.length) {
43
+ topLevelDivert = divertMatch[1];
44
+ continue;
45
+ }
46
+ // Knot declaration
47
+ const knotMatch = line.match(/^===\s*(\w+)\s*===?\s*$/);
48
+ if (knotMatch) {
49
+ knots.push({
50
+ id: knotMatch[1],
51
+ title: knotMatch[1].replace(/_/g, ' '),
52
+ content: '',
53
+ rawContent: '',
54
+ startLine: i,
55
+ endLine: lines.length - 1, // will be adjusted
56
+ choices: [],
57
+ tags: [],
58
+ timerSeconds: 0,
59
+ imagePath: null,
60
+ imagePrompt: null,
61
+ endingType: null,
62
+ variableAssignments: []
63
+ });
64
+ }
65
+ }
66
+ // Second pass: parse content within each knot
67
+ for (let k = 0; k < knots.length; k++) {
68
+ const knot = knots[k];
69
+ const nextKnotStart = k + 1 < knots.length ? knots[k + 1].startLine : lines.length;
70
+ knot.endLine = nextKnotStart - 1;
71
+ const knotLines = lines.slice(knot.startLine + 1, nextKnotStart);
72
+ knot.rawContent = knotLines.join('\n');
73
+ const contentLines = [];
74
+ for (const line of knotLines) {
75
+ const trimmed = line.trim();
76
+ // Tags
77
+ if (trimmed.startsWith('#')) {
78
+ const tagContent = trimmed.substring(1).trim();
79
+ knot.tags.push(tagContent);
80
+ // Special tags
81
+ const timerMatch = tagContent.match(/^TIMER:\s*(\d+)/);
82
+ if (timerMatch) {
83
+ knot.timerSeconds = parseInt(timerMatch[1]);
84
+ }
85
+ const imageMatch = tagContent.match(/^IMAGE:\s*(.+)/);
86
+ if (imageMatch) {
87
+ knot.imagePath = imageMatch[1].trim();
88
+ }
89
+ const imagePromptMatch = tagContent.match(/^IMAGE_PROMPT:\s*(.+)/);
90
+ if (imagePromptMatch) {
91
+ knot.imagePrompt = imagePromptMatch[1].trim();
92
+ }
93
+ const endingMatch = tagContent.match(/^ENDING:\s*(.+)/);
94
+ if (endingMatch) {
95
+ knot.endingType = endingMatch[1].trim();
96
+ }
97
+ continue;
98
+ }
99
+ // Choices
100
+ const choiceMatch = trimmed.match(/^([*+])\s*(?:\{([^}]+)\}\s*)?\[([^\]]*)\]/);
101
+ if (choiceMatch) {
102
+ const isSticky = choiceMatch[1] === '+';
103
+ const condition = choiceMatch[2] || null;
104
+ const text = choiceMatch[3];
105
+ // Find divert target in the choice block
106
+ let target = '';
107
+ const divertInLine = trimmed.match(/->\s*(\w+)/);
108
+ if (divertInLine) {
109
+ target = divertInLine[1];
110
+ }
111
+ else {
112
+ // Check subsequent lines for the divert
113
+ const lineIdx = knotLines.indexOf(line);
114
+ for (let j = lineIdx + 1; j < knotLines.length; j++) {
115
+ const nextLine = knotLines[j].trim();
116
+ if (nextLine.startsWith('*') || nextLine.startsWith('+') || nextLine.startsWith('==='))
117
+ break;
118
+ const d = nextLine.match(/^->\s*(\w+)/);
119
+ if (d) {
120
+ target = d[1];
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ knot.choices.push({ text, target, condition, isSticky });
126
+ continue;
127
+ }
128
+ // Variable assignments
129
+ const assignMatch = trimmed.match(/^~\s*(\w+)\s*=\s*(.+)$/);
130
+ if (assignMatch) {
131
+ knot.variableAssignments.push({
132
+ variable: assignMatch[1],
133
+ expression: assignMatch[2].trim()
134
+ });
135
+ continue;
136
+ }
137
+ // Regular content
138
+ if (trimmed && !trimmed.startsWith('->') && !trimmed.startsWith('=')) {
139
+ contentLines.push(trimmed);
140
+ }
141
+ }
142
+ knot.content = contentLines.join('\n');
143
+ }
144
+ return { knots, variables, topLevelDivert };
145
+ }
146
+ /**
147
+ * Update a single knot's text content in the Ink source.
148
+ */
149
+ function updateKnotContent(source, knotId, newRawContent) {
150
+ const lines = source.split('\n');
151
+ const parsed = parseInkSource(source);
152
+ const knot = parsed.knots.find((k) => k.id === knotId);
153
+ if (!knot)
154
+ return source;
155
+ const before = lines.slice(0, knot.startLine + 1);
156
+ const after = lines.slice(knot.endLine + 1);
157
+ return [...before, newRawContent, ...after].join('\n');
158
+ }
159
+ /**
160
+ * Add a new knot to the Ink source.
161
+ */
162
+ function addKnot(source, knotId, title) {
163
+ const displayTitle = title || knotId.replace(/_/g, ' ');
164
+ const newKnot = `\n=== ${knotId} ===\n${displayTitle}.\n* [Continue]\n -> END\n`;
165
+ return source.trimEnd() + '\n' + newKnot;
166
+ }
167
+ /**
168
+ * Remove a knot from the Ink source.
169
+ */
170
+ function removeKnot(source, knotId) {
171
+ const parsed = parseInkSource(source);
172
+ const knot = parsed.knots.find((k) => k.id === knotId);
173
+ if (!knot)
174
+ return source;
175
+ const lines = source.split('\n');
176
+ const before = lines.slice(0, knot.startLine);
177
+ const after = lines.slice(knot.endLine + 1);
178
+ // Also update the top-level divert if it pointed to this knot
179
+ let result = [...before, ...after].join('\n');
180
+ if (parsed.topLevelDivert === knotId && parsed.knots.length > 1) {
181
+ const nextKnot = parsed.knots.find((k) => k.id !== knotId);
182
+ if (nextKnot) {
183
+ result = result.replace(new RegExp(`^->\\s*${knotId}\\s*$`, 'm'), `-> ${nextKnot.id}`);
184
+ }
185
+ }
186
+ return result;
187
+ }
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ /**
3
+ * Recommender "plan" export formats, shared by the renderer and the CLI.
4
+ *
5
+ * Standalone (only the PlanResult *type* is imported — erased at compile time).
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.toPlainText = toPlainText;
9
+ exports.toStandaloneHTML = toStandaloneHTML;
10
+ const favicon_js_1 = require("./favicon.js");
11
+ const TARGET_LABEL = {
12
+ story: 'Story (interactive fiction)',
13
+ flashcards: 'Flashcards',
14
+ quiz: 'Quiz',
15
+ summary: 'Summary',
16
+ 'ai-task': 'AI-collaboration tasks',
17
+ 'case-study': 'Case study'
18
+ };
19
+ /** A printable plan: material summary + the recommended targets with rationale. */
20
+ function toPlainText(result, title) {
21
+ const heading = result.title || title || 'Recommended outputs';
22
+ const lines = [heading, ''];
23
+ if (result.summary) {
24
+ lines.push(result.summary, '');
25
+ }
26
+ lines.push('Recommended outputs:');
27
+ result.recommendations.forEach((r, i) => {
28
+ let line = `${i + 1}. ${TARGET_LABEL[r.target] ?? r.target}`;
29
+ if (r.depth)
30
+ line += ` (${r.depth})`;
31
+ if (r.count)
32
+ line += ` ×${r.count}`;
33
+ lines.push(line);
34
+ lines.push(` ${r.rationale}`);
35
+ });
36
+ return lines.join('\n') + '\n';
37
+ }
38
+ /**
39
+ * A self-contained, offline HTML plan: material summary + recommended outputs
40
+ * with rationale. Text via textContent; embedded JSON guarded by escaping '<'.
41
+ */
42
+ function toStandaloneHTML(result, title) {
43
+ const pageTitle = result.title || title;
44
+ const labels = TARGET_LABEL;
45
+ const dataJson = JSON.stringify({ ...result, labels }).replace(/</g, '\\u003c');
46
+ const safeTitle = String(pageTitle)
47
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
48
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
49
+ return `<!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="UTF-8">
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
54
+ ${favicon_js_1.FAVICON_LINK}
55
+ <title>${safeTitle} — Recommended Outputs</title>
56
+ <style>
57
+ * { margin: 0; padding: 0; box-sizing: border-box; }
58
+ body { font-family: Georgia, 'Times New Roman', serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; justify-content: center; padding: 40px 20px; line-height: 1.7; }
59
+ #sheet { max-width: 720px; width: 100%; }
60
+ h1 { font-size: 26px; color: #a78bfa; margin-bottom: 10px; }
61
+ .summary { font-size: 17px; color: #cfcfe6; margin-bottom: 24px; }
62
+ .rec { background: #252547; border: 1px solid #3a3a5c; border-radius: 12px; padding: 16px 20px; margin-bottom: 14px; }
63
+ .rec-head { font-size: 16px; color: #a78bfa; font-weight: 700; }
64
+ .rec-meta { font-size: 13px; color: #888; margin-left: 8px; font-weight: 400; }
65
+ .rec-why { font-size: 15px; color: #cfcfe6; margin-top: 6px; }
66
+ .meta { text-align: center; color: #555; font-size: 12px; margin-top: 32px; }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div id="sheet">
71
+ <h1 id="title"></h1>
72
+ <p id="summary" class="summary"></p>
73
+ <div id="recs"></div>
74
+ </div>
75
+ <script>
76
+ (function () {
77
+ var p = ${dataJson};
78
+ document.getElementById('title').textContent = p.title || 'Recommended outputs';
79
+ if (p.summary) document.getElementById('summary').textContent = p.summary;
80
+ var host = document.getElementById('recs');
81
+ var labels = p.labels || {};
82
+ (p.recommendations || []).forEach(function (r, i) {
83
+ var card = document.createElement('div');
84
+ card.className = 'rec';
85
+ var head = document.createElement('div');
86
+ head.className = 'rec-head';
87
+ head.textContent = (i + 1) + '. ' + (labels[r.target] || r.target);
88
+ var meta = [];
89
+ if (r.depth) meta.push(r.depth);
90
+ if (r.count) meta.push('×' + r.count);
91
+ if (meta.length) {
92
+ var m = document.createElement('span');
93
+ m.className = 'rec-meta';
94
+ m.textContent = '(' + meta.join(', ') + ')';
95
+ head.appendChild(m);
96
+ }
97
+ card.appendChild(head);
98
+ var why = document.createElement('div');
99
+ why.className = 'rec-why';
100
+ why.textContent = r.rationale;
101
+ card.appendChild(why);
102
+ host.appendChild(card);
103
+ });
104
+ var meta = document.createElement('div');
105
+ meta.className = 'meta';
106
+ meta.textContent = 'Created with Playable Lessons';
107
+ document.getElementById('sheet').appendChild(meta);
108
+ })();
109
+ </script>
110
+ </body>
111
+ </html>`;
112
+ }
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.projectIdFromName = projectIdFromName;
4
+ /** A filesystem-safe, unique id derived from a name (+ short suffix). */
5
+ function projectIdFromName(name) {
6
+ const slug = name
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, '-')
9
+ .replace(/^-+|-+$/g, '')
10
+ .slice(0, 50) || 'project';
11
+ return `${slug}-${Date.now().toString(36)}`;
12
+ }