@hyperfixi/speech 2.4.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 ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 LokaScript Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ 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, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @hyperfixi/speech
2
+
3
+ Web Speech API + `prompt()` plugin for [hyperfixi](https://github.com/codetalcott/hyperfixi). Adds three commands from upstream `_hyperscript 0.9.90`:
4
+
5
+ | Command | Purpose |
6
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
7
+ | `speak "<text>" [with <option> <value>]*` | Speaks the text via `window.speechSynthesis`. Optional rate/pitch/voice/volume. |
8
+ | `ask "<prompt>" [with default "<value>"]` | Calls `window.prompt()` and writes the answer to `result` and `it`. |
9
+ | `answer with "<value>"` | Sets `result` and `it` to the value without prompting — useful for scripted flows and test mocks. |
10
+
11
+ ## Install
12
+
13
+ ```ts
14
+ import { createRuntime, installPlugin } from '@hyperfixi/core';
15
+ import { speechPlugin } from '@hyperfixi/speech';
16
+
17
+ const runtime = createRuntime();
18
+ installPlugin(runtime, speechPlugin);
19
+ ```
20
+
21
+ Re-installing is safe: the plugin registers idempotent command keywords with the parser and replaces the existing command implementations in the runtime registry with identical ones.
22
+
23
+ ## `speak`
24
+
25
+ ```hyperscript
26
+ speak "Welcome back"
27
+ speak "Hello" with rate 1.5 with pitch 0.8
28
+ speak "Bonjour" with voice "Google français"
29
+ speak "Loud and clear" with volume 1 with rate 1.2
30
+ ```
31
+
32
+ Options (all optional, any combination, in any order):
33
+
34
+ | Option | Type | Notes |
35
+ | -------- | ------ | ---------------------------------------------------------------------------------------------------------------------- |
36
+ | `rate` | number | Forwarded to `SpeechSynthesisUtterance.rate`. Browser default 1; valid range typically 0.1 – 10. |
37
+ | `pitch` | number | Forwarded to `SpeechSynthesisUtterance.pitch`. Browser default 1; valid range typically 0 – 2. |
38
+ | `volume` | number | Forwarded to `SpeechSynthesisUtterance.volume`. Browser default 1; valid range 0 – 1. |
39
+ | `voice` | string | Matched against `speechSynthesis.getVoices()` by `.name`. If no voice matches, the utterance uses the browser default. |
40
+
41
+ Side effects: `context.result` is set to `true` on success, `false` when the Web Speech API is unavailable (Node, restricted contexts). The command never throws on missing browser support — it silently no-ops so the surrounding handler can keep running.
42
+
43
+ ## `ask`
44
+
45
+ ```hyperscript
46
+ ask "What's your name?"
47
+ put result into #greeting
48
+
49
+ ask "Username?" with default "guest"
50
+ ```
51
+
52
+ Calls `window.prompt(text, defaultValue?)`. The user's answer is written to **both** `context.result` and `context.it`, so the natural follow-up is `put it into ...` or `put result into ...`.
53
+
54
+ When `window.prompt` isn't available (headless / Node), the command returns `null` without setting `result` — let callers detect "no UI" by checking the value before using it.
55
+
56
+ ## `answer`
57
+
58
+ ```hyperscript
59
+ answer with "programmatic value"
60
+ -- result and it are now "programmatic value"
61
+
62
+ answer "bare form also works"
63
+ -- the leading `with` is optional
64
+ ```
65
+
66
+ `answer` is the scripted counterpart to `ask` — no UI, just sets `result` and `it` to the given value. Useful for:
67
+
68
+ - Stubbing user input in tests (`answer with "Test User"` to drive a flow that normally reads from `ask`)
69
+ - Forwarding a value through `result` without an intermediate `put` step
70
+
71
+ ## Notes on browser support
72
+
73
+ - `speechSynthesis` is widely available but [voices load asynchronously on some browsers](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis/getVoices). If `with voice "<name>"` doesn't apply, the named voice may not yet be in `getVoices()`; the utterance falls through to the browser default.
74
+ - iOS Safari requires a user gesture (tap/click) before `speechSynthesis.speak()` will produce audio. Calling `speak` from a `DOMContentLoaded` handler will silently fail there — wire it to a button click instead.
75
+ - `window.prompt()` is blocked by some browsers in cross-origin iframes; `ask` will return `null` in those contexts.
76
+
77
+ ## API exports
78
+
79
+ - `speechPlugin` (default export): the `HyperfixiPlugin` to pass to `installPlugin`.
80
+ - `speakCommand`, `askCommand`, `answerCommand`: the individual command implementations, exported for advanced wiring (e.g., registering against a custom registry).
81
+ - Types: `SpeakCommandInput`, `AskCommandInput`, `AnswerCommandInput`.
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Speech plugin commands — upstream _hyperscript 0.9.90.
3
+ *
4
+ * speak "text" SpeechSynthesis.speak(new Utterance(text))
5
+ * speak "text" with rate 1.5 options on the utterance
6
+ * speak "text" with pitch 0.8 with voice "Google UK English Female"
7
+ *
8
+ * ask "Your name?" window.prompt(text) → context.result
9
+ * ask window.prompt()
10
+ *
11
+ * answer with "programmatic" context.result = text (no UI; useful for
12
+ * scripted flows or test mocks)
13
+ *
14
+ * Each command returns a plain object with `{ name, parseInput, execute,
15
+ * validate }` — the minimum shape accepted by `CommandRegistryV2.register()`.
16
+ * The commands deliberately avoid importing from `@hyperfixi/core/commands`
17
+ * internals so the plugin stays a thin peer of the core package.
18
+ */
19
+ interface ASTNode {
20
+ type: string;
21
+ name?: string;
22
+ value?: unknown;
23
+ [k: string]: unknown;
24
+ }
25
+ interface ExpressionEvaluator {
26
+ evaluate(node: ASTNode, context: unknown): Promise<unknown>;
27
+ }
28
+ interface ExecutionContext {
29
+ result?: unknown;
30
+ it?: unknown;
31
+ [k: string]: unknown;
32
+ }
33
+ interface RawCommandInput {
34
+ args: ASTNode[];
35
+ modifiers: Record<string, ASTNode>;
36
+ }
37
+ export interface SpeakCommandInput {
38
+ text: string;
39
+ rate?: number;
40
+ pitch?: number;
41
+ voice?: string;
42
+ volume?: number;
43
+ }
44
+ export declare const speakCommand: {
45
+ name: string;
46
+ parseInput(raw: RawCommandInput, evaluator: ExpressionEvaluator, context: ExecutionContext): Promise<SpeakCommandInput>;
47
+ execute(input: SpeakCommandInput, context: ExecutionContext): Promise<void>;
48
+ validate(input: unknown): boolean;
49
+ };
50
+ export interface AskCommandInput {
51
+ prompt?: string;
52
+ defaultValue?: string;
53
+ }
54
+ export declare const askCommand: {
55
+ name: string;
56
+ parseInput(raw: RawCommandInput, evaluator: ExpressionEvaluator, context: ExecutionContext): Promise<AskCommandInput>;
57
+ execute(input: AskCommandInput, context: ExecutionContext): Promise<unknown>;
58
+ validate(input: unknown): boolean;
59
+ };
60
+ export interface AnswerCommandInput {
61
+ value: unknown;
62
+ }
63
+ export declare const answerCommand: {
64
+ name: string;
65
+ parseInput(raw: RawCommandInput, evaluator: ExpressionEvaluator, context: ExecutionContext): Promise<AnswerCommandInput>;
66
+ execute(input: AnswerCommandInput, context: ExecutionContext): Promise<unknown>;
67
+ validate(input: unknown): boolean;
68
+ };
69
+ export {};
package/dist/index.cjs ADDED
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ answerCommand: () => answerCommand,
24
+ askCommand: () => askCommand,
25
+ default: () => index_default,
26
+ speakCommand: () => speakCommand,
27
+ speechPlugin: () => speechPlugin
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/commands.ts
32
+ async function parseSpeakOptions(args, startIndex, evaluator, context) {
33
+ const out = {};
34
+ let i = startIndex;
35
+ while (i < args.length) {
36
+ const tok = args[i];
37
+ if (tok?.type === "identifier" && tok.name?.toLowerCase() === "with") {
38
+ i++;
39
+ continue;
40
+ }
41
+ if (tok?.type === "identifier" && i + 1 < args.length) {
42
+ const key = tok.name.toLowerCase();
43
+ const value = await evaluator.evaluate(args[i + 1], context);
44
+ if (key === "rate" && typeof value === "number") out.rate = value;
45
+ else if (key === "pitch" && typeof value === "number") out.pitch = value;
46
+ else if (key === "volume" && typeof value === "number") out.volume = value;
47
+ else if (key === "voice" && typeof value === "string") out.voice = value;
48
+ i += 2;
49
+ continue;
50
+ }
51
+ i++;
52
+ }
53
+ return out;
54
+ }
55
+ var speakCommand = {
56
+ name: "speak",
57
+ async parseInput(raw, evaluator, context) {
58
+ if (!raw.args?.length) {
59
+ throw new Error("speak command requires a text argument");
60
+ }
61
+ const text = await evaluator.evaluate(raw.args[0], context);
62
+ const opts = await parseSpeakOptions(raw.args, 1, evaluator, context);
63
+ return { text: text == null ? "" : String(text), ...opts };
64
+ },
65
+ async execute(input, context) {
66
+ const synth = typeof globalThis !== "undefined" ? globalThis.speechSynthesis : void 0;
67
+ if (!synth || typeof SpeechSynthesisUtterance === "undefined") {
68
+ context.result = false;
69
+ return;
70
+ }
71
+ const utter = new SpeechSynthesisUtterance(input.text);
72
+ if (input.rate != null) utter.rate = input.rate;
73
+ if (input.pitch != null) utter.pitch = input.pitch;
74
+ if (input.volume != null) utter.volume = input.volume;
75
+ if (input.voice != null) {
76
+ const voices = synth.getVoices?.() ?? [];
77
+ const match = voices.find((v) => v.name === input.voice);
78
+ if (match) utter.voice = match;
79
+ }
80
+ synth.speak(utter);
81
+ context.result = true;
82
+ },
83
+ validate(input) {
84
+ return !!input && typeof input === "object" && typeof input.text === "string";
85
+ }
86
+ };
87
+ var askCommand = {
88
+ name: "ask",
89
+ async parseInput(raw, evaluator, context) {
90
+ const out = {};
91
+ if (raw.args?.length) {
92
+ const promptValue = await evaluator.evaluate(raw.args[0], context);
93
+ if (promptValue != null) out.prompt = String(promptValue);
94
+ }
95
+ for (let i = 1; i < (raw.args?.length ?? 0) - 1; i++) {
96
+ const tok = raw.args[i];
97
+ const next = raw.args[i + 1];
98
+ if (tok?.type === "identifier" && tok.name?.toLowerCase() === "default" && next) {
99
+ const defaultVal = await evaluator.evaluate(next, context);
100
+ if (defaultVal != null) out.defaultValue = String(defaultVal);
101
+ }
102
+ }
103
+ return out;
104
+ },
105
+ async execute(input, context) {
106
+ const win = typeof globalThis !== "undefined" ? globalThis : void 0;
107
+ if (!win || typeof win.prompt !== "function") {
108
+ return null;
109
+ }
110
+ const answer = win.prompt(input.prompt ?? "", input.defaultValue ?? "");
111
+ context.result = answer;
112
+ context.it = answer;
113
+ return answer;
114
+ },
115
+ validate(input) {
116
+ return typeof input === "object" && input !== null;
117
+ }
118
+ };
119
+ var answerCommand = {
120
+ name: "answer",
121
+ async parseInput(raw, evaluator, context) {
122
+ if (!raw.args?.length) {
123
+ throw new Error('answer command requires a value (e.g. `answer with "text"`)');
124
+ }
125
+ let valueIndex = 0;
126
+ const first = raw.args[0];
127
+ if (first?.type === "identifier" && first.name?.toLowerCase() === "with") {
128
+ valueIndex = 1;
129
+ }
130
+ if (valueIndex >= raw.args.length) {
131
+ throw new Error("answer command requires a value after `with`");
132
+ }
133
+ const value = await evaluator.evaluate(raw.args[valueIndex], context);
134
+ return { value };
135
+ },
136
+ async execute(input, context) {
137
+ context.result = input.value;
138
+ context.it = input.value;
139
+ return input.value;
140
+ },
141
+ validate(input) {
142
+ return typeof input === "object" && input !== null && "value" in input;
143
+ }
144
+ };
145
+
146
+ // src/index.ts
147
+ var speechPlugin = {
148
+ name: "@hyperfixi/speech",
149
+ install({ commandRegistry, parserExtensions }) {
150
+ parserExtensions.registerCommand("speak");
151
+ parserExtensions.registerCommand("ask");
152
+ parserExtensions.registerCommand("answer");
153
+ commandRegistry.register(speakCommand);
154
+ commandRegistry.register(askCommand);
155
+ commandRegistry.register(answerCommand);
156
+ }
157
+ };
158
+ var index_default = speechPlugin;
159
+ // Annotate the CommonJS export names for ESM import in node:
160
+ 0 && (module.exports = {
161
+ answerCommand,
162
+ askCommand,
163
+ speakCommand,
164
+ speechPlugin
165
+ });
166
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/commands.ts"],"sourcesContent":["/**\n * @hyperfixi/speech — Web Speech API + prompt() plugin for hyperfixi.\n *\n * Adds three commands from upstream _hyperscript 0.9.90:\n *\n * speak \"text\" [with rate N] [with pitch N] [with voice \"Name\"]\n * ask \"prompt\" → context.result = user's answer\n * answer with \"text\" → context.result = text (scripted)\n *\n * Installation:\n *\n * ```ts\n * import { createRuntime, installPlugin } from '@hyperfixi/core';\n * import { speechPlugin } from '@hyperfixi/speech';\n *\n * const runtime = createRuntime();\n * installPlugin(runtime, speechPlugin);\n * ```\n */\n\nimport type { HyperfixiPlugin, HyperfixiPluginContext } from '@hyperfixi/core';\nimport { speakCommand, askCommand, answerCommand } from './commands';\n\nexport { speakCommand, askCommand, answerCommand };\nexport type { SpeakCommandInput, AskCommandInput, AnswerCommandInput } from './commands';\n\n/**\n * Plugin object for one-shot installation. Registers three command keywords\n * with the parser and three command implementations with the runtime.\n *\n * Idempotent: re-installing in the same process is a no-op for the parser\n * (keywords are Set-based) and replaces the existing command implementations\n * with identical ones in the runtime registry.\n */\nexport const speechPlugin: HyperfixiPlugin = {\n name: '@hyperfixi/speech',\n install({ commandRegistry, parserExtensions }: HyperfixiPluginContext) {\n parserExtensions.registerCommand('speak');\n parserExtensions.registerCommand('ask');\n parserExtensions.registerCommand('answer');\n commandRegistry.register(speakCommand as never);\n commandRegistry.register(askCommand as never);\n commandRegistry.register(answerCommand as never);\n },\n};\n\nexport default speechPlugin;\n","/**\n * Speech plugin commands — upstream _hyperscript 0.9.90.\n *\n * speak \"text\" SpeechSynthesis.speak(new Utterance(text))\n * speak \"text\" with rate 1.5 options on the utterance\n * speak \"text\" with pitch 0.8 with voice \"Google UK English Female\"\n *\n * ask \"Your name?\" window.prompt(text) → context.result\n * ask window.prompt()\n *\n * answer with \"programmatic\" context.result = text (no UI; useful for\n * scripted flows or test mocks)\n *\n * Each command returns a plain object with `{ name, parseInput, execute,\n * validate }` — the minimum shape accepted by `CommandRegistryV2.register()`.\n * The commands deliberately avoid importing from `@hyperfixi/core/commands`\n * internals so the plugin stays a thin peer of the core package.\n */\n\n// Lightweight type stubs — the plugin consumes raw shapes rather than import\n// tightly from core internals, keeping the package self-contained.\ninterface ASTNode {\n type: string;\n name?: string;\n value?: unknown;\n [k: string]: unknown;\n}\ninterface ExpressionEvaluator {\n evaluate(node: ASTNode, context: unknown): Promise<unknown>;\n}\ninterface ExecutionContext {\n result?: unknown;\n it?: unknown;\n [k: string]: unknown;\n}\n\ninterface RawCommandInput {\n args: ASTNode[];\n modifiers: Record<string, ASTNode>;\n}\n\n// ---------------------------------------------------------------------------\n// speak\n// ---------------------------------------------------------------------------\n\nexport interface SpeakCommandInput {\n text: string;\n rate?: number;\n pitch?: number;\n voice?: string;\n volume?: number;\n}\n\n/**\n * Consume a leading `with` identifier followed by option pairs like\n * `rate 1.5`, `pitch 0.8`, `voice \"Google UK English Female\"`. Accepts\n * multiple `with <key> <value>` pairs.\n */\nasync function parseSpeakOptions(\n args: ASTNode[],\n startIndex: number,\n evaluator: ExpressionEvaluator,\n context: unknown\n): Promise<Omit<SpeakCommandInput, 'text'>> {\n const out: Omit<SpeakCommandInput, 'text'> = {};\n let i = startIndex;\n while (i < args.length) {\n const tok = args[i];\n if (tok?.type === 'identifier' && (tok.name as string)?.toLowerCase() === 'with') {\n i++;\n continue;\n }\n if (tok?.type === 'identifier' && i + 1 < args.length) {\n const key = (tok.name as string).toLowerCase();\n const value = await evaluator.evaluate(args[i + 1], context);\n if (key === 'rate' && typeof value === 'number') out.rate = value;\n else if (key === 'pitch' && typeof value === 'number') out.pitch = value;\n else if (key === 'volume' && typeof value === 'number') out.volume = value;\n else if (key === 'voice' && typeof value === 'string') out.voice = value;\n i += 2;\n continue;\n }\n i++;\n }\n return out;\n}\n\nexport const speakCommand = {\n name: 'speak',\n async parseInput(\n raw: RawCommandInput,\n evaluator: ExpressionEvaluator,\n context: ExecutionContext\n ): Promise<SpeakCommandInput> {\n if (!raw.args?.length) {\n throw new Error('speak command requires a text argument');\n }\n const text = await evaluator.evaluate(raw.args[0], context);\n const opts = await parseSpeakOptions(raw.args, 1, evaluator, context);\n return { text: text == null ? '' : String(text), ...opts };\n },\n async execute(input: SpeakCommandInput, context: ExecutionContext): Promise<void> {\n const synth =\n typeof globalThis !== 'undefined'\n ? (globalThis as unknown as { speechSynthesis?: SpeechSynthesis }).speechSynthesis\n : undefined;\n if (!synth || typeof SpeechSynthesisUtterance === 'undefined') {\n // No Web Speech API available — no-op. Downstream consumers can detect\n // this by observing that `context.result` is still set (see below).\n context.result = false;\n return;\n }\n const utter = new SpeechSynthesisUtterance(input.text);\n if (input.rate != null) utter.rate = input.rate;\n if (input.pitch != null) utter.pitch = input.pitch;\n if (input.volume != null) utter.volume = input.volume;\n if (input.voice != null) {\n const voices = synth.getVoices?.() ?? [];\n const match = voices.find(v => v.name === input.voice);\n if (match) utter.voice = match;\n }\n synth.speak(utter);\n context.result = true;\n },\n validate(input: unknown): boolean {\n return !!input && typeof input === 'object' && typeof (input as any).text === 'string';\n },\n};\n\n// ---------------------------------------------------------------------------\n// ask\n// ---------------------------------------------------------------------------\n\nexport interface AskCommandInput {\n prompt?: string;\n defaultValue?: string;\n}\n\nexport const askCommand = {\n name: 'ask',\n async parseInput(\n raw: RawCommandInput,\n evaluator: ExpressionEvaluator,\n context: ExecutionContext\n ): Promise<AskCommandInput> {\n const out: AskCommandInput = {};\n if (raw.args?.length) {\n const promptValue = await evaluator.evaluate(raw.args[0], context);\n if (promptValue != null) out.prompt = String(promptValue);\n }\n // Optional `with default \"X\"` form\n for (let i = 1; i < (raw.args?.length ?? 0) - 1; i++) {\n const tok = raw.args[i];\n const next = raw.args[i + 1];\n if (tok?.type === 'identifier' && (tok.name as string)?.toLowerCase() === 'default' && next) {\n const defaultVal = await evaluator.evaluate(next, context);\n if (defaultVal != null) out.defaultValue = String(defaultVal);\n }\n }\n return out;\n },\n async execute(input: AskCommandInput, context: ExecutionContext): Promise<unknown> {\n const win =\n typeof globalThis !== 'undefined'\n ? (globalThis as unknown as { prompt?: (p?: string, d?: string) => string | null })\n : undefined;\n if (!win || typeof win.prompt !== 'function') {\n // No prompt available (e.g. Node headless) — no-op; leave result unset.\n return null;\n }\n const answer = win.prompt(input.prompt ?? '', input.defaultValue ?? '');\n context.result = answer;\n context.it = answer;\n return answer;\n },\n validate(input: unknown): boolean {\n return typeof input === 'object' && input !== null;\n },\n};\n\n// ---------------------------------------------------------------------------\n// answer\n// ---------------------------------------------------------------------------\n\nexport interface AnswerCommandInput {\n value: unknown;\n}\n\nexport const answerCommand = {\n name: 'answer',\n async parseInput(\n raw: RawCommandInput,\n evaluator: ExpressionEvaluator,\n context: ExecutionContext\n ): Promise<AnswerCommandInput> {\n if (!raw.args?.length) {\n throw new Error('answer command requires a value (e.g. `answer with \"text\"`)');\n }\n // Skip a leading `with` keyword if present: `answer with \"text\"`.\n let valueIndex = 0;\n const first = raw.args[0];\n if (first?.type === 'identifier' && (first.name as string)?.toLowerCase() === 'with') {\n valueIndex = 1;\n }\n if (valueIndex >= raw.args.length) {\n throw new Error('answer command requires a value after `with`');\n }\n const value = await evaluator.evaluate(raw.args[valueIndex], context);\n return { value };\n },\n async execute(input: AnswerCommandInput, context: ExecutionContext): Promise<unknown> {\n context.result = input.value;\n context.it = input.value;\n return input.value;\n },\n validate(input: unknown): boolean {\n return typeof input === 'object' && input !== null && 'value' in (input as object);\n },\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AC0DA,eAAe,kBACb,MACA,YACA,WACA,SAC0C;AAC1C,QAAM,MAAuC,CAAC;AAC9C,MAAI,IAAI;AACR,SAAO,IAAI,KAAK,QAAQ;AACtB,UAAM,MAAM,KAAK,CAAC;AAClB,QAAI,KAAK,SAAS,gBAAiB,IAAI,MAAiB,YAAY,MAAM,QAAQ;AAChF;AACA;AAAA,IACF;AACA,QAAI,KAAK,SAAS,gBAAgB,IAAI,IAAI,KAAK,QAAQ;AACrD,YAAM,MAAO,IAAI,KAAgB,YAAY;AAC7C,YAAM,QAAQ,MAAM,UAAU,SAAS,KAAK,IAAI,CAAC,GAAG,OAAO;AAC3D,UAAI,QAAQ,UAAU,OAAO,UAAU,SAAU,KAAI,OAAO;AAAA,eACnD,QAAQ,WAAW,OAAO,UAAU,SAAU,KAAI,QAAQ;AAAA,eAC1D,QAAQ,YAAY,OAAO,UAAU,SAAU,KAAI,SAAS;AAAA,eAC5D,QAAQ,WAAW,OAAO,UAAU,SAAU,KAAI,QAAQ;AACnE,WAAK;AACL;AAAA,IACF;AACA;AAAA,EACF;AACA,SAAO;AACT;AAEO,IAAM,eAAe;AAAA,EAC1B,MAAM;AAAA,EACN,MAAM,WACJ,KACA,WACA,SAC4B;AAC5B,QAAI,CAAC,IAAI,MAAM,QAAQ;AACrB,YAAM,IAAI,MAAM,wCAAwC;AAAA,IAC1D;AACA,UAAM,OAAO,MAAM,UAAU,SAAS,IAAI,KAAK,CAAC,GAAG,OAAO;AAC1D,UAAM,OAAO,MAAM,kBAAkB,IAAI,MAAM,GAAG,WAAW,OAAO;AACpE,WAAO,EAAE,MAAM,QAAQ,OAAO,KAAK,OAAO,IAAI,GAAG,GAAG,KAAK;AAAA,EAC3D;AAAA,EACA,MAAM,QAAQ,OAA0B,SAA0C;AAChF,UAAM,QACJ,OAAO,eAAe,cACjB,WAAgE,kBACjE;AACN,QAAI,CAAC,SAAS,OAAO,6BAA6B,aAAa;AAG7D,cAAQ,SAAS;AACjB;AAAA,IACF;AACA,UAAM,QAAQ,IAAI,yBAAyB,MAAM,IAAI;AACrD,QAAI,MAAM,QAAQ,KAAM,OAAM,OAAO,MAAM;AAC3C,QAAI,MAAM,SAAS,KAAM,OAAM,QAAQ,MAAM;AAC7C,QAAI,MAAM,UAAU,KAAM,OAAM,SAAS,MAAM;AAC/C,QAAI,MAAM,SAAS,MAAM;AACvB,YAAM,SAAS,MAAM,YAAY,KAAK,CAAC;AACvC,YAAM,QAAQ,OAAO,KAAK,OAAK,EAAE,SAAS,MAAM,KAAK;AACrD,UAAI,MAAO,OAAM,QAAQ;AAAA,IAC3B;AACA,UAAM,MAAM,KAAK;AACjB,YAAQ,SAAS;AAAA,EACnB;AAAA,EACA,SAAS,OAAyB;AAChC,WAAO,CAAC,CAAC,SAAS,OAAO,UAAU,YAAY,OAAQ,MAAc,SAAS;AAAA,EAChF;AACF;AAWO,IAAM,aAAa;AAAA,EACxB,MAAM;AAAA,EACN,MAAM,WACJ,KACA,WACA,SAC0B;AAC1B,UAAM,MAAuB,CAAC;AAC9B,QAAI,IAAI,MAAM,QAAQ;AACpB,YAAM,cAAc,MAAM,UAAU,SAAS,IAAI,KAAK,CAAC,GAAG,OAAO;AACjE,UAAI,eAAe,KAAM,KAAI,SAAS,OAAO,WAAW;AAAA,IAC1D;AAEA,aAAS,IAAI,GAAG,KAAK,IAAI,MAAM,UAAU,KAAK,GAAG,KAAK;AACpD,YAAM,MAAM,IAAI,KAAK,CAAC;AACtB,YAAM,OAAO,IAAI,KAAK,IAAI,CAAC;AAC3B,UAAI,KAAK,SAAS,gBAAiB,IAAI,MAAiB,YAAY,MAAM,aAAa,MAAM;AAC3F,cAAM,aAAa,MAAM,UAAU,SAAS,MAAM,OAAO;AACzD,YAAI,cAAc,KAAM,KAAI,eAAe,OAAO,UAAU;AAAA,MAC9D;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EACA,MAAM,QAAQ,OAAwB,SAA6C;AACjF,UAAM,MACJ,OAAO,eAAe,cACjB,aACD;AACN,QAAI,CAAC,OAAO,OAAO,IAAI,WAAW,YAAY;AAE5C,aAAO;AAAA,IACT;AACA,UAAM,SAAS,IAAI,OAAO,MAAM,UAAU,IAAI,MAAM,gBAAgB,EAAE;AACtE,YAAQ,SAAS;AACjB,YAAQ,KAAK;AACb,WAAO;AAAA,EACT;AAAA,EACA,SAAS,OAAyB;AAChC,WAAO,OAAO,UAAU,YAAY,UAAU;AAAA,EAChD;AACF;AAUO,IAAM,gBAAgB;AAAA,EAC3B,MAAM;AAAA,EACN,MAAM,WACJ,KACA,WACA,SAC6B;AAC7B,QAAI,CAAC,IAAI,MAAM,QAAQ;AACrB,YAAM,IAAI,MAAM,6DAA6D;AAAA,IAC/E;AAEA,QAAI,aAAa;AACjB,UAAM,QAAQ,IAAI,KAAK,CAAC;AACxB,QAAI,OAAO,SAAS,gBAAiB,MAAM,MAAiB,YAAY,MAAM,QAAQ;AACpF,mBAAa;AAAA,IACf;AACA,QAAI,cAAc,IAAI,KAAK,QAAQ;AACjC,YAAM,IAAI,MAAM,8CAA8C;AAAA,IAChE;AACA,UAAM,QAAQ,MAAM,UAAU,SAAS,IAAI,KAAK,UAAU,GAAG,OAAO;AACpE,WAAO,EAAE,MAAM;AAAA,EACjB;AAAA,EACA,MAAM,QAAQ,OAA2B,SAA6C;AACpF,YAAQ,SAAS,MAAM;AACvB,YAAQ,KAAK,MAAM;AACnB,WAAO,MAAM;AAAA,EACf;AAAA,EACA,SAAS,OAAyB;AAChC,WAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,WAAY;AAAA,EACpE;AACF;;;ADxLO,IAAM,eAAgC;AAAA,EAC3C,MAAM;AAAA,EACN,QAAQ,EAAE,iBAAiB,iBAAiB,GAA2B;AACrE,qBAAiB,gBAAgB,OAAO;AACxC,qBAAiB,gBAAgB,KAAK;AACtC,qBAAiB,gBAAgB,QAAQ;AACzC,oBAAgB,SAAS,YAAqB;AAC9C,oBAAgB,SAAS,UAAmB;AAC5C,oBAAgB,SAAS,aAAsB;AAAA,EACjD;AACF;AAEA,IAAO,gBAAQ;","names":[]}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @hyperfixi/speech — Web Speech API + prompt() plugin for hyperfixi.
3
+ *
4
+ * Adds three commands from upstream _hyperscript 0.9.90:
5
+ *
6
+ * speak "text" [with rate N] [with pitch N] [with voice "Name"]
7
+ * ask "prompt" → context.result = user's answer
8
+ * answer with "text" → context.result = text (scripted)
9
+ *
10
+ * Installation:
11
+ *
12
+ * ```ts
13
+ * import { createRuntime, installPlugin } from '@hyperfixi/core';
14
+ * import { speechPlugin } from '@hyperfixi/speech';
15
+ *
16
+ * const runtime = createRuntime();
17
+ * installPlugin(runtime, speechPlugin);
18
+ * ```
19
+ */
20
+ import type { HyperfixiPlugin } from '@hyperfixi/core';
21
+ import { speakCommand, askCommand, answerCommand } from './commands';
22
+ export { speakCommand, askCommand, answerCommand };
23
+ export type { SpeakCommandInput, AskCommandInput, AnswerCommandInput } from './commands';
24
+ /**
25
+ * Plugin object for one-shot installation. Registers three command keywords
26
+ * with the parser and three command implementations with the runtime.
27
+ *
28
+ * Idempotent: re-installing in the same process is a no-op for the parser
29
+ * (keywords are Set-based) and replaces the existing command implementations
30
+ * with identical ones in the runtime registry.
31
+ */
32
+ export declare const speechPlugin: HyperfixiPlugin;
33
+ export default speechPlugin;
package/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ // src/commands.ts
2
+ async function parseSpeakOptions(args, startIndex, evaluator, context) {
3
+ const out = {};
4
+ let i = startIndex;
5
+ while (i < args.length) {
6
+ const tok = args[i];
7
+ if (tok?.type === "identifier" && tok.name?.toLowerCase() === "with") {
8
+ i++;
9
+ continue;
10
+ }
11
+ if (tok?.type === "identifier" && i + 1 < args.length) {
12
+ const key = tok.name.toLowerCase();
13
+ const value = await evaluator.evaluate(args[i + 1], context);
14
+ if (key === "rate" && typeof value === "number") out.rate = value;
15
+ else if (key === "pitch" && typeof value === "number") out.pitch = value;
16
+ else if (key === "volume" && typeof value === "number") out.volume = value;
17
+ else if (key === "voice" && typeof value === "string") out.voice = value;
18
+ i += 2;
19
+ continue;
20
+ }
21
+ i++;
22
+ }
23
+ return out;
24
+ }
25
+ var speakCommand = {
26
+ name: "speak",
27
+ async parseInput(raw, evaluator, context) {
28
+ if (!raw.args?.length) {
29
+ throw new Error("speak command requires a text argument");
30
+ }
31
+ const text = await evaluator.evaluate(raw.args[0], context);
32
+ const opts = await parseSpeakOptions(raw.args, 1, evaluator, context);
33
+ return { text: text == null ? "" : String(text), ...opts };
34
+ },
35
+ async execute(input, context) {
36
+ const synth = typeof globalThis !== "undefined" ? globalThis.speechSynthesis : void 0;
37
+ if (!synth || typeof SpeechSynthesisUtterance === "undefined") {
38
+ context.result = false;
39
+ return;
40
+ }
41
+ const utter = new SpeechSynthesisUtterance(input.text);
42
+ if (input.rate != null) utter.rate = input.rate;
43
+ if (input.pitch != null) utter.pitch = input.pitch;
44
+ if (input.volume != null) utter.volume = input.volume;
45
+ if (input.voice != null) {
46
+ const voices = synth.getVoices?.() ?? [];
47
+ const match = voices.find((v) => v.name === input.voice);
48
+ if (match) utter.voice = match;
49
+ }
50
+ synth.speak(utter);
51
+ context.result = true;
52
+ },
53
+ validate(input) {
54
+ return !!input && typeof input === "object" && typeof input.text === "string";
55
+ }
56
+ };
57
+ var askCommand = {
58
+ name: "ask",
59
+ async parseInput(raw, evaluator, context) {
60
+ const out = {};
61
+ if (raw.args?.length) {
62
+ const promptValue = await evaluator.evaluate(raw.args[0], context);
63
+ if (promptValue != null) out.prompt = String(promptValue);
64
+ }
65
+ for (let i = 1; i < (raw.args?.length ?? 0) - 1; i++) {
66
+ const tok = raw.args[i];
67
+ const next = raw.args[i + 1];
68
+ if (tok?.type === "identifier" && tok.name?.toLowerCase() === "default" && next) {
69
+ const defaultVal = await evaluator.evaluate(next, context);
70
+ if (defaultVal != null) out.defaultValue = String(defaultVal);
71
+ }
72
+ }
73
+ return out;
74
+ },
75
+ async execute(input, context) {
76
+ const win = typeof globalThis !== "undefined" ? globalThis : void 0;
77
+ if (!win || typeof win.prompt !== "function") {
78
+ return null;
79
+ }
80
+ const answer = win.prompt(input.prompt ?? "", input.defaultValue ?? "");
81
+ context.result = answer;
82
+ context.it = answer;
83
+ return answer;
84
+ },
85
+ validate(input) {
86
+ return typeof input === "object" && input !== null;
87
+ }
88
+ };
89
+ var answerCommand = {
90
+ name: "answer",
91
+ async parseInput(raw, evaluator, context) {
92
+ if (!raw.args?.length) {
93
+ throw new Error('answer command requires a value (e.g. `answer with "text"`)');
94
+ }
95
+ let valueIndex = 0;
96
+ const first = raw.args[0];
97
+ if (first?.type === "identifier" && first.name?.toLowerCase() === "with") {
98
+ valueIndex = 1;
99
+ }
100
+ if (valueIndex >= raw.args.length) {
101
+ throw new Error("answer command requires a value after `with`");
102
+ }
103
+ const value = await evaluator.evaluate(raw.args[valueIndex], context);
104
+ return { value };
105
+ },
106
+ async execute(input, context) {
107
+ context.result = input.value;
108
+ context.it = input.value;
109
+ return input.value;
110
+ },
111
+ validate(input) {
112
+ return typeof input === "object" && input !== null && "value" in input;
113
+ }
114
+ };
115
+
116
+ // src/index.ts
117
+ var speechPlugin = {
118
+ name: "@hyperfixi/speech",
119
+ install({ commandRegistry, parserExtensions }) {
120
+ parserExtensions.registerCommand("speak");
121
+ parserExtensions.registerCommand("ask");
122
+ parserExtensions.registerCommand("answer");
123
+ commandRegistry.register(speakCommand);
124
+ commandRegistry.register(askCommand);
125
+ commandRegistry.register(answerCommand);
126
+ }
127
+ };
128
+ var index_default = speechPlugin;
129
+ export {
130
+ answerCommand,
131
+ askCommand,
132
+ index_default as default,
133
+ speakCommand,
134
+ speechPlugin
135
+ };
136
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/commands.ts","../src/index.ts"],"sourcesContent":["/**\n * Speech plugin commands — upstream _hyperscript 0.9.90.\n *\n * speak \"text\" SpeechSynthesis.speak(new Utterance(text))\n * speak \"text\" with rate 1.5 options on the utterance\n * speak \"text\" with pitch 0.8 with voice \"Google UK English Female\"\n *\n * ask \"Your name?\" window.prompt(text) → context.result\n * ask window.prompt()\n *\n * answer with \"programmatic\" context.result = text (no UI; useful for\n * scripted flows or test mocks)\n *\n * Each command returns a plain object with `{ name, parseInput, execute,\n * validate }` — the minimum shape accepted by `CommandRegistryV2.register()`.\n * The commands deliberately avoid importing from `@hyperfixi/core/commands`\n * internals so the plugin stays a thin peer of the core package.\n */\n\n// Lightweight type stubs — the plugin consumes raw shapes rather than import\n// tightly from core internals, keeping the package self-contained.\ninterface ASTNode {\n type: string;\n name?: string;\n value?: unknown;\n [k: string]: unknown;\n}\ninterface ExpressionEvaluator {\n evaluate(node: ASTNode, context: unknown): Promise<unknown>;\n}\ninterface ExecutionContext {\n result?: unknown;\n it?: unknown;\n [k: string]: unknown;\n}\n\ninterface RawCommandInput {\n args: ASTNode[];\n modifiers: Record<string, ASTNode>;\n}\n\n// ---------------------------------------------------------------------------\n// speak\n// ---------------------------------------------------------------------------\n\nexport interface SpeakCommandInput {\n text: string;\n rate?: number;\n pitch?: number;\n voice?: string;\n volume?: number;\n}\n\n/**\n * Consume a leading `with` identifier followed by option pairs like\n * `rate 1.5`, `pitch 0.8`, `voice \"Google UK English Female\"`. Accepts\n * multiple `with <key> <value>` pairs.\n */\nasync function parseSpeakOptions(\n args: ASTNode[],\n startIndex: number,\n evaluator: ExpressionEvaluator,\n context: unknown\n): Promise<Omit<SpeakCommandInput, 'text'>> {\n const out: Omit<SpeakCommandInput, 'text'> = {};\n let i = startIndex;\n while (i < args.length) {\n const tok = args[i];\n if (tok?.type === 'identifier' && (tok.name as string)?.toLowerCase() === 'with') {\n i++;\n continue;\n }\n if (tok?.type === 'identifier' && i + 1 < args.length) {\n const key = (tok.name as string).toLowerCase();\n const value = await evaluator.evaluate(args[i + 1], context);\n if (key === 'rate' && typeof value === 'number') out.rate = value;\n else if (key === 'pitch' && typeof value === 'number') out.pitch = value;\n else if (key === 'volume' && typeof value === 'number') out.volume = value;\n else if (key === 'voice' && typeof value === 'string') out.voice = value;\n i += 2;\n continue;\n }\n i++;\n }\n return out;\n}\n\nexport const speakCommand = {\n name: 'speak',\n async parseInput(\n raw: RawCommandInput,\n evaluator: ExpressionEvaluator,\n context: ExecutionContext\n ): Promise<SpeakCommandInput> {\n if (!raw.args?.length) {\n throw new Error('speak command requires a text argument');\n }\n const text = await evaluator.evaluate(raw.args[0], context);\n const opts = await parseSpeakOptions(raw.args, 1, evaluator, context);\n return { text: text == null ? '' : String(text), ...opts };\n },\n async execute(input: SpeakCommandInput, context: ExecutionContext): Promise<void> {\n const synth =\n typeof globalThis !== 'undefined'\n ? (globalThis as unknown as { speechSynthesis?: SpeechSynthesis }).speechSynthesis\n : undefined;\n if (!synth || typeof SpeechSynthesisUtterance === 'undefined') {\n // No Web Speech API available — no-op. Downstream consumers can detect\n // this by observing that `context.result` is still set (see below).\n context.result = false;\n return;\n }\n const utter = new SpeechSynthesisUtterance(input.text);\n if (input.rate != null) utter.rate = input.rate;\n if (input.pitch != null) utter.pitch = input.pitch;\n if (input.volume != null) utter.volume = input.volume;\n if (input.voice != null) {\n const voices = synth.getVoices?.() ?? [];\n const match = voices.find(v => v.name === input.voice);\n if (match) utter.voice = match;\n }\n synth.speak(utter);\n context.result = true;\n },\n validate(input: unknown): boolean {\n return !!input && typeof input === 'object' && typeof (input as any).text === 'string';\n },\n};\n\n// ---------------------------------------------------------------------------\n// ask\n// ---------------------------------------------------------------------------\n\nexport interface AskCommandInput {\n prompt?: string;\n defaultValue?: string;\n}\n\nexport const askCommand = {\n name: 'ask',\n async parseInput(\n raw: RawCommandInput,\n evaluator: ExpressionEvaluator,\n context: ExecutionContext\n ): Promise<AskCommandInput> {\n const out: AskCommandInput = {};\n if (raw.args?.length) {\n const promptValue = await evaluator.evaluate(raw.args[0], context);\n if (promptValue != null) out.prompt = String(promptValue);\n }\n // Optional `with default \"X\"` form\n for (let i = 1; i < (raw.args?.length ?? 0) - 1; i++) {\n const tok = raw.args[i];\n const next = raw.args[i + 1];\n if (tok?.type === 'identifier' && (tok.name as string)?.toLowerCase() === 'default' && next) {\n const defaultVal = await evaluator.evaluate(next, context);\n if (defaultVal != null) out.defaultValue = String(defaultVal);\n }\n }\n return out;\n },\n async execute(input: AskCommandInput, context: ExecutionContext): Promise<unknown> {\n const win =\n typeof globalThis !== 'undefined'\n ? (globalThis as unknown as { prompt?: (p?: string, d?: string) => string | null })\n : undefined;\n if (!win || typeof win.prompt !== 'function') {\n // No prompt available (e.g. Node headless) — no-op; leave result unset.\n return null;\n }\n const answer = win.prompt(input.prompt ?? '', input.defaultValue ?? '');\n context.result = answer;\n context.it = answer;\n return answer;\n },\n validate(input: unknown): boolean {\n return typeof input === 'object' && input !== null;\n },\n};\n\n// ---------------------------------------------------------------------------\n// answer\n// ---------------------------------------------------------------------------\n\nexport interface AnswerCommandInput {\n value: unknown;\n}\n\nexport const answerCommand = {\n name: 'answer',\n async parseInput(\n raw: RawCommandInput,\n evaluator: ExpressionEvaluator,\n context: ExecutionContext\n ): Promise<AnswerCommandInput> {\n if (!raw.args?.length) {\n throw new Error('answer command requires a value (e.g. `answer with \"text\"`)');\n }\n // Skip a leading `with` keyword if present: `answer with \"text\"`.\n let valueIndex = 0;\n const first = raw.args[0];\n if (first?.type === 'identifier' && (first.name as string)?.toLowerCase() === 'with') {\n valueIndex = 1;\n }\n if (valueIndex >= raw.args.length) {\n throw new Error('answer command requires a value after `with`');\n }\n const value = await evaluator.evaluate(raw.args[valueIndex], context);\n return { value };\n },\n async execute(input: AnswerCommandInput, context: ExecutionContext): Promise<unknown> {\n context.result = input.value;\n context.it = input.value;\n return input.value;\n },\n validate(input: unknown): boolean {\n return typeof input === 'object' && input !== null && 'value' in (input as object);\n },\n};\n","/**\n * @hyperfixi/speech — Web Speech API + prompt() plugin for hyperfixi.\n *\n * Adds three commands from upstream _hyperscript 0.9.90:\n *\n * speak \"text\" [with rate N] [with pitch N] [with voice \"Name\"]\n * ask \"prompt\" → context.result = user's answer\n * answer with \"text\" → context.result = text (scripted)\n *\n * Installation:\n *\n * ```ts\n * import { createRuntime, installPlugin } from '@hyperfixi/core';\n * import { speechPlugin } from '@hyperfixi/speech';\n *\n * const runtime = createRuntime();\n * installPlugin(runtime, speechPlugin);\n * ```\n */\n\nimport type { HyperfixiPlugin, HyperfixiPluginContext } from '@hyperfixi/core';\nimport { speakCommand, askCommand, answerCommand } from './commands';\n\nexport { speakCommand, askCommand, answerCommand };\nexport type { SpeakCommandInput, AskCommandInput, AnswerCommandInput } from './commands';\n\n/**\n * Plugin object for one-shot installation. Registers three command keywords\n * with the parser and three command implementations with the runtime.\n *\n * Idempotent: re-installing in the same process is a no-op for the parser\n * (keywords are Set-based) and replaces the existing command implementations\n * with identical ones in the runtime registry.\n */\nexport const speechPlugin: HyperfixiPlugin = {\n name: '@hyperfixi/speech',\n install({ commandRegistry, parserExtensions }: HyperfixiPluginContext) {\n parserExtensions.registerCommand('speak');\n parserExtensions.registerCommand('ask');\n parserExtensions.registerCommand('answer');\n commandRegistry.register(speakCommand as never);\n commandRegistry.register(askCommand as never);\n commandRegistry.register(answerCommand as never);\n },\n};\n\nexport default speechPlugin;\n"],"mappings":";AA0DA,eAAe,kBACb,MACA,YACA,WACA,SAC0C;AAC1C,QAAM,MAAuC,CAAC;AAC9C,MAAI,IAAI;AACR,SAAO,IAAI,KAAK,QAAQ;AACtB,UAAM,MAAM,KAAK,CAAC;AAClB,QAAI,KAAK,SAAS,gBAAiB,IAAI,MAAiB,YAAY,MAAM,QAAQ;AAChF;AACA;AAAA,IACF;AACA,QAAI,KAAK,SAAS,gBAAgB,IAAI,IAAI,KAAK,QAAQ;AACrD,YAAM,MAAO,IAAI,KAAgB,YAAY;AAC7C,YAAM,QAAQ,MAAM,UAAU,SAAS,KAAK,IAAI,CAAC,GAAG,OAAO;AAC3D,UAAI,QAAQ,UAAU,OAAO,UAAU,SAAU,KAAI,OAAO;AAAA,eACnD,QAAQ,WAAW,OAAO,UAAU,SAAU,KAAI,QAAQ;AAAA,eAC1D,QAAQ,YAAY,OAAO,UAAU,SAAU,KAAI,SAAS;AAAA,eAC5D,QAAQ,WAAW,OAAO,UAAU,SAAU,KAAI,QAAQ;AACnE,WAAK;AACL;AAAA,IACF;AACA;AAAA,EACF;AACA,SAAO;AACT;AAEO,IAAM,eAAe;AAAA,EAC1B,MAAM;AAAA,EACN,MAAM,WACJ,KACA,WACA,SAC4B;AAC5B,QAAI,CAAC,IAAI,MAAM,QAAQ;AACrB,YAAM,IAAI,MAAM,wCAAwC;AAAA,IAC1D;AACA,UAAM,OAAO,MAAM,UAAU,SAAS,IAAI,KAAK,CAAC,GAAG,OAAO;AAC1D,UAAM,OAAO,MAAM,kBAAkB,IAAI,MAAM,GAAG,WAAW,OAAO;AACpE,WAAO,EAAE,MAAM,QAAQ,OAAO,KAAK,OAAO,IAAI,GAAG,GAAG,KAAK;AAAA,EAC3D;AAAA,EACA,MAAM,QAAQ,OAA0B,SAA0C;AAChF,UAAM,QACJ,OAAO,eAAe,cACjB,WAAgE,kBACjE;AACN,QAAI,CAAC,SAAS,OAAO,6BAA6B,aAAa;AAG7D,cAAQ,SAAS;AACjB;AAAA,IACF;AACA,UAAM,QAAQ,IAAI,yBAAyB,MAAM,IAAI;AACrD,QAAI,MAAM,QAAQ,KAAM,OAAM,OAAO,MAAM;AAC3C,QAAI,MAAM,SAAS,KAAM,OAAM,QAAQ,MAAM;AAC7C,QAAI,MAAM,UAAU,KAAM,OAAM,SAAS,MAAM;AAC/C,QAAI,MAAM,SAAS,MAAM;AACvB,YAAM,SAAS,MAAM,YAAY,KAAK,CAAC;AACvC,YAAM,QAAQ,OAAO,KAAK,OAAK,EAAE,SAAS,MAAM,KAAK;AACrD,UAAI,MAAO,OAAM,QAAQ;AAAA,IAC3B;AACA,UAAM,MAAM,KAAK;AACjB,YAAQ,SAAS;AAAA,EACnB;AAAA,EACA,SAAS,OAAyB;AAChC,WAAO,CAAC,CAAC,SAAS,OAAO,UAAU,YAAY,OAAQ,MAAc,SAAS;AAAA,EAChF;AACF;AAWO,IAAM,aAAa;AAAA,EACxB,MAAM;AAAA,EACN,MAAM,WACJ,KACA,WACA,SAC0B;AAC1B,UAAM,MAAuB,CAAC;AAC9B,QAAI,IAAI,MAAM,QAAQ;AACpB,YAAM,cAAc,MAAM,UAAU,SAAS,IAAI,KAAK,CAAC,GAAG,OAAO;AACjE,UAAI,eAAe,KAAM,KAAI,SAAS,OAAO,WAAW;AAAA,IAC1D;AAEA,aAAS,IAAI,GAAG,KAAK,IAAI,MAAM,UAAU,KAAK,GAAG,KAAK;AACpD,YAAM,MAAM,IAAI,KAAK,CAAC;AACtB,YAAM,OAAO,IAAI,KAAK,IAAI,CAAC;AAC3B,UAAI,KAAK,SAAS,gBAAiB,IAAI,MAAiB,YAAY,MAAM,aAAa,MAAM;AAC3F,cAAM,aAAa,MAAM,UAAU,SAAS,MAAM,OAAO;AACzD,YAAI,cAAc,KAAM,KAAI,eAAe,OAAO,UAAU;AAAA,MAC9D;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EACA,MAAM,QAAQ,OAAwB,SAA6C;AACjF,UAAM,MACJ,OAAO,eAAe,cACjB,aACD;AACN,QAAI,CAAC,OAAO,OAAO,IAAI,WAAW,YAAY;AAE5C,aAAO;AAAA,IACT;AACA,UAAM,SAAS,IAAI,OAAO,MAAM,UAAU,IAAI,MAAM,gBAAgB,EAAE;AACtE,YAAQ,SAAS;AACjB,YAAQ,KAAK;AACb,WAAO;AAAA,EACT;AAAA,EACA,SAAS,OAAyB;AAChC,WAAO,OAAO,UAAU,YAAY,UAAU;AAAA,EAChD;AACF;AAUO,IAAM,gBAAgB;AAAA,EAC3B,MAAM;AAAA,EACN,MAAM,WACJ,KACA,WACA,SAC6B;AAC7B,QAAI,CAAC,IAAI,MAAM,QAAQ;AACrB,YAAM,IAAI,MAAM,6DAA6D;AAAA,IAC/E;AAEA,QAAI,aAAa;AACjB,UAAM,QAAQ,IAAI,KAAK,CAAC;AACxB,QAAI,OAAO,SAAS,gBAAiB,MAAM,MAAiB,YAAY,MAAM,QAAQ;AACpF,mBAAa;AAAA,IACf;AACA,QAAI,cAAc,IAAI,KAAK,QAAQ;AACjC,YAAM,IAAI,MAAM,8CAA8C;AAAA,IAChE;AACA,UAAM,QAAQ,MAAM,UAAU,SAAS,IAAI,KAAK,UAAU,GAAG,OAAO;AACpE,WAAO,EAAE,MAAM;AAAA,EACjB;AAAA,EACA,MAAM,QAAQ,OAA2B,SAA6C;AACpF,YAAQ,SAAS,MAAM;AACvB,YAAQ,KAAK,MAAM;AACnB,WAAO,MAAM;AAAA,EACf;AAAA,EACA,SAAS,OAAyB;AAChC,WAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,WAAY;AAAA,EACpE;AACF;;;ACxLO,IAAM,eAAgC;AAAA,EAC3C,MAAM;AAAA,EACN,QAAQ,EAAE,iBAAiB,iBAAiB,GAA2B;AACrE,qBAAiB,gBAAgB,OAAO;AACxC,qBAAiB,gBAAgB,KAAK;AACtC,qBAAiB,gBAAgB,QAAQ;AACzC,oBAAgB,SAAS,YAAqB;AAC9C,oBAAgB,SAAS,UAAmB;AAC5C,oBAAgB,SAAS,aAAsB;AAAA,EACjD;AACF;AAEA,IAAO,gBAAQ;","names":[]}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@hyperfixi/speech",
3
+ "version": "2.4.0",
4
+ "description": "Speech Synthesis and prompt() plugin for hyperfixi — adds `speak`, `ask`, and `answer` commands (upstream _hyperscript 0.9.90).",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "dist/index.cjs",
8
+ "module": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsup && npm run build:types",
19
+ "build:types": "tsc --emitDeclarationOnly --outDir dist --noEmit false",
20
+ "test": "vitest",
21
+ "test:run": "vitest run",
22
+ "test:check": "vitest run --reporter=dot 2>&1 | tail -5",
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "dependencies": {
26
+ "@hyperfixi/core": "*"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.0.0",
30
+ "happy-dom": "^20.9.0",
31
+ "tsup": "^8.0.0",
32
+ "typescript": "^5.0.0",
33
+ "vitest": "^4.1.5"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src",
38
+ "LICENSE",
39
+ "README.md"
40
+ ],
41
+ "keywords": [
42
+ "hyperfixi",
43
+ "hyperscript",
44
+ "speech-synthesis",
45
+ "prompt",
46
+ "plugin",
47
+ "_hyperscript",
48
+ "v0.9.90"
49
+ ],
50
+ "author": "LokaScript Contributors",
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/codetalcott/hyperfixi.git",
55
+ "directory": "packages/speech"
56
+ },
57
+ "engines": {
58
+ "node": ">=18.0.0"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Speech plugin commands — upstream _hyperscript 0.9.90.
3
+ *
4
+ * speak "text" SpeechSynthesis.speak(new Utterance(text))
5
+ * speak "text" with rate 1.5 options on the utterance
6
+ * speak "text" with pitch 0.8 with voice "Google UK English Female"
7
+ *
8
+ * ask "Your name?" window.prompt(text) → context.result
9
+ * ask window.prompt()
10
+ *
11
+ * answer with "programmatic" context.result = text (no UI; useful for
12
+ * scripted flows or test mocks)
13
+ *
14
+ * Each command returns a plain object with `{ name, parseInput, execute,
15
+ * validate }` — the minimum shape accepted by `CommandRegistryV2.register()`.
16
+ * The commands deliberately avoid importing from `@hyperfixi/core/commands`
17
+ * internals so the plugin stays a thin peer of the core package.
18
+ */
19
+
20
+ // Lightweight type stubs — the plugin consumes raw shapes rather than import
21
+ // tightly from core internals, keeping the package self-contained.
22
+ interface ASTNode {
23
+ type: string;
24
+ name?: string;
25
+ value?: unknown;
26
+ [k: string]: unknown;
27
+ }
28
+ interface ExpressionEvaluator {
29
+ evaluate(node: ASTNode, context: unknown): Promise<unknown>;
30
+ }
31
+ interface ExecutionContext {
32
+ result?: unknown;
33
+ it?: unknown;
34
+ [k: string]: unknown;
35
+ }
36
+
37
+ interface RawCommandInput {
38
+ args: ASTNode[];
39
+ modifiers: Record<string, ASTNode>;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // speak
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export interface SpeakCommandInput {
47
+ text: string;
48
+ rate?: number;
49
+ pitch?: number;
50
+ voice?: string;
51
+ volume?: number;
52
+ }
53
+
54
+ /**
55
+ * Consume a leading `with` identifier followed by option pairs like
56
+ * `rate 1.5`, `pitch 0.8`, `voice "Google UK English Female"`. Accepts
57
+ * multiple `with <key> <value>` pairs.
58
+ */
59
+ async function parseSpeakOptions(
60
+ args: ASTNode[],
61
+ startIndex: number,
62
+ evaluator: ExpressionEvaluator,
63
+ context: unknown
64
+ ): Promise<Omit<SpeakCommandInput, 'text'>> {
65
+ const out: Omit<SpeakCommandInput, 'text'> = {};
66
+ let i = startIndex;
67
+ while (i < args.length) {
68
+ const tok = args[i];
69
+ if (tok?.type === 'identifier' && (tok.name as string)?.toLowerCase() === 'with') {
70
+ i++;
71
+ continue;
72
+ }
73
+ if (tok?.type === 'identifier' && i + 1 < args.length) {
74
+ const key = (tok.name as string).toLowerCase();
75
+ const value = await evaluator.evaluate(args[i + 1], context);
76
+ if (key === 'rate' && typeof value === 'number') out.rate = value;
77
+ else if (key === 'pitch' && typeof value === 'number') out.pitch = value;
78
+ else if (key === 'volume' && typeof value === 'number') out.volume = value;
79
+ else if (key === 'voice' && typeof value === 'string') out.voice = value;
80
+ i += 2;
81
+ continue;
82
+ }
83
+ i++;
84
+ }
85
+ return out;
86
+ }
87
+
88
+ export const speakCommand = {
89
+ name: 'speak',
90
+ async parseInput(
91
+ raw: RawCommandInput,
92
+ evaluator: ExpressionEvaluator,
93
+ context: ExecutionContext
94
+ ): Promise<SpeakCommandInput> {
95
+ if (!raw.args?.length) {
96
+ throw new Error('speak command requires a text argument');
97
+ }
98
+ const text = await evaluator.evaluate(raw.args[0], context);
99
+ const opts = await parseSpeakOptions(raw.args, 1, evaluator, context);
100
+ return { text: text == null ? '' : String(text), ...opts };
101
+ },
102
+ async execute(input: SpeakCommandInput, context: ExecutionContext): Promise<void> {
103
+ const synth =
104
+ typeof globalThis !== 'undefined'
105
+ ? (globalThis as unknown as { speechSynthesis?: SpeechSynthesis }).speechSynthesis
106
+ : undefined;
107
+ if (!synth || typeof SpeechSynthesisUtterance === 'undefined') {
108
+ // No Web Speech API available — no-op. Downstream consumers can detect
109
+ // this by observing that `context.result` is still set (see below).
110
+ context.result = false;
111
+ return;
112
+ }
113
+ const utter = new SpeechSynthesisUtterance(input.text);
114
+ if (input.rate != null) utter.rate = input.rate;
115
+ if (input.pitch != null) utter.pitch = input.pitch;
116
+ if (input.volume != null) utter.volume = input.volume;
117
+ if (input.voice != null) {
118
+ const voices = synth.getVoices?.() ?? [];
119
+ const match = voices.find(v => v.name === input.voice);
120
+ if (match) utter.voice = match;
121
+ }
122
+ synth.speak(utter);
123
+ context.result = true;
124
+ },
125
+ validate(input: unknown): boolean {
126
+ return !!input && typeof input === 'object' && typeof (input as any).text === 'string';
127
+ },
128
+ };
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // ask
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export interface AskCommandInput {
135
+ prompt?: string;
136
+ defaultValue?: string;
137
+ }
138
+
139
+ export const askCommand = {
140
+ name: 'ask',
141
+ async parseInput(
142
+ raw: RawCommandInput,
143
+ evaluator: ExpressionEvaluator,
144
+ context: ExecutionContext
145
+ ): Promise<AskCommandInput> {
146
+ const out: AskCommandInput = {};
147
+ if (raw.args?.length) {
148
+ const promptValue = await evaluator.evaluate(raw.args[0], context);
149
+ if (promptValue != null) out.prompt = String(promptValue);
150
+ }
151
+ // Optional `with default "X"` form
152
+ for (let i = 1; i < (raw.args?.length ?? 0) - 1; i++) {
153
+ const tok = raw.args[i];
154
+ const next = raw.args[i + 1];
155
+ if (tok?.type === 'identifier' && (tok.name as string)?.toLowerCase() === 'default' && next) {
156
+ const defaultVal = await evaluator.evaluate(next, context);
157
+ if (defaultVal != null) out.defaultValue = String(defaultVal);
158
+ }
159
+ }
160
+ return out;
161
+ },
162
+ async execute(input: AskCommandInput, context: ExecutionContext): Promise<unknown> {
163
+ const win =
164
+ typeof globalThis !== 'undefined'
165
+ ? (globalThis as unknown as { prompt?: (p?: string, d?: string) => string | null })
166
+ : undefined;
167
+ if (!win || typeof win.prompt !== 'function') {
168
+ // No prompt available (e.g. Node headless) — no-op; leave result unset.
169
+ return null;
170
+ }
171
+ const answer = win.prompt(input.prompt ?? '', input.defaultValue ?? '');
172
+ context.result = answer;
173
+ context.it = answer;
174
+ return answer;
175
+ },
176
+ validate(input: unknown): boolean {
177
+ return typeof input === 'object' && input !== null;
178
+ },
179
+ };
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // answer
183
+ // ---------------------------------------------------------------------------
184
+
185
+ export interface AnswerCommandInput {
186
+ value: unknown;
187
+ }
188
+
189
+ export const answerCommand = {
190
+ name: 'answer',
191
+ async parseInput(
192
+ raw: RawCommandInput,
193
+ evaluator: ExpressionEvaluator,
194
+ context: ExecutionContext
195
+ ): Promise<AnswerCommandInput> {
196
+ if (!raw.args?.length) {
197
+ throw new Error('answer command requires a value (e.g. `answer with "text"`)');
198
+ }
199
+ // Skip a leading `with` keyword if present: `answer with "text"`.
200
+ let valueIndex = 0;
201
+ const first = raw.args[0];
202
+ if (first?.type === 'identifier' && (first.name as string)?.toLowerCase() === 'with') {
203
+ valueIndex = 1;
204
+ }
205
+ if (valueIndex >= raw.args.length) {
206
+ throw new Error('answer command requires a value after `with`');
207
+ }
208
+ const value = await evaluator.evaluate(raw.args[valueIndex], context);
209
+ return { value };
210
+ },
211
+ async execute(input: AnswerCommandInput, context: ExecutionContext): Promise<unknown> {
212
+ context.result = input.value;
213
+ context.it = input.value;
214
+ return input.value;
215
+ },
216
+ validate(input: unknown): boolean {
217
+ return typeof input === 'object' && input !== null && 'value' in (input as object);
218
+ },
219
+ };
@@ -0,0 +1,273 @@
1
+ /**
2
+ * End-to-end tests for @hyperfixi/speech.
3
+ *
4
+ * Validates:
5
+ * 1. The plugin installs cleanly via installPlugin()
6
+ * 2. The parser accepts `speak`, `ask`, `answer` at command position
7
+ * 3. Runtime execution dispatches to the right commands
8
+ * 4. speak() uses the Web Speech API (with a mock)
9
+ * 5. ask() reads from window.prompt (with a mock)
10
+ * 6. answer sets context.result / context.it
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
14
+ import { speechPlugin, speakCommand, askCommand, answerCommand } from './index';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Shared fixtures
18
+ // ---------------------------------------------------------------------------
19
+
20
+ interface MockEvaluator {
21
+ evaluate: (node: any, ctx: any) => Promise<unknown>;
22
+ }
23
+
24
+ /** Minimal evaluator that returns the literal value or identifier name. */
25
+ const mockEvaluator: MockEvaluator = {
26
+ async evaluate(node: any) {
27
+ if (!node) return undefined;
28
+ if (node.type === 'literal') return node.value;
29
+ if (node.type === 'identifier') return node.name;
30
+ return node.value ?? node.name;
31
+ },
32
+ };
33
+
34
+ function literal<T>(value: T) {
35
+ return { type: 'literal', value };
36
+ }
37
+
38
+ function identifier(name: string) {
39
+ return { type: 'identifier', name };
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Unit-level tests per command
44
+ // ---------------------------------------------------------------------------
45
+
46
+ describe('speakCommand', () => {
47
+ let utterances: Array<{
48
+ text: string;
49
+ rate?: number;
50
+ pitch?: number;
51
+ voice?: SpeechSynthesisVoice | null;
52
+ volume?: number;
53
+ }>;
54
+ let originalSynth: unknown;
55
+ let originalUtterance: unknown;
56
+
57
+ beforeEach(() => {
58
+ utterances = [];
59
+ originalSynth = (globalThis as any).speechSynthesis;
60
+ originalUtterance = (globalThis as any).SpeechSynthesisUtterance;
61
+
62
+ (globalThis as any).SpeechSynthesisUtterance = class MockUtterance {
63
+ text: string;
64
+ rate = 1;
65
+ pitch = 1;
66
+ volume = 1;
67
+ voice: SpeechSynthesisVoice | null = null;
68
+ constructor(text: string) {
69
+ this.text = text;
70
+ }
71
+ };
72
+ (globalThis as any).speechSynthesis = {
73
+ speak: vi.fn((utter: any) => {
74
+ utterances.push({
75
+ text: utter.text,
76
+ rate: utter.rate,
77
+ pitch: utter.pitch,
78
+ voice: utter.voice,
79
+ volume: utter.volume,
80
+ });
81
+ }),
82
+ getVoices: () => [
83
+ { name: 'Google UK English Female', lang: 'en-GB' } as SpeechSynthesisVoice,
84
+ ],
85
+ };
86
+ });
87
+
88
+ afterEach(() => {
89
+ (globalThis as any).speechSynthesis = originalSynth;
90
+ (globalThis as any).SpeechSynthesisUtterance = originalUtterance;
91
+ });
92
+
93
+ it('speaks the text argument via speechSynthesis.speak', async () => {
94
+ const input = await speakCommand.parseInput(
95
+ { args: [literal('hello')], modifiers: {} },
96
+ mockEvaluator,
97
+ {}
98
+ );
99
+ expect(input).toEqual({ text: 'hello' });
100
+ const ctx: Record<string, unknown> = {};
101
+ await speakCommand.execute(input, ctx);
102
+ expect(utterances).toEqual([{ text: 'hello', rate: 1, pitch: 1, voice: null, volume: 1 }]);
103
+ expect(ctx.result).toBe(true);
104
+ });
105
+
106
+ it('applies `with rate`, `with pitch`, `with volume` options', async () => {
107
+ const input = await speakCommand.parseInput(
108
+ {
109
+ args: [
110
+ literal('hi'),
111
+ identifier('with'),
112
+ identifier('rate'),
113
+ literal(1.5),
114
+ identifier('with'),
115
+ identifier('pitch'),
116
+ literal(0.8),
117
+ ],
118
+ modifiers: {},
119
+ },
120
+ mockEvaluator,
121
+ {}
122
+ );
123
+ expect(input).toEqual({ text: 'hi', rate: 1.5, pitch: 0.8 });
124
+ await speakCommand.execute(input, {});
125
+ expect(utterances[0].rate).toBe(1.5);
126
+ expect(utterances[0].pitch).toBe(0.8);
127
+ });
128
+
129
+ it('matches `with voice "<name>"` against available voices', async () => {
130
+ const input = await speakCommand.parseInput(
131
+ {
132
+ args: [
133
+ literal('hi'),
134
+ identifier('with'),
135
+ identifier('voice'),
136
+ literal('Google UK English Female'),
137
+ ],
138
+ modifiers: {},
139
+ },
140
+ mockEvaluator,
141
+ {}
142
+ );
143
+ await speakCommand.execute(input, {});
144
+ expect(utterances[0].voice).toMatchObject({ name: 'Google UK English Female' });
145
+ });
146
+
147
+ it('no-ops when SpeechSynthesis is unavailable (sets result=false)', async () => {
148
+ (globalThis as any).speechSynthesis = undefined;
149
+ const ctx: Record<string, unknown> = {};
150
+ await speakCommand.execute({ text: 'hi' }, ctx);
151
+ expect(ctx.result).toBe(false);
152
+ });
153
+
154
+ it('throws when text argument is missing', async () => {
155
+ await expect(
156
+ speakCommand.parseInput({ args: [], modifiers: {} }, mockEvaluator, {})
157
+ ).rejects.toThrow(/requires a text argument/);
158
+ });
159
+ });
160
+
161
+ describe('askCommand', () => {
162
+ let originalPrompt: unknown;
163
+
164
+ beforeEach(() => {
165
+ originalPrompt = (globalThis as any).prompt;
166
+ });
167
+ afterEach(() => {
168
+ (globalThis as any).prompt = originalPrompt;
169
+ });
170
+
171
+ it('reads from window.prompt and stores result in context.result and context.it', async () => {
172
+ (globalThis as any).prompt = vi.fn(() => 'Alice');
173
+ const input = await askCommand.parseInput(
174
+ { args: [literal('Your name?')], modifiers: {} },
175
+ mockEvaluator,
176
+ {}
177
+ );
178
+ expect(input.prompt).toBe('Your name?');
179
+ const ctx: Record<string, unknown> = {};
180
+ const out = await askCommand.execute(input, ctx);
181
+ expect(out).toBe('Alice');
182
+ expect(ctx.result).toBe('Alice');
183
+ expect(ctx.it).toBe('Alice');
184
+ });
185
+
186
+ it('returns null when prompt is unavailable', async () => {
187
+ (globalThis as any).prompt = undefined;
188
+ const ctx: Record<string, unknown> = {};
189
+ const out = await askCommand.execute({}, ctx);
190
+ expect(out).toBeNull();
191
+ });
192
+
193
+ it('honors `with default "value"` syntax', async () => {
194
+ (globalThis as any).prompt = vi.fn((_p: string, d: string) => d);
195
+ const input = await askCommand.parseInput(
196
+ {
197
+ args: [literal('Your name?'), identifier('with'), identifier('default'), literal('Guest')],
198
+ modifiers: {},
199
+ },
200
+ mockEvaluator,
201
+ {}
202
+ );
203
+ expect(input.defaultValue).toBe('Guest');
204
+ const ctx: Record<string, unknown> = {};
205
+ await askCommand.execute(input, ctx);
206
+ expect(ctx.result).toBe('Guest');
207
+ });
208
+ });
209
+
210
+ describe('answerCommand', () => {
211
+ it('sets context.result and context.it to the given value', async () => {
212
+ const input = await answerCommand.parseInput(
213
+ { args: [identifier('with'), literal('programmatic')], modifiers: {} },
214
+ mockEvaluator,
215
+ {}
216
+ );
217
+ expect(input).toEqual({ value: 'programmatic' });
218
+ const ctx: Record<string, unknown> = {};
219
+ await answerCommand.execute(input, ctx);
220
+ expect(ctx.result).toBe('programmatic');
221
+ expect(ctx.it).toBe('programmatic');
222
+ });
223
+
224
+ it('accepts bare-value form without leading `with`', async () => {
225
+ const input = await answerCommand.parseInput(
226
+ { args: [literal('bare')], modifiers: {} },
227
+ mockEvaluator,
228
+ {}
229
+ );
230
+ expect(input).toEqual({ value: 'bare' });
231
+ });
232
+
233
+ it('throws without any value', async () => {
234
+ await expect(
235
+ answerCommand.parseInput({ args: [], modifiers: {} }, mockEvaluator, {})
236
+ ).rejects.toThrow(/requires a value/);
237
+ });
238
+
239
+ it('validate() accepts {value: ...} and rejects other shapes', () => {
240
+ expect(answerCommand.validate({ value: 'x' })).toBe(true);
241
+ expect(answerCommand.validate(null)).toBe(false);
242
+ expect(answerCommand.validate({})).toBe(false);
243
+ });
244
+ });
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Plugin shape
248
+ // ---------------------------------------------------------------------------
249
+
250
+ describe('speechPlugin', () => {
251
+ it('has a valid HyperfixiPlugin shape', () => {
252
+ expect(speechPlugin.name).toBe('@hyperfixi/speech');
253
+ expect(typeof speechPlugin.install).toBe('function');
254
+ });
255
+
256
+ it('install() wires three commands into both registries', () => {
257
+ const commandCalls: Array<{ name: string }> = [];
258
+ const keywordCalls: string[] = [];
259
+ const ctx = {
260
+ commandRegistry: {
261
+ register: (cmd: any) => commandCalls.push({ name: cmd.name }),
262
+ },
263
+ parserExtensions: {
264
+ registerCommand: (name: string) => keywordCalls.push(name),
265
+ },
266
+ } as any;
267
+
268
+ speechPlugin.install(ctx);
269
+
270
+ expect(keywordCalls.sort()).toEqual(['answer', 'ask', 'speak']);
271
+ expect(commandCalls.map(c => c.name).sort()).toEqual(['answer', 'ask', 'speak']);
272
+ });
273
+ });
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @hyperfixi/speech — Web Speech API + prompt() plugin for hyperfixi.
3
+ *
4
+ * Adds three commands from upstream _hyperscript 0.9.90:
5
+ *
6
+ * speak "text" [with rate N] [with pitch N] [with voice "Name"]
7
+ * ask "prompt" → context.result = user's answer
8
+ * answer with "text" → context.result = text (scripted)
9
+ *
10
+ * Installation:
11
+ *
12
+ * ```ts
13
+ * import { createRuntime, installPlugin } from '@hyperfixi/core';
14
+ * import { speechPlugin } from '@hyperfixi/speech';
15
+ *
16
+ * const runtime = createRuntime();
17
+ * installPlugin(runtime, speechPlugin);
18
+ * ```
19
+ */
20
+
21
+ import type { HyperfixiPlugin, HyperfixiPluginContext } from '@hyperfixi/core';
22
+ import { speakCommand, askCommand, answerCommand } from './commands';
23
+
24
+ export { speakCommand, askCommand, answerCommand };
25
+ export type { SpeakCommandInput, AskCommandInput, AnswerCommandInput } from './commands';
26
+
27
+ /**
28
+ * Plugin object for one-shot installation. Registers three command keywords
29
+ * with the parser and three command implementations with the runtime.
30
+ *
31
+ * Idempotent: re-installing in the same process is a no-op for the parser
32
+ * (keywords are Set-based) and replaces the existing command implementations
33
+ * with identical ones in the runtime registry.
34
+ */
35
+ export const speechPlugin: HyperfixiPlugin = {
36
+ name: '@hyperfixi/speech',
37
+ install({ commandRegistry, parserExtensions }: HyperfixiPluginContext) {
38
+ parserExtensions.registerCommand('speak');
39
+ parserExtensions.registerCommand('ask');
40
+ parserExtensions.registerCommand('answer');
41
+ commandRegistry.register(speakCommand as never);
42
+ commandRegistry.register(askCommand as never);
43
+ commandRegistry.register(answerCommand as never);
44
+ },
45
+ };
46
+
47
+ export default speechPlugin;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Real end-to-end integration test: install speechPlugin into an actual
3
+ * hyperfixi Runtime, parse hyperscript source with the plugin's commands,
4
+ * and verify execution flows through correctly.
5
+ *
6
+ * This is the "round-trip" test the Phase 5 plan called for — proof that
7
+ * an external plugin package can contribute commands through the public
8
+ * plugin infrastructure without touching core internals.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
12
+ import { Runtime, installPlugin, getParserExtensionRegistry, parse } from '@hyperfixi/core';
13
+ import { speechPlugin } from './index';
14
+
15
+ describe('@hyperfixi/speech end-to-end integration', () => {
16
+ const registry = getParserExtensionRegistry();
17
+ let baseline: ReturnType<typeof registry.snapshot>;
18
+ let runtime: Runtime;
19
+ let originalSynth: unknown;
20
+ let originalUtterance: unknown;
21
+ let originalPrompt: unknown;
22
+ let spoken: string[];
23
+
24
+ beforeEach(() => {
25
+ baseline = registry.snapshot();
26
+ spoken = [];
27
+
28
+ originalSynth = (globalThis as any).speechSynthesis;
29
+ originalUtterance = (globalThis as any).SpeechSynthesisUtterance;
30
+ originalPrompt = (globalThis as any).prompt;
31
+
32
+ (globalThis as any).SpeechSynthesisUtterance = class MockUtterance {
33
+ text: string;
34
+ rate = 1;
35
+ pitch = 1;
36
+ volume = 1;
37
+ voice: SpeechSynthesisVoice | null = null;
38
+ constructor(text: string) {
39
+ this.text = text;
40
+ }
41
+ };
42
+ (globalThis as any).speechSynthesis = {
43
+ speak: (utter: any) => {
44
+ spoken.push(utter.text);
45
+ },
46
+ getVoices: () => [],
47
+ };
48
+ (globalThis as any).prompt = vi.fn(() => 'user-typed');
49
+
50
+ runtime = new Runtime();
51
+ installPlugin(runtime, speechPlugin);
52
+ });
53
+
54
+ afterEach(() => {
55
+ registry.restore(baseline);
56
+ (globalThis as any).speechSynthesis = originalSynth;
57
+ (globalThis as any).SpeechSynthesisUtterance = originalUtterance;
58
+ (globalThis as any).prompt = originalPrompt;
59
+ });
60
+
61
+ it('registers `speak`/`ask`/`answer` as command keywords', () => {
62
+ expect(registry.hasCommand('speak')).toBe(true);
63
+ expect(registry.hasCommand('ask')).toBe(true);
64
+ expect(registry.hasCommand('answer')).toBe(true);
65
+ });
66
+
67
+ it('executes `speak "hello"` through the full parse→runtime pipeline', async () => {
68
+ // Parser acceptance: importing `parse` from the core entry is the most
69
+ // convenient way to go from source to AST in the integration layer.
70
+ const result = parse('speak "hello world"');
71
+ expect(result.success).toBe(true);
72
+
73
+ const el = document.createElement('div');
74
+ const ctx = {
75
+ me: el,
76
+ it: null,
77
+ you: null,
78
+ result: null,
79
+ locals: new Map(),
80
+ globals: new Map(),
81
+ variables: new Map(),
82
+ events: new Map(),
83
+ } as any;
84
+
85
+ await runtime.execute(result.node!, ctx);
86
+ expect(spoken).toEqual(['hello world']);
87
+ });
88
+
89
+ it('executes `answer with "x"` and sets context.result', async () => {
90
+ const result = parse('answer with "programmatic"');
91
+ expect(result.success).toBe(true);
92
+
93
+ const el = document.createElement('div');
94
+ const ctx = {
95
+ me: el,
96
+ it: null,
97
+ you: null,
98
+ result: null,
99
+ locals: new Map(),
100
+ globals: new Map(),
101
+ variables: new Map(),
102
+ events: new Map(),
103
+ } as any;
104
+
105
+ await runtime.execute(result.node!, ctx);
106
+ expect(ctx.result).toBe('programmatic');
107
+ expect(ctx.it).toBe('programmatic');
108
+ });
109
+ });