rockstar-strudel 1.0.3 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PUBLISH.md ADDED
@@ -0,0 +1,102 @@
1
+ # Publishing rockstar-strudel to npm
2
+
3
+ This package is ready to publish from the repo root.
4
+
5
+ ## 1. Log in to npm
6
+
7
+ ```bash
8
+ npm login
9
+ ```
10
+
11
+ If you already have a token-based setup, make sure you are logged in as the correct account:
12
+
13
+ ```bash
14
+ npm whoami
15
+ ```
16
+
17
+ ---
18
+
19
+ ## 2. Run the tests
20
+
21
+ From the project root:
22
+
23
+ ```bash
24
+ npm test
25
+ ```
26
+
27
+ You already verified the suite is passing.
28
+
29
+ ---
30
+
31
+ ## 3. Bump the version
32
+
33
+ Current version is the one in [package.json](package.json).
34
+
35
+ For a patch release:
36
+
37
+ ```bash
38
+ npm version patch
39
+ ```
40
+
41
+ For a minor release:
42
+
43
+ ```bash
44
+ npm version minor
45
+ ```
46
+
47
+ For a major release:
48
+
49
+ ```bash
50
+ npm version major
51
+ ```
52
+
53
+ This updates the version and creates a git tag.
54
+
55
+ ---
56
+
57
+ ## 4. Preview what will be published
58
+
59
+ ```bash
60
+ npm pack --dry-run
61
+ ```
62
+
63
+ Check that only the expected files are included.
64
+
65
+ ---
66
+
67
+ ## 5. Publish to npm
68
+
69
+ ```bash
70
+ npm publish
71
+ ```
72
+
73
+ If you ever need public access explicitly:
74
+
75
+ ```bash
76
+ npm publish --access public
77
+ ```
78
+
79
+ ---
80
+
81
+ ## 6. Push git commits and tags
82
+
83
+ ```bash
84
+ git push && git push --tags
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Handy one-release flow
90
+
91
+ ```bash
92
+ npm test && npm version patch && npm publish && git push && git push --tags
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Notes
98
+
99
+ - Package name: `rockstar-strudel`
100
+ - Entry point: [src/index.js](src/index.js)
101
+ - npm metadata is defined in [package.json](package.json)
102
+ - If `npm publish` fails because the version already exists, bump the version and try again.
package/README.md CHANGED
@@ -4,29 +4,19 @@ Run [Rockstar](https://codewithrockstar.com) programs from the
4
4
  [strudel.cc](https://strudel.cc) live-coding REPL (or any browser-based JS
5
5
  environment) via a simple template-tag function.
6
6
 
7
- ```js
8
- const data = await rockstar`
9
- My heart is 123
10
- Let your love be 456
11
- Put 789 into the night
12
- Shout my heart. Scream your love. Whisper the night.
13
- `
14
- // data === [123, 456, 789]
15
- ```
16
-
17
7
  Every value printed by `Say` / `Shout` / `Scream` / `Whisper` becomes one
18
- element of the returned array. Values that parse as finite numbers are
19
- returned as JS `number`; everything else is returned as a `string`.
8
+ element of the returned array. The default `rockstar` view is now
9
+ numeric-first, so printed text is converted using Rockstar's poetic numeric
10
+ literal rules when needed.
11
+
12
+ For richer workflows (lyrics/text + numeric pipelines), use `rockstar_pro`.
20
13
 
21
14
  ---
22
15
 
23
16
  ## Using it in strudel.cc
24
17
 
25
- This package is ESM-only. In `strudel.cc`, use dynamic `import()` rather than
26
- static `import ... from`, since the editor input is not a module file.
27
-
28
18
  ```js
29
- const { init, rockstar } = await import('https://esm.sh/rockstar-strudel')
19
+ const { init, rockstar, rockstar_pro } = await import('https://esm.sh/rockstar-strudel')
30
20
 
31
21
  // Pre-warm the WASM engine while other code loads (optional but recommended)
32
22
  await init()
@@ -34,36 +24,58 @@ await init()
34
24
  // Run a Rockstar program
35
25
  const notes = await rockstar`
36
26
  Tommy was 60
27
+ Say Tommy
37
28
  Build Tommy up, up, up, up
38
- Shout Tommy
29
+ Say Tommy
39
30
  Build Tommy up
40
31
  Shout Tommy
41
32
  Build Tommy up, up
42
- Shout Tommy
33
+ Scream Tommy
43
34
  `
44
- // notes === [64, 65, 67]
35
+ //notes === [60, 64, 65, 67]
45
36
 
46
- note(notes).sound("piano").slow(2)
47
- ```
37
+ note(seq(notes)).sound("piano")
38
+
39
+ // Rich result with parallel views
40
+ const pro = await rockstar_pro`
41
+ Say hello world
42
+ Shout [ "012", ["my dreams", "007"] ]
43
+ `
44
+
45
+ // Default numeric-first values for number-based Strudel functions
46
+ // pro.output === [55, [12, [26, 7]]]
48
47
 
49
- Standalone browser example:
48
+ // Mixed typed values with words preserved where possible
49
+ // pro.mixed_output === ["hello world", [12, ["my dreams", 7]]]
50
50
 
51
- ```html
52
- <script type="module">
53
- import { init, rockstar } from 'https://esm.sh/rockstar-strudel'
51
+ // Fully stringified values for speech/text workflows
52
+ // pro.text_output === ["hello world", ["12", ["my dreams", "7"]]]
54
53
 
55
- await init()
54
+ // Sanitized source tokens for Shaba/Shabda speech sample names
55
+ // pro.speech === ["say_hello_world", "shout__012_my_dreams_007_"]
56
+ // Use in Strudel as:
57
+ // samples('shabda/speech:'+pro.speech.join(','))
56
58
 
57
- const notes = await rockstar`
58
- Tommy was 60
59
- Build Tommy up, up, up, up
60
- Shout Tommy
61
- `
59
+ // Raw callback lines from WASM
60
+ // pro.raw_output keeps trailing newlines exactly as emitted
62
61
 
63
- console.log(notes)
64
- </script>
62
+ // Exact source text executed (after template interpolation)
63
+ // pro.sourceText is available for lyric reuse
64
+
65
+ const root = 60
66
+ const melody = await rockstar_pro`
67
+ Tommy was ${root}
68
+ Build Tommy up, up, up, up
69
+ Shout Tommy
70
+ `
71
+
72
+ // Run the same template again with new interpolation values
73
+ const shifted = await melody.rerun(62)
74
+ // or derive from the previous interpolation array
75
+ const shiftedAgain = await shifted.rerun(([prevRoot]) => [prevRoot + 2])
65
76
  ```
66
77
 
78
+
67
79
  ### Template interpolations
68
80
 
69
81
  JavaScript values can be spliced into the source, letting you parameterise
@@ -83,10 +95,31 @@ const data = await rockstar`
83
95
 
84
96
  ## API
85
97
 
86
- ### `rockstar(strings, ...values)` → `Promise<Array<number|string>>`
98
+ ### `rockstar(strings, ...values)` → `Promise<Array<number|Array>>`
99
+
100
+ Tagged-template function. Runs the Rockstar source code and resolves with the
101
+ numeric-first output view, ready for number-based Strudel functions.
102
+
103
+ ### `rockstar_pro(strings, ...values)` → `Promise<object>`
104
+
105
+ Tagged-template function with richer parallel output views:
106
+
107
+ - `sourceText`: exact Rockstar code that was executed.
108
+ - `raw_output`: raw callback lines from WASM (verbatim, including trailing newlines).
109
+ - `output`: numeric-first values (`number` or nested numeric arrays).
110
+ - `mixed_output`: mixed typed values with words preserved.
111
+ - `text_output`: fully stringified values for text/speech use.
112
+ - `speech`: sanitized line tokens derived from source code for Shabda speech
113
+ sample lookup (for example `samples('shabda/speech:'+prog.speech.join(','))`).
114
+ - `templateValues`: the interpolation values used for this run.
115
+ - `rerun(...values)`: run the same template again, replacing interpolation
116
+ values positionally. Calling `rerun()` with no arguments repeats the same run.
87
117
 
88
- Tagged-template function. Runs the Rockstar source code and resolves with an
89
- array of every printed value.
118
+ `output[i]`, `mixed_output[i]`, and `text_output[i]` always refer to the same
119
+ emitted line.
120
+
121
+ For JSON-style list output (for example `[ "012" ]`), all views parse the same
122
+ structure, and `output` resolves that case to `[12]`.
90
123
 
91
124
  ### `init([dotnetUrl])` → `Promise<void>`
92
125
 
@@ -103,12 +136,24 @@ call. Exported for testing.
103
136
  Pure helper that converts a raw WASM callback line to a typed JS value.
104
137
  Exported for testing.
105
138
 
139
+ ### `parsePoeticNumber(text)` → `number | undefined`
140
+
141
+ Converts text using the Rockstar poetic numeric literal algorithm:
142
+ each word contributes one digit using its length modulo 10, hyphens count as
143
+ letters, apostrophes do not, statement-ending punctuation stops parsing, and
144
+ an ellipsis (`...` or `…`) introduces the decimal separator.
145
+
146
+ ### `parseOutputLine(line)` → `object | undefined`
147
+
148
+ Parses one raw callback line into a dual-view structure used by
149
+ `rockstar_pro` (`raw`, `output`, `poetic`). Exported for testing.
150
+
106
151
  ---
107
152
 
108
153
  ## How it works
109
154
 
110
155
  The Rockstar **Starship** engine is a .NET 9 application compiled to
111
- WebAssembly. The built WASM is hosted at
156
+ WebAssembly. The built WASM is hosted at `https://stretchyboy.github.io/rockstar/wasm` instead of
112
157
  `https://codewithrockstar.com/wasm/`. This package dynamically imports
113
158
  `dotnet.js` from that URL, initialises the runtime, and calls
114
159
  `RockstarRunner.Run(source, outputCallback, stdin, args)`, which is
@@ -119,6 +164,14 @@ the callback with the printed string (plus a trailing newline added by
119
164
  `WasmIO.WriteLine` in C#). The tag strips whitespace and coerces numeric
120
165
  strings to `number`.
121
166
 
167
+ `rockstar_pro` adds a parsing layer on top of that callback stream:
168
+
169
+ - JSON-style lists are parsed when possible.
170
+ - List members are converted recursively.
171
+ - `output` is numeric-ready for sequence/math pipelines.
172
+ - `mixed_output` preserves words while keeping numbers typed.
173
+ - `text_output` keeps everything stringified for speech/lyrics workflows.
174
+
122
175
  ---
123
176
 
124
177
  ## CORS requirement
@@ -130,7 +183,6 @@ Access-Control-Allow-Origin: *
130
183
  ```
131
184
 
132
185
  Without this, browsers will block the cross-origin `import()` of `dotnet.js`.
133
- See [PLAN.md](PLAN.md) for the exact steps needed in the rockstar fork.
134
186
 
135
187
  ---
136
188
 
@@ -139,6 +191,3 @@ See [PLAN.md](PLAN.md) for the exact steps needed in the rockstar fork.
139
191
  ```bash
140
192
  npm test # run the 19 unit tests (pure JS logic, no WASM required)
141
193
  ```
142
-
143
- Integration testing (actual Rockstar program execution) requires a browser
144
- environment where the WASM can load; see [PLAN.md](PLAN.md) for details.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rockstar-strudel",
3
- "version": "1.0.3",
3
+ "version": "1.0.7",
4
4
  "description": "Run Rockstar lang programs via the Starship WASM engine, returning output as a JS array. Designed for use in the strudel.cc live-coding REPL.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -19,13 +19,13 @@
19
19
  *
20
20
  * CORS requirement
21
21
  * ────────────────
22
- * The WASM engine is loaded from https://codewithrockstar.com/wasm/.
22
+ * The WASM engine is loaded from https://stretchyboy.github.io/rockstar/ as https://codewithrockstar.com/wasm/ has broken CORS.
23
23
  * That origin must serve its WASM assets with
24
24
  * Access-Control-Allow-Origin: *
25
25
  * See PLAN.md for instructions on enabling this in a rockstar fork.
26
26
  */
27
27
 
28
- /** Default URL of the .NET WASM loader published by codewithrockstar.com. */
28
+ /** Default URL of the .NET WASM loader published by stretchyboy.github.io. */
29
29
  const DEFAULT_DOTNET_URL =
30
30
  'https://stretchyboy.github.io/rockstar/wasm/wwwroot/_framework/dotnet.js';
31
31
 
@@ -38,6 +38,7 @@ const DEFAULT_DOTNET_URL =
38
38
  */
39
39
  export const ALLOWED_URL_PREFIXES = [
40
40
  'https://codewithrockstar.com/',
41
+ 'https://stretchyboy.github.io/rockstar/',
41
42
  'https://cdn.jsdelivr.net/',
42
43
  'https://unpkg.com/',
43
44
  'http://localhost:',
@@ -170,6 +171,249 @@ export function coerce(line) {
170
171
  return Number.isFinite(num) ? num : trimmed;
171
172
  }
172
173
 
174
+ /**
175
+ * Convert text to a Rockstar-style poetic numeric literal.
176
+ *
177
+ * Rockstar counts each word's length modulo 10. Hyphens count as letters,
178
+ * apostrophes do not. Parsing stops at the end of the current statement
179
+ * (`.`, `!`, `?`, `;`, or a newline). An ellipsis (`...` or `…`) acts as the
180
+ * decimal separator.
181
+ *
182
+ * Examples:
183
+ * - "a panther, he ain't talkin' 'bout love." -> 1724644
184
+ * - "ice... a life unfulfilled" -> 3.141
185
+ *
186
+ * @param {string} text
187
+ * @returns {number | undefined}
188
+ */
189
+ export function parsePoeticNumber(text) {
190
+ const source = String(text).replace(/…/g, '...');
191
+ const intDigits = [];
192
+ const fracDigits = [];
193
+ let currentWord = '';
194
+ let sawDecimal = false;
195
+
196
+ const pushWord = () => {
197
+ if (!currentWord) return;
198
+
199
+ const normalized = currentWord.replace(/'/g, '');
200
+ currentWord = '';
201
+ if (!normalized) return;
202
+
203
+ const digit = normalized.length % 10;
204
+ if (sawDecimal) {
205
+ fracDigits.push(String(digit));
206
+ } else {
207
+ intDigits.push(String(digit));
208
+ }
209
+ };
210
+
211
+ for (let i = 0; i < source.length; i += 1) {
212
+ const char = source[i];
213
+
214
+ if (source.slice(i, i + 3) === '...') {
215
+ pushWord();
216
+ sawDecimal = true;
217
+ i += 2;
218
+ continue;
219
+ }
220
+
221
+ if (char === '\n' || char === '.' || char === '!' || char === '?' || char === ';') {
222
+ pushWord();
223
+ break;
224
+ }
225
+
226
+ if (/[\p{L}'-]/u.test(char)) {
227
+ currentWord += char;
228
+ continue;
229
+ }
230
+
231
+ pushWord();
232
+ }
233
+
234
+ pushWord();
235
+
236
+ if (intDigits.length === 0 && fracDigits.length === 0) return undefined;
237
+
238
+ const intPart = intDigits.length > 0 ? intDigits.join('') : '0';
239
+ const value =
240
+ fracDigits.length > 0
241
+ ? Number(`${intPart}.${fracDigits.join('')}`)
242
+ : Number(intPart);
243
+
244
+ return Number.isFinite(value) ? value : undefined;
245
+ }
246
+
247
+ /**
248
+ * Parse a single output line into the parallel views used by `rockstar_pro`.
249
+ *
250
+ * - `output` is numeric-first for Strudel number pipelines.
251
+ * - `mixed_output` preserves words while keeping numeric values typed.
252
+ * - `text_output` keeps the same shape but stringifies all values.
253
+ *
254
+ * @param {string} line
255
+ * @returns {{
256
+ * raw: string,
257
+ * output: number|Array<number|Array<unknown>>,
258
+ * mixed_output: number|string|Array<unknown>,
259
+ * text_output: string|Array<unknown>
260
+ * } | undefined}
261
+ */
262
+ export function parseOutputLine(line) {
263
+ const raw = line;
264
+ const trimmed = line.trimEnd();
265
+ if (trimmed === '') return undefined;
266
+
267
+ const parsedArray = _parseJsonArray(trimmed);
268
+ if (parsedArray !== undefined) {
269
+ const mixed_output = _toMixedArray(parsedArray);
270
+ const text_output = _toTextArray(mixed_output);
271
+ const output = _toPoeticArray(parsedArray);
272
+ return { raw, output, mixed_output, text_output };
273
+ }
274
+
275
+ const mixed_output = coerce(trimmed);
276
+ const text_output = _toTextValue(mixed_output);
277
+ const output = _toPoeticNumber(mixed_output);
278
+ return { raw, output, mixed_output, text_output };
279
+ }
280
+
281
+ /**
282
+ * Attempt to parse a JSON-style array string, otherwise return undefined.
283
+ * @param {string} text
284
+ * @returns {Array<unknown> | undefined}
285
+ */
286
+ function _parseJsonArray(text) {
287
+ const startsLikeArray = text.startsWith('[') && text.endsWith(']');
288
+ if (!startsLikeArray) return undefined;
289
+
290
+ try {
291
+ const parsed = JSON.parse(text);
292
+ return Array.isArray(parsed) ? parsed : undefined;
293
+ } catch {
294
+ return undefined;
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Convert any parsed array value into mixed output values.
300
+ * Numeric-looking strings become numbers; other strings stay as text.
301
+ *
302
+ * @param {Array<unknown>} value
303
+ * @returns {Array<unknown>}
304
+ */
305
+ function _toMixedArray(value) {
306
+ return value.map((item) => {
307
+ if (Array.isArray(item)) return _toMixedArray(item);
308
+ if (typeof item === 'string') {
309
+ const numeric = _parseNumberish(item);
310
+ return numeric !== undefined ? numeric : item;
311
+ }
312
+ return item;
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Convert a value tree to text output values.
318
+ * Numbers and other scalars are stringified while preserving array shape.
319
+ *
320
+ * @param {unknown} value
321
+ * @returns {string|Array<unknown>}
322
+ */
323
+ function _toTextValue(value) {
324
+ if (Array.isArray(value)) return _toTextArray(value);
325
+ if (typeof value === 'string') return value;
326
+ return String(value);
327
+ }
328
+
329
+ /**
330
+ * Convert any parsed array value into text output values.
331
+ *
332
+ * @param {Array<unknown>} value
333
+ * @returns {Array<unknown>}
334
+ */
335
+ function _toTextArray(value) {
336
+ return value.map((item) => _toTextValue(item));
337
+ }
338
+
339
+ /**
340
+ * Convert any parsed array value into numeric-first output values.
341
+ * Output is strictly numbers or nested arrays of numbers.
342
+ *
343
+ * @param {Array<unknown>} value
344
+ * @returns {Array<number|Array<unknown>>}
345
+ */
346
+ function _toPoeticArray(value) {
347
+ return value.map((item) => {
348
+ if (Array.isArray(item)) return _toPoeticArray(item);
349
+ return _toPoeticNumber(item);
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Convert a single scalar value to a number using numeric parse first,
355
+ * then Rockstar poetic numeric literal parsing.
356
+ *
357
+ * @param {unknown} value
358
+ * @returns {number}
359
+ */
360
+ function _toPoeticNumber(value) {
361
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
362
+ if (typeof value === 'boolean') return value ? 1 : 0;
363
+ if (value === null) return 0;
364
+
365
+ if (typeof value === 'string') {
366
+ const numeric = _parseNumberish(value);
367
+ if (numeric !== undefined) return numeric;
368
+
369
+ const poetic = parsePoeticNumber(value);
370
+ return poetic !== undefined ? poetic : 0;
371
+ }
372
+
373
+ return 0;
374
+ }
375
+
376
+ /**
377
+ * Parse text as a finite JS number.
378
+ * @param {string} text
379
+ * @returns {number | undefined}
380
+ */
381
+ function _parseNumberish(text) {
382
+ const num = Number(String(text));
383
+ return Number.isFinite(num) ? num : undefined;
384
+ }
385
+
386
+ /**
387
+ * Resolve the interpolation values for a rerun of `rockstar_pro`.
388
+ *
389
+ * - `rerun()` repeats the previous interpolation set.
390
+ * - `rerun(v1, v2, ...)` replaces values positionally.
391
+ * - `rerun([v1, v2, ...])` replaces from an array.
392
+ * - `rerun(fn)` derives the next values from the previous array.
393
+ *
394
+ * @param {Array<unknown>} previousValues
395
+ * @param {...unknown} nextArgs
396
+ * @returns {Array<unknown>}
397
+ */
398
+ export function resolveRerunValues(previousValues, ...nextArgs) {
399
+ if (nextArgs.length === 0) return [...previousValues];
400
+
401
+ if (nextArgs.length === 1) {
402
+ const [arg] = nextArgs;
403
+
404
+ if (typeof arg === 'function') {
405
+ const result = arg([...previousValues]);
406
+ return Array.isArray(result) ? [...result] : [result];
407
+ }
408
+
409
+ if (Array.isArray(arg)) {
410
+ return [...arg];
411
+ }
412
+ }
413
+
414
+ return [...nextArgs];
415
+ }
416
+
173
417
  /**
174
418
  * Build the full Rockstar source string from a tagged-template call's parts.
175
419
  *
@@ -192,11 +436,12 @@ export function buildSource(strings, ...values) {
192
436
  }
193
437
 
194
438
  /**
195
- * Template tag that executes a Rockstar program and returns an array of every
196
- * value printed by the program (via `Say` / `Shout` / `Scream` / `Whisper`).
439
+ * Template tag that executes a Rockstar program and returns a numeric-first
440
+ * array of every printed value (via `Say` / `Shout` / `Scream` / `Whisper`).
197
441
  *
198
- * Values that parse as finite numbers are returned as JS `number`; everything
199
- * else is returned as a `string`.
442
+ * Plain numeric strings stay numeric, and other printed text is converted
443
+ * using Rockstar poetic numeric literal rules. JSON-style lists are converted
444
+ * recursively to nested numeric arrays.
200
445
  *
201
446
  * The WASM engine is loaded lazily on the first call and cached for subsequent
202
447
  * calls. You can call `init()` first to pre-warm the engine if desired.
@@ -212,22 +457,91 @@ export function buildSource(strings, ...values) {
212
457
  *
213
458
  * @param {TemplateStringsArray} strings
214
459
  * @param {...*} values
215
- * @returns {Promise<Array<number|string>>}
460
+ * @returns {Promise<Array<number|Array<unknown>>>}
216
461
  */
217
462
  export async function rockstar(strings, ...values) {
463
+ const result = await rockstar_pro(strings, ...values);
464
+ return result.output;
465
+ }
466
+
467
+ /**
468
+ * Template tag that executes Rockstar and returns richer parallel output views:
469
+ *
470
+ * - `output`: numeric-first values for Strudel sequence/math use.
471
+ * - `mixed_output`: mixed typed values with words preserved.
472
+ * - `text_output`: all values stringified for speech/text workflows.
473
+ * - `raw_output`: unmodified callback lines from WASM.
474
+ * - `sourceText`: exact source string that was executed.
475
+ *
476
+ * @param {TemplateStringsArray} strings
477
+ * @param {...*} values
478
+ * @returns {Promise<{
479
+ * sourceText: string,
480
+ * templateValues: Array<unknown>,
481
+ * raw_output: Array<string>,
482
+ * output: Array<number|Array<unknown>>,
483
+ * mixed_output: Array<number|string|Array<unknown>>,
484
+ * text_output: Array<string|Array<unknown>>,
485
+ * speech: Array<string>, // samples('shabda/speech:'+prog.speech.join(','))
486
+ * rerun: (...values: Array<unknown>) => Promise<object>,
487
+ * getVariables: () => never,
488
+ * callFunction: (name: string, ...args: Array<unknown>) => never,
489
+ * listFunctions: () => never
490
+ * }>}
491
+ */
492
+ export async function rockstar_pro(strings, ...values) {
218
493
  const code = buildSource(strings, ...values);
219
494
  const runner = await _runner();
220
- const outputs = [];
495
+ const raw_output = [];
496
+ const output = [];
497
+ const mixed_output = [];
498
+ const text_output = [];
499
+ const lines = code.split('\n')
500
+ const speech = lines.map((x) => x.toLowerCase()
501
+ .trim()
502
+ .replaceAll(' ', '_')
503
+ .replace(/\W/g, ''))
504
+ .filter((x)=> x.length);
505
+
506
+ console.log(`samples('shabda/speech:'+prog.speech.join(','))`)
221
507
 
222
508
  await runner.Run(
223
509
  code,
224
510
  (line) => {
225
- const value = coerce(line);
226
- if (value !== undefined) outputs.push(value);
511
+ raw_output.push(line);
512
+
513
+ const parsed = parseOutputLine(line);
514
+ if (!parsed) return;
515
+
516
+ output.push(parsed.output);
517
+ mixed_output.push(parsed.mixed_output);
518
+ text_output.push(parsed.text_output);
227
519
  },
228
520
  /* stdin */ '',
229
521
  /* args */ ''
230
522
  );
231
523
 
232
- return outputs;
524
+ const unsupported = (featureName) => {
525
+ throw new Error(
526
+ `${featureName} is not available in the current JS-only wrapper. ` +
527
+ `It requires new JSExport methods in the WASM RockstarRunner.`
528
+ );
529
+ };
530
+
531
+ return {
532
+ sourceText: code,
533
+ templateValues: [...values],
534
+ raw_output,
535
+ output,
536
+ mixed_output,
537
+ text_output,
538
+ speech,
539
+ rerun: (...nextArgs) => {
540
+ const nextValues = resolveRerunValues(values, ...nextArgs);
541
+ return rockstar_pro(strings, ...nextValues);
542
+ },
543
+ getVariables: () => unsupported('getVariables()'),
544
+ callFunction: () => unsupported('callFunction()'),
545
+ listFunctions: () => unsupported('listFunctions()'),
546
+ };
233
547
  }
@@ -9,7 +9,14 @@
9
9
 
10
10
  import { describe, it } from 'node:test';
11
11
  import assert from 'node:assert/strict';
12
- import { buildSource, coerce, isTrustedUrl } from '../src/index.js';
12
+ import {
13
+ buildSource,
14
+ coerce,
15
+ isTrustedUrl,
16
+ parsePoeticNumber,
17
+ parseOutputLine,
18
+ resolveRerunValues,
19
+ } from '../src/index.js';
13
20
 
14
21
  // ─── buildSource ────────────────────────────────────────────────────────────
15
22
 
@@ -157,3 +164,125 @@ describe('isTrustedUrl', () => {
157
164
  assert.ok(!isTrustedUrl('http://codewithrockstar.com/wasm/dotnet.js'));
158
165
  });
159
166
  });
167
+
168
+ // ─── parsePoeticNumber ──────────────────────────────────────────────────────
169
+
170
+ describe('parsePoeticNumber', () => {
171
+ it('maps word lengths to digits modulo 10', () => {
172
+ assert.equal(parsePoeticNumber('My dreams'), 26);
173
+ });
174
+
175
+ it('ignores apostrophes and stops at statement punctuation', () => {
176
+ assert.equal(
177
+ parsePoeticNumber("a panther, he ain't talkin' 'bout love. Shout Tommy"),
178
+ 1724644
179
+ );
180
+ });
181
+
182
+ it('treats an ellipsis as the decimal separator', () => {
183
+ assert.equal(
184
+ parsePoeticNumber("ice... a life unfulfilled, wakin' everybody up, taking booze and pills."),
185
+ 3.1415926535
186
+ );
187
+ });
188
+
189
+ it('counts hyphens as letters', () => {
190
+ assert.equal(parsePoeticNumber('life-long.'), 9);
191
+ });
192
+
193
+ it('supports the unicode ellipsis character too', () => {
194
+ assert.equal(
195
+ parsePoeticNumber('my… darkest nightmarish longings, my cravings, a symphony of suff\'ring that lasts life-long.'),
196
+ 2.718281828459
197
+ );
198
+ });
199
+
200
+ it('returns undefined when there are no words', () => {
201
+ assert.equal(parsePoeticNumber('!!!'), undefined);
202
+ });
203
+ });
204
+
205
+ // ─── parseOutputLine ────────────────────────────────────────────────────────
206
+
207
+ describe('parseOutputLine', () => {
208
+ it('returns numeric-first output while preserving words in other views', () => {
209
+ const parsed = parseOutputLine('hello world\n');
210
+ assert.deepEqual(parsed, {
211
+ raw: 'hello world\n',
212
+ output: 55,
213
+ mixed_output: 'hello world',
214
+ text_output: 'hello world',
215
+ });
216
+ });
217
+
218
+ it('stringifies numbers in text_output', () => {
219
+ const parsed = parseOutputLine('123\n');
220
+ assert.deepEqual(parsed, {
221
+ raw: '123\n',
222
+ output: 123,
223
+ mixed_output: 123,
224
+ text_output: '123',
225
+ });
226
+ });
227
+
228
+ it('parses JSON-style lists and converts string numerals', () => {
229
+ const parsed = parseOutputLine('[ "012" ]\n');
230
+ assert.deepEqual(parsed, {
231
+ raw: '[ "012" ]\n',
232
+ output: [12],
233
+ mixed_output: [12],
234
+ text_output: ['12'],
235
+ });
236
+ });
237
+
238
+ it('keeps mixed and text views alongside numeric-first output', () => {
239
+ const parsed = parseOutputLine('["3", ["my dreams", "007"]]\n');
240
+ assert.deepEqual(parsed, {
241
+ raw: '["3", ["my dreams", "007"]]\n',
242
+ output: [3, [26, 7]],
243
+ mixed_output: [3, ['my dreams', 7]],
244
+ text_output: ['3', ['my dreams', '7']],
245
+ });
246
+ });
247
+
248
+ it('returns undefined for blank output lines', () => {
249
+ assert.equal(parseOutputLine('\n'), undefined);
250
+ assert.equal(parseOutputLine('\r\n'), undefined);
251
+ });
252
+
253
+ it('falls back to text in mixed/text views when malformed JSON list is printed', () => {
254
+ const parsed = parseOutputLine('[ nope ]\n');
255
+ assert.deepEqual(parsed, {
256
+ raw: '[ nope ]\n',
257
+ output: 4,
258
+ mixed_output: '[ nope ]',
259
+ text_output: '[ nope ]',
260
+ });
261
+ });
262
+ });
263
+
264
+ // ─── resolveRerunValues ────────────────────────────────────────────────────
265
+
266
+ describe('resolveRerunValues', () => {
267
+ it('repeats the previous values when no args are provided', () => {
268
+ assert.deepEqual(resolveRerunValues([1, 'two', 3]), [1, 'two', 3]);
269
+ });
270
+
271
+ it('replaces values positionally from variadic args', () => {
272
+ assert.deepEqual(resolveRerunValues([1, 2, 3], 9, 8), [9, 8]);
273
+ });
274
+
275
+ it('replaces values from an array argument', () => {
276
+ assert.deepEqual(resolveRerunValues([1, 2, 3], [7, 6]), [7, 6]);
277
+ });
278
+
279
+ it('derives next values from a function', () => {
280
+ const next = resolveRerunValues([2, 4, 6], (prev) => prev.map((n) => n * 10));
281
+ assert.deepEqual(next, [20, 40, 60]);
282
+ });
283
+
284
+ it('wraps a non-array function result as a single interpolation value', () => {
285
+ const next = resolveRerunValues([2, 4, 6], () => 99);
286
+ assert.deepEqual(next, [99]);
287
+ });
288
+ });
@@ -0,0 +1,203 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": 1,
6
+ "id": "e8b054ac",
7
+ "metadata": {},
8
+ "outputs": [
9
+ {
10
+ "name": "stdout",
11
+ "output_type": "stream",
12
+ "text": [
13
+ "\n",
14
+ "> rockstar-strudel@1.0.4 test\n",
15
+ "> node --test test/*.test.js\n",
16
+ "\n",
17
+ "▶ buildSource\n",
18
+ " \u001b[32m✔ returns the raw template string when there are no interpolations \u001b[90m(0.835688ms)\u001b[39m\u001b[39m\n",
19
+ " \u001b[32m✔ splices a single string interpolation \u001b[90m(0.159772ms)\u001b[39m\u001b[39m\n",
20
+ " \u001b[32m✔ stringifies a numeric interpolation \u001b[90m(0.114796ms)\u001b[39m\u001b[39m\n",
21
+ " \u001b[32m✔ handles multiple interpolations in the correct order \u001b[90m(0.129431ms)\u001b[39m\u001b[39m\n",
22
+ " \u001b[32m✔ preserves leading and trailing whitespace / newlines in the template \u001b[90m(0.217994ms)\u001b[39m\u001b[39m\n",
23
+ " \u001b[32m✔ treats undefined interpolation values as empty strings \u001b[90m(0.098519ms)\u001b[39m\u001b[39m\n",
24
+ "\u001b[32m✔ buildSource \u001b[90m(2.715025ms)\u001b[39m\u001b[39m\n",
25
+ "▶ coerce\n",
26
+ " \u001b[32m✔ converts an integer string to a JS number \u001b[90m(0.608473ms)\u001b[39m\u001b[39m\n",
27
+ " \u001b[32m✔ converts a decimal string to a JS number \u001b[90m(0.590633ms)\u001b[39m\u001b[39m\n",
28
+ " \u001b[32m✔ converts a negative number string \u001b[90m(2.175091ms)\u001b[39m\u001b[39m\n",
29
+ " \u001b[32m✔ handles Windows-style CRLF line endings \u001b[90m(0.363412ms)\u001b[39m\u001b[39m\n",
30
+ " \u001b[32m✔ converts zero \u001b[90m(0.158039ms)\u001b[39m\u001b[39m\n",
31
+ " \u001b[32m✔ returns a string for non-numeric output \u001b[90m(0.194609ms)\u001b[39m\u001b[39m\n",
32
+ " \u001b[32m✔ returns a string for boolean-like output \u001b[90m(0.094095ms)\u001b[39m\u001b[39m\n",
33
+ " \u001b[32m✔ returns a string for null-like output \u001b[90m(0.055956ms)\u001b[39m\u001b[39m\n",
34
+ " \u001b[32m✔ returns undefined for an empty line \u001b[90m(0.046294ms)\u001b[39m\u001b[39m\n",
35
+ " \u001b[32m✔ returns undefined for a line containing only a newline \u001b[90m(0.044543ms)\u001b[39m\u001b[39m\n",
36
+ " \u001b[32m✔ does not coerce Infinity to a number (not finite) \u001b[90m(0.045679ms)\u001b[39m\u001b[39m\n",
37
+ " \u001b[32m✔ does not coerce NaN to a number \u001b[90m(0.044212ms)\u001b[39m\u001b[39m\n",
38
+ " \u001b[32m✔ strips multiple trailing newlines \u001b[90m(0.044264ms)\u001b[39m\u001b[39m\n",
39
+ "\u001b[32m✔ coerce \u001b[90m(5.028022ms)\u001b[39m\u001b[39m\n",
40
+ "▶ isTrustedUrl\n",
41
+ " \u001b[32m✔ trusts codewithrockstar.com \u001b[90m(0.192548ms)\u001b[39m\u001b[39m\n",
42
+ " \u001b[32m✔ trusts cdn.jsdelivr.net \u001b[90m(0.054461ms)\u001b[39m\u001b[39m\n",
43
+ " \u001b[32m✔ trusts unpkg.com \u001b[90m(0.047384ms)\u001b[39m\u001b[39m\n",
44
+ " \u001b[32m✔ trusts any *.github.io subdomain \u001b[90m(0.159751ms)\u001b[39m\u001b[39m\n",
45
+ " \u001b[32m✔ trusts localhost with a port \u001b[90m(0.10433ms)\u001b[39m\u001b[39m\n",
46
+ " \u001b[32m✔ trusts 127.0.0.1 with a port \u001b[90m(0.058382ms)\u001b[39m\u001b[39m\n",
47
+ " \u001b[32m✔ rejects an arbitrary https URL \u001b[90m(0.090083ms)\u001b[39m\u001b[39m\n",
48
+ " \u001b[32m✔ rejects a bare github.io URL without a subdomain \u001b[90m(0.054799ms)\u001b[39m\u001b[39m\n",
49
+ " \u001b[32m✔ rejects http (non-localhost) \u001b[90m(0.052302ms)\u001b[39m\u001b[39m\n",
50
+ "\u001b[32m✔ isTrustedUrl \u001b[90m(1.01552ms)\u001b[39m\u001b[39m\n",
51
+ "▶ parsePoeticNumber\n",
52
+ " \u001b[32m✔ maps word lengths to digits modulo 10 \u001b[90m(0.546635ms)\u001b[39m\u001b[39m\n",
53
+ " \u001b[32m✔ ignores apostrophes and stops at statement punctuation \u001b[90m(0.197288ms)\u001b[39m\u001b[39m\n",
54
+ " \u001b[32m✔ treats an ellipsis as the decimal separator \u001b[90m(0.099897ms)\u001b[39m\u001b[39m\n",
55
+ " \u001b[32m✔ counts hyphens as letters \u001b[90m(0.057811ms)\u001b[39m\u001b[39m\n",
56
+ " \u001b[32m✔ supports the unicode ellipsis character too \u001b[90m(0.076137ms)\u001b[39m\u001b[39m\n",
57
+ " \u001b[32m✔ returns undefined when there are no words \u001b[90m(0.05618ms)\u001b[39m\u001b[39m\n",
58
+ "\u001b[32m✔ parsePoeticNumber \u001b[90m(1.207209ms)\u001b[39m\u001b[39m\n",
59
+ "▶ parseOutputLine\n",
60
+ " \u001b[32m✔ returns numeric-first output while preserving words in other views \u001b[90m(0.870018ms)\u001b[39m\u001b[39m\n",
61
+ " \u001b[32m✔ stringifies numbers in text_output \u001b[90m(0.102378ms)\u001b[39m\u001b[39m\n",
62
+ " \u001b[32m✔ parses JSON-style lists and converts string numerals \u001b[90m(0.231375ms)\u001b[39m\u001b[39m\n",
63
+ " \u001b[32m✔ keeps mixed and text views alongside numeric-first output \u001b[90m(0.173793ms)\u001b[39m\u001b[39m\n",
64
+ " \u001b[32m✔ returns undefined for blank output lines \u001b[90m(0.097894ms)\u001b[39m\u001b[39m\n",
65
+ " \u001b[32m✔ falls back to text in mixed/text views when malformed JSON list is printed \u001b[90m(0.173253ms)\u001b[39m\u001b[39m\n",
66
+ "\u001b[32m✔ parseOutputLine \u001b[90m(1.835256ms)\u001b[39m\u001b[39m\n",
67
+ "▶ resolveRerunValues\n",
68
+ " \u001b[32m✔ repeats the previous values when no args are provided \u001b[90m(0.133796ms)\u001b[39m\u001b[39m\n",
69
+ " \u001b[32m✔ replaces values positionally from variadic args \u001b[90m(0.06069ms)\u001b[39m\u001b[39m\n",
70
+ " \u001b[32m✔ replaces values from an array argument \u001b[90m(0.3767ms)\u001b[39m\u001b[39m\n",
71
+ " \u001b[32m✔ derives next values from a function \u001b[90m(0.136754ms)\u001b[39m\u001b[39m\n",
72
+ " \u001b[32m✔ wraps a non-array function result as a single interpolation value \u001b[90m(0.080649ms)\u001b[39m\u001b[39m\n",
73
+ "\u001b[32m✔ resolveRerunValues \u001b[90m(0.943939ms)\u001b[39m\u001b[39m\n",
74
+ "\u001b[34mℹ tests 45\u001b[39m\n",
75
+ "\u001b[34mℹ suites 6\u001b[39m\n",
76
+ "\u001b[34mℹ pass 45\u001b[39m\n",
77
+ "\u001b[34mℹ fail 0\u001b[39m\n",
78
+ "\u001b[34mℹ cancelled 0\u001b[39m\n",
79
+ "\u001b[34mℹ skipped 0\u001b[39m\n",
80
+ "\u001b[34mℹ todo 0\u001b[39m\n",
81
+ "\u001b[34mℹ duration_ms 127.20622\u001b[39m\n",
82
+ "\n",
83
+ "{\"exports\":[\"ALLOWED_URL_PREFIXES\",\"GITHUB_IO_PATTERN\",\"buildSource\",\"coerce\",\"init\",\"isTrustedUrl\",\"parseOutputLine\",\"parsePoeticNumber\",\"resolveRerunValues\",\"rockstar\",\"rockstar_pro\"],\"hasRockstar\":true,\"hasRockstarPro\":true,\"parsedWordOutput\":{\"raw\":\"hello world\\n\",\"output\":55,\"mixed_output\":\"hello world\",\"text_output\":\"hello world\"},\"parsedNestedOutput\":{\"raw\":\"[\\\"3\\\", [\\\"my dreams\\\", \\\"007\\\"]]\\n\",\"output\":[3,[26,7]],\"mixed_output\":[3,[\"my dreams\",7]],\"text_output\":[\"3\",[\"my dreams\",\"7\"]]},\"rockstarReturnsStateOutput\":false,\"rockstarProExposesAliases\":true}\n",
84
+ "\n",
85
+ "\n",
86
+ "FINAL_VERIFICATION_JSON=\n",
87
+ "{\n",
88
+ " \"pass_fail\": \"PASS\",\n",
89
+ " \"summary_counts\": {},\n",
90
+ " \"failing_tests\": [],\n",
91
+ " \"rockstar_numeric_first\": false,\n",
92
+ " \"rockstar_pro_aliases\": true,\n",
93
+ " \"api_exports\": [\n",
94
+ " \"ALLOWED_URL_PREFIXES\",\n",
95
+ " \"GITHUB_IO_PATTERN\",\n",
96
+ " \"buildSource\",\n",
97
+ " \"coerce\",\n",
98
+ " \"init\",\n",
99
+ " \"isTrustedUrl\",\n",
100
+ " \"parseOutputLine\",\n",
101
+ " \"parsePoeticNumber\",\n",
102
+ " \"resolveRerunValues\",\n",
103
+ " \"rockstar\",\n",
104
+ " \"rockstar_pro\"\n",
105
+ " ]\n",
106
+ "}\n"
107
+ ]
108
+ }
109
+ ],
110
+ "source": [
111
+ "from pathlib import Path\n",
112
+ "import subprocess, json, re, textwrap\n",
113
+ "\n",
114
+ "repo = Path('/workspaces/rockstar-strudel')\n",
115
+ "\n",
116
+ "# Run the full test suite\n",
117
+ "proc = subprocess.run(\n",
118
+ " ['npm', 'test'],\n",
119
+ " cwd=repo,\n",
120
+ " capture_output=True,\n",
121
+ " text=True,\n",
122
+ ")\n",
123
+ "\n",
124
+ "output = (proc.stdout or '') + ('\\n' + proc.stderr if proc.stderr else '')\n",
125
+ "print(output)\n",
126
+ "\n",
127
+ "counts = {}\n",
128
+ "for key in ['tests', 'suites', 'pass', 'fail', 'cancelled', 'skipped', 'todo', 'duration_ms']:\n",
129
+ " m = re.search(rf'#\\s+{key}\\s+(\\d+)', output)\n",
130
+ " if m:\n",
131
+ " counts[key] = int(m.group(1))\n",
132
+ "\n",
133
+ "failing_tests = []\n",
134
+ "for line in output.splitlines():\n",
135
+ " stripped = line.strip()\n",
136
+ " if stripped.startswith('not ok '):\n",
137
+ " failing_tests.append(stripped[7:])\n",
138
+ "\n",
139
+ "node_code = r'''\n",
140
+ "import * as mod from './src/index.js';\n",
141
+ "const result = {\n",
142
+ " exports: Object.keys(mod).sort(),\n",
143
+ " hasRockstar: typeof mod.rockstar === 'function',\n",
144
+ " hasRockstarPro: typeof mod.rockstar_pro === 'function',\n",
145
+ " parsedWordOutput: mod.parseOutputLine('hello world\\n'),\n",
146
+ " parsedNestedOutput: mod.parseOutputLine('[\"3\", [\"my dreams\", \"007\"]]\\n'),\n",
147
+ " rockstarReturnsStateOutput: /return\\s+state\\.output\\b/.test(String(mod.rockstar)),\n",
148
+ " rockstarProExposesAliases:\n",
149
+ " /output/.test(String(mod.rockstar_pro)) &&\n",
150
+ " /mixed_output/.test(String(mod.rockstar_pro)) &&\n",
151
+ " /text_output/.test(String(mod.rockstar_pro)) &&\n",
152
+ " /raw_output/.test(String(mod.rockstar_pro)),\n",
153
+ "};\n",
154
+ "console.log(JSON.stringify(result));\n",
155
+ "'''\n",
156
+ "api_proc = subprocess.run(\n",
157
+ " ['node', '--input-type=module', '-e', node_code],\n",
158
+ " cwd=repo,\n",
159
+ " capture_output=True,\n",
160
+ " text=True,\n",
161
+ ")\n",
162
+ "print(api_proc.stdout)\n",
163
+ "if api_proc.stderr:\n",
164
+ " print(api_proc.stderr)\n",
165
+ "\n",
166
+ "api = json.loads(api_proc.stdout)\n",
167
+ "\n",
168
+ "verification = {\n",
169
+ " 'pass_fail': 'PASS' if proc.returncode == 0 else 'FAIL',\n",
170
+ " 'summary_counts': counts,\n",
171
+ " 'failing_tests': failing_tests,\n",
172
+ " 'rockstar_numeric_first': bool(api['rockstarReturnsStateOutput']) and api['parsedWordOutput']['output'] == 55,\n",
173
+ " 'rockstar_pro_aliases': bool(api['rockstarProExposesAliases']),\n",
174
+ " 'api_exports': api['exports'],\n",
175
+ "}\n",
176
+ "\n",
177
+ "print('\\nFINAL_VERIFICATION_JSON=')\n",
178
+ "print(json.dumps(verification, indent=2))"
179
+ ]
180
+ }
181
+ ],
182
+ "metadata": {
183
+ "kernelspec": {
184
+ "display_name": "Python 3",
185
+ "language": "python",
186
+ "name": "python3"
187
+ },
188
+ "language_info": {
189
+ "codemirror_mode": {
190
+ "name": "ipython",
191
+ "version": 3
192
+ },
193
+ "file_extension": ".py",
194
+ "mimetype": "text/x-python",
195
+ "name": "python",
196
+ "nbconvert_exporter": "python",
197
+ "pygments_lexer": "ipython3",
198
+ "version": "3.12.1"
199
+ }
200
+ },
201
+ "nbformat": 4,
202
+ "nbformat_minor": 5
203
+ }