@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 +20 -0
- package/README.md +85 -0
- package/dist/commands.d.ts +69 -0
- package/dist/index.cjs +166 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +136 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/src/commands.ts +219 -0
- package/src/index.test.ts +273 -0
- package/src/index.ts +47 -0
- package/src/integration.test.ts +109 -0
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":"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","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -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":"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","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
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -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
|
+
});
|