@yaebal/morda 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ import type { Context, Plugin, User } from "@yaebal/core";
2
+ import {
3
+ type DialogContext,
4
+ type DialogControl,
5
+ type DialogDef,
6
+ type DialogsOptions,
7
+ type Button as MordaButton,
8
+ type WindowView,
9
+ dialogs,
10
+ } from "../index.js";
11
+ import { Fragment, VNODE, type VNode } from "./jsx-runtime.js";
12
+
13
+ export type { VNode } from "./jsx-runtime.js";
14
+
15
+ /** a screen is a component: a zero-arg function returning a `<Screen>` tree. */
16
+ export type ScreenComponent = () => VNode;
17
+
18
+ export interface ButtonProps {
19
+ id: string;
20
+ onClick?: () => unknown;
21
+ children?: unknown;
22
+ }
23
+
24
+ // component markers. never invoked by the runtime — matched by identity below.
25
+ export function Screen(props: { children?: unknown }): VNode {
26
+ return { [VNODE]: true, type: Screen, props: { ...props } };
27
+ }
28
+
29
+ export function ButtonRow(props: { children?: unknown }): VNode {
30
+ return { [VNODE]: true, type: ButtonRow, props: { ...props } };
31
+ }
32
+
33
+ export function Button(props: ButtonProps): VNode {
34
+ return { [VNODE]: true, type: Button, props: { ...props } };
35
+ }
36
+
37
+ const MARKERS = new Set<unknown>([Screen, ButtonRow, Button, Fragment]);
38
+
39
+ interface Frame {
40
+ slots: unknown[];
41
+ cursor: number;
42
+ effects: Array<() => unknown>;
43
+ ctx: DialogContext;
44
+ idByComponent: Map<ScreenComponent, string>;
45
+ }
46
+
47
+ let current: Frame | undefined;
48
+
49
+ function need(): Frame {
50
+ if (!current) throw new Error("morda/jsx: hooks may only be called during a screen render");
51
+ return current;
52
+ }
53
+
54
+ /** per-frame state. `setState` re-renders the screen in place and resolves when the edit lands. */
55
+ export function useState<T>(
56
+ initial: T | (() => T),
57
+ ): [T, (next: T | ((prev: T) => T)) => Promise<void>] {
58
+ const frame = need();
59
+ const i = frame.cursor++;
60
+
61
+ if (!(i in frame.slots)) {
62
+ frame.slots[i] = typeof initial === "function" ? (initial as () => T)() : initial;
63
+ }
64
+
65
+ const set = (next: T | ((prev: T) => T)): Promise<void> => {
66
+ const prev = frame.slots[i] as T;
67
+ frame.slots[i] = typeof next === "function" ? (next as (p: T) => T)(prev) : next;
68
+
69
+ // ponytail: one editMessageText per setState — no batching. Combine writes
70
+ // yourself if a handler calls setState many times in a row.
71
+ return frame.ctx.dialog.rerender();
72
+ };
73
+
74
+ return [frame.slots[i] as T, set];
75
+ }
76
+
77
+ /**
78
+ * run an effect after the screen renders. `deps` gate re-runs.
79
+ * prefer passing `deps` — the no-deps form fires on every *internal* render
80
+ * (a button press renders once to route and again to commit), not once per tap.
81
+ */
82
+ export function useEffect(fn: () => unknown, deps?: unknown[]): void {
83
+ const frame = need();
84
+ const i = frame.cursor++;
85
+ const prev = frame.slots[i] as { deps?: unknown[] } | undefined;
86
+
87
+ frame.slots[i] = { deps };
88
+
89
+ // ponytail: no cleanup support — effects are fire-and-forget (enough for bots).
90
+ if (!prev || !depsEqual(prev.deps, deps)) frame.effects.push(fn);
91
+ }
92
+
93
+ function depsEqual(a: unknown[] | undefined, b: unknown[] | undefined): boolean {
94
+ if (!a || !b || a.length !== b.length) return false;
95
+ return a.every((v, i) => Object.is(v, b[i]));
96
+ }
97
+
98
+ export interface Navigation {
99
+ push(target: ScreenComponent | string): Promise<void>;
100
+ replace(target: ScreenComponent | string): Promise<void>;
101
+ back(): Promise<void>;
102
+ }
103
+
104
+ /** navigate by screen component (or window id). */
105
+ export function useNavigation(): Navigation {
106
+ const frame = need();
107
+ const dialog = frame.ctx.dialog;
108
+
109
+ const idOf = (t: ScreenComponent | string): string => {
110
+ if (typeof t === "string") return t;
111
+
112
+ const id = frame.idByComponent.get(t);
113
+ if (id === undefined) throw new Error("morda/jsx: navigating to a screen not in jsxDialogs()");
114
+
115
+ return id;
116
+ };
117
+
118
+ return {
119
+ push: (t) => dialog.push(idOf(t)),
120
+ replace: (t) => dialog.replace(idOf(t)),
121
+ back: () => dialog.back(),
122
+ };
123
+ }
124
+
125
+ /** the telegram user behind the current update. */
126
+ export function useUser(): User | undefined {
127
+ return need().ctx.from;
128
+ }
129
+
130
+ /** the session session (requires `@yaebal/session` installed before the dialog). */
131
+ export function useSession<S>(): S {
132
+ return (need().ctx as unknown as { session: S }).session;
133
+ }
134
+
135
+ export interface Translation {
136
+ t(key: string, ...args: unknown[]): string;
137
+ changeLanguage(language: string): unknown;
138
+ }
139
+
140
+ /** i18n helpers (requires `@yaebal/i18n` installed before the dialog). */
141
+ export function useTranslation(): Translation {
142
+ const ctx = need().ctx as unknown as Partial<Translation>;
143
+
144
+ if (!ctx.t || !ctx.changeLanguage) {
145
+ throw new Error("morda/jsx: useTranslation() requires @yaebal/i18n installed");
146
+ }
147
+
148
+ return { t: ctx.t, changeLanguage: ctx.changeLanguage };
149
+ }
150
+
151
+ // flattening
152
+
153
+ function isVNode(x: unknown): x is VNode {
154
+ return typeof x === "object" && x !== null && VNODE in x;
155
+ }
156
+
157
+ function resolve(node: unknown): unknown {
158
+ let cur = node;
159
+
160
+ for (
161
+ let guard = 0;
162
+ isVNode(cur) && typeof cur.type === "function" && !MARKERS.has(cur.type);
163
+ guard++
164
+ ) {
165
+ if (guard >= 50) throw new Error("morda/jsx: component nesting too deep (cyclic render?)");
166
+ cur = (cur.type as (p: unknown) => unknown)(cur.props);
167
+ }
168
+
169
+ return cur;
170
+ }
171
+
172
+ function toArray(x: unknown): unknown[] {
173
+ if (Array.isArray(x)) return x;
174
+ return x === undefined ? [] : [x];
175
+ }
176
+
177
+ function textOf(children: unknown): string {
178
+ const parts: string[] = [];
179
+
180
+ for (const c of toArray(children)) {
181
+ const n = resolve(c);
182
+
183
+ if (typeof n === "string" || typeof n === "number") parts.push(String(n));
184
+ }
185
+
186
+ return parts.join("");
187
+ }
188
+
189
+ function toButton(node: VNode): MordaButton {
190
+ const props = node.props as unknown as ButtonProps;
191
+ const onClick = props.onClick;
192
+
193
+ return {
194
+ id: props.id,
195
+ label: textOf(props.children),
196
+ onClick: onClick ? () => onClick() : undefined,
197
+ };
198
+ }
199
+
200
+ function collectRow(children: unknown): MordaButton[] {
201
+ const out: MordaButton[] = [];
202
+
203
+ for (const c of toArray(children)) {
204
+ const n = resolve(c);
205
+
206
+ if (isVNode(n) && n.type === Button) out.push(toButton(n));
207
+ }
208
+
209
+ return out;
210
+ }
211
+
212
+ function collect(node: unknown, texts: string[], rows: MordaButton[][]): void {
213
+ const n = resolve(node);
214
+ if (n === null || n === undefined || typeof n === "boolean") return;
215
+
216
+ if (typeof n === "string" || typeof n === "number") {
217
+ texts.push(String(n));
218
+ return;
219
+ }
220
+
221
+ if (Array.isArray(n)) {
222
+ for (const c of n) collect(c, texts, rows);
223
+ return;
224
+ }
225
+
226
+ if (isVNode(n)) {
227
+ if (n.type === Fragment) {
228
+ for (const c of toArray(n.props.children)) collect(c, texts, rows);
229
+ } else if (n.type === ButtonRow) {
230
+ rows.push(collectRow(n.props.children));
231
+ } else if (n.type === Button) {
232
+ rows.push([toButton(n)]);
233
+ }
234
+ }
235
+ }
236
+
237
+ function renderView(el: unknown): WindowView {
238
+ const screen = resolve(el);
239
+
240
+ if (!isVNode(screen) || screen.type !== Screen) {
241
+ throw new Error("morda/jsx: a screen component must render <Screen>…</Screen>");
242
+ }
243
+
244
+ const texts: string[] = [];
245
+ const rows: MordaButton[][] = [];
246
+
247
+ for (const c of toArray(screen.props.children)) collect(c, texts, rows);
248
+
249
+ const keyboard = rows.filter((r) => r.length > 0);
250
+ return keyboard.length > 0 ? { text: texts.join(""), keyboard } : { text: texts.join("") };
251
+ }
252
+
253
+ // hook state per chat+window. evicted when a window leaves the stack (via morda's
254
+ // onLeave) so a reopened screen re-mounts fresh. bounded by chats × windows.
255
+ const slotStore = new Map<string, unknown[]>();
256
+ const hookCounts = new Map<string, number>();
257
+
258
+ function makeRender(
259
+ windowId: string,
260
+ Component: ScreenComponent,
261
+ idByComponent: Map<ScreenComponent, string>,
262
+ ): (ctx: DialogContext) => Promise<WindowView> {
263
+ return async (ctx) => {
264
+ const storeKey = `${ctx.chat?.id ?? "_"}:${windowId}`;
265
+
266
+ let slots = slotStore.get(storeKey);
267
+ if (!slots) {
268
+ slots = [];
269
+ slotStore.set(storeKey, slots);
270
+ }
271
+
272
+ const frame: Frame = { slots, cursor: 0, effects: [], ctx, idByComponent };
273
+ const prev = current;
274
+
275
+ current = frame;
276
+
277
+ let tree: VNode;
278
+ try {
279
+ tree = Component();
280
+ } finally {
281
+ current = prev;
282
+ }
283
+
284
+ // rules of hooks: a changed hook count means a conditional hook — fail loud
285
+ // instead of silently reinterpreting slots.
286
+ const expected = hookCounts.get(storeKey);
287
+
288
+ if (expected !== undefined && expected !== frame.cursor) {
289
+ throw new Error(
290
+ "morda/jsx: hooks must be called unconditionally (hook count changed between renders)",
291
+ );
292
+ }
293
+
294
+ hookCounts.set(storeKey, frame.cursor);
295
+
296
+ const view = renderView(tree);
297
+ for (const effect of frame.effects) await effect();
298
+
299
+ return view;
300
+ };
301
+ }
302
+
303
+ /** install a set of JSX screens as a dialog. `bot.install(jsxDialogs({ main, settings }))`. */
304
+ export function jsxDialogs(
305
+ screens: Record<string, ScreenComponent>,
306
+ options?: DialogsOptions,
307
+ ): Plugin<Context, { dialog: DialogControl }> {
308
+ const idByComponent = new Map<ScreenComponent, string>();
309
+ for (const [id, Component] of Object.entries(screens)) idByComponent.set(Component, id);
310
+
311
+ const def: DialogDef = {};
312
+ for (const [id, Component] of Object.entries(screens)) {
313
+ def[id] = makeRender(id, Component, idByComponent);
314
+ }
315
+
316
+ return dialogs(def, {
317
+ ...options,
318
+ onLeave: (chatId, windowId) => {
319
+ const key = `${chatId}:${windowId}`;
320
+ slotStore.delete(key);
321
+ hookCounts.delete(key);
322
+ options?.onLeave?.(chatId, windowId);
323
+ },
324
+ });
325
+ }
@@ -0,0 +1,34 @@
1
+ // automatic JSX runtime. `jsx(type, props)` just builds a vnode; the morda/jsx
2
+ // renderer flattens it to a WindowView. components are never invoked by the
3
+ // runtime itself — they are resolved during flattening.
4
+
5
+ /** brand so `isVNode` can't mistake a plain user object for an element. */
6
+ export const VNODE: unique symbol = Symbol.for("yaebal.morda.vnode");
7
+
8
+ export interface VNode {
9
+ [VNODE]: true;
10
+ type: unknown;
11
+ props: { children?: unknown; [key: string]: unknown };
12
+ }
13
+
14
+ export function jsx(type: unknown, props: { children?: unknown; [key: string]: unknown }): VNode {
15
+ return { [VNODE]: true, type, props };
16
+ }
17
+
18
+ export const jsxs = jsx;
19
+
20
+ export function Fragment(props: { children?: unknown }): VNode {
21
+ return { [VNODE]: true, type: Fragment, props };
22
+ }
23
+
24
+ export namespace JSX {
25
+ export type Element = VNode;
26
+
27
+ export interface ElementChildrenAttribute {
28
+ children: Record<never, never>;
29
+ }
30
+
31
+ export interface IntrinsicElements {
32
+ [elem: string]: never;
33
+ }
34
+ }
@@ -0,0 +1,266 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Composer, Context, type Middleware } from "@yaebal/core";
4
+ import {
5
+ Button,
6
+ ButtonRow,
7
+ Screen,
8
+ jsxDialogs,
9
+ useEffect,
10
+ useNavigation,
11
+ useState,
12
+ } from "./jsx/index.js";
13
+
14
+ const noop = async () => {};
15
+ const entry = <C extends Context>(c: Composer<C>) =>
16
+ c.toMiddleware() as unknown as Middleware<Context>;
17
+
18
+ interface Call {
19
+ method: string;
20
+ // biome-ignore lint/suspicious/noExplicitAny: test recorder captures arbitrary params
21
+ params: any;
22
+ }
23
+
24
+ function fakeApi() {
25
+ const calls: Call[] = [];
26
+ let nextId = 100;
27
+
28
+ const api = {
29
+ sendMessage(params: Record<string, unknown>) {
30
+ calls.push({ method: "sendMessage", params });
31
+ return Promise.resolve({ message_id: nextId++ });
32
+ },
33
+ answerCallbackQuery(params: Record<string, unknown>) {
34
+ calls.push({ method: "answerCallbackQuery", params });
35
+ return Promise.resolve(true);
36
+ },
37
+ call(method: string, params: Record<string, unknown>) {
38
+ calls.push({ method, params });
39
+ return Promise.resolve(true);
40
+ },
41
+ } as never;
42
+
43
+ return { api, calls };
44
+ }
45
+
46
+ const msgCtx = (api: never, text: string, chatId: number) =>
47
+ new Context({
48
+ api,
49
+ update: {
50
+ update_id: 1,
51
+ message: {
52
+ message_id: 1,
53
+ date: 0,
54
+ chat: { id: chatId, type: "private" },
55
+ from: { id: chatId, is_bot: false, first_name: "u" },
56
+ text,
57
+ },
58
+ } as never,
59
+ updateType: "message",
60
+ });
61
+
62
+ const cbCtx = (api: never, data: string, chatId: number, messageId: number) =>
63
+ new Context({
64
+ api,
65
+ update: {
66
+ update_id: 1,
67
+ callback_query: {
68
+ id: "cb",
69
+ from: { id: chatId, is_bot: false, first_name: "u" },
70
+ message: { message_id: messageId, date: 0, chat: { id: chatId, type: "private" } },
71
+ data,
72
+ },
73
+ } as never,
74
+ updateType: "callback_query",
75
+ });
76
+
77
+ // biome-ignore lint/suspicious/noExplicitAny: reaching into recorded params
78
+ const dataAt = (params: any, row: number, col: number): string =>
79
+ params.reply_markup.inline_keyboard[row][col].callback_data;
80
+
81
+ test("useState re-renders the screen in place on setState", async () => {
82
+ function Counter() {
83
+ const [n, setN] = useState(0);
84
+ return (
85
+ <Screen>
86
+ {`n=${n}`}
87
+ <ButtonRow>
88
+ <Button id="inc" onClick={() => setN((v) => v + 1)}>
89
+ +
90
+ </Button>
91
+ </ButtonRow>
92
+ </Screen>
93
+ );
94
+ }
95
+
96
+ const { api, calls } = fakeApi();
97
+ const mw = entry(
98
+ new Composer<Context>()
99
+ .install(jsxDialogs({ counter: Counter }))
100
+ .command("go", (ctx) => ctx.dialog.start("counter")),
101
+ );
102
+
103
+ await mw(msgCtx(api, "/go", 1), noop);
104
+
105
+ const sent = calls.find((c) => c.method === "sendMessage");
106
+ assert.equal(sent?.params.text, "n=0");
107
+
108
+ const incData = dataAt(sent?.params, 0, 0);
109
+ calls.length = 0;
110
+
111
+ await mw(cbCtx(api, incData, 1, 100), noop);
112
+ assert.equal(calls.find((c) => c.method === "editMessageText")?.params.text, "n=1");
113
+
114
+ calls.length = 0;
115
+
116
+ await mw(cbCtx(api, incData, 1, 100), noop);
117
+ assert.equal(calls.find((c) => c.method === "editMessageText")?.params.text, "n=2");
118
+ });
119
+
120
+ test("useEffect with [] runs once on mount, not on re-render", async () => {
121
+ let runs = 0;
122
+ function Screen1() {
123
+ const [n, setN] = useState(0);
124
+
125
+ useEffect(() => {
126
+ runs++;
127
+ }, []);
128
+
129
+ return (
130
+ <Screen>
131
+ {`n=${n}`}
132
+ <ButtonRow>
133
+ <Button id="inc" onClick={() => setN((v) => v + 1)}>
134
+ +
135
+ </Button>
136
+ </ButtonRow>
137
+ </Screen>
138
+ );
139
+ }
140
+
141
+ const { api, calls } = fakeApi();
142
+ const mw = entry(
143
+ new Composer<Context>()
144
+ .install(jsxDialogs({ s: Screen1 }))
145
+ .command("go", (ctx) => ctx.dialog.start("s")),
146
+ );
147
+
148
+ await mw(msgCtx(api, "/go", 1), noop);
149
+ assert.equal(runs, 1);
150
+
151
+ const incData = dataAt(calls.find((c) => c.method === "sendMessage")?.params, 0, 0);
152
+
153
+ await mw(cbCtx(api, incData, 1, 100), noop); // re-renders twice (find + after setState)
154
+ assert.equal(runs, 1);
155
+ });
156
+
157
+ test("reopening a closed screen resets hook state", async () => {
158
+ let runs = 0;
159
+ function Counter() {
160
+ const [n, setN] = useState(0);
161
+ const { back } = useNavigation();
162
+
163
+ useEffect(() => {
164
+ runs++;
165
+ }, []);
166
+
167
+ return (
168
+ <Screen>
169
+ {`n=${n}`}
170
+ <ButtonRow>
171
+ <Button id="inc" onClick={() => setN((v) => v + 1)}>
172
+ +
173
+ </Button>
174
+ </ButtonRow>
175
+ <ButtonRow>
176
+ <Button id="close" onClick={() => back()}>
177
+ x
178
+ </Button>
179
+ </ButtonRow>
180
+ </Screen>
181
+ );
182
+ }
183
+
184
+ const { api, calls } = fakeApi();
185
+
186
+ // distinct window id ("rc") so this test's hook slots don't collide with the
187
+ // other counter test (module-level slot store is keyed by chat:window).
188
+ const mw = entry(
189
+ new Composer<Context>()
190
+ .install(jsxDialogs({ rc: Counter }))
191
+ .command("go", (ctx) => ctx.dialog.start("rc")),
192
+ );
193
+
194
+ await mw(msgCtx(api, "/go", 1), noop);
195
+
196
+ let sent = calls.find((c) => c.method === "sendMessage");
197
+ assert.equal(sent?.params.text, "n=0");
198
+ assert.equal(runs, 1);
199
+
200
+ const incData = dataAt(sent?.params, 0, 0);
201
+ const closeData = dataAt(sent?.params, 1, 0);
202
+
203
+ await mw(cbCtx(api, incData, 1, 100), noop); // n=1
204
+ calls.length = 0;
205
+
206
+ await mw(cbCtx(api, closeData, 1, 100), noop); // back() at root → close + evict slots
207
+ assert.ok(calls.some((c) => c.method === "deleteMessage"));
208
+
209
+ calls.length = 0;
210
+ await mw(msgCtx(api, "/go", 1), noop); // reopen
211
+
212
+ sent = calls.find((c) => c.method === "sendMessage");
213
+ assert.equal(sent?.params.text, "n=0"); // counter reset, not "n=1"
214
+ assert.equal(runs, 2); // mount effect re-fired
215
+ });
216
+
217
+ test("useNavigation pushes and pops across screens", async () => {
218
+ function Detail() {
219
+ const { back } = useNavigation();
220
+ return (
221
+ <Screen>
222
+ {"detail"}
223
+ <ButtonRow>
224
+ <Button id="back" onClick={() => back()}>
225
+ back
226
+ </Button>
227
+ </ButtonRow>
228
+ </Screen>
229
+ );
230
+ }
231
+
232
+ function Home() {
233
+ const { push } = useNavigation();
234
+ return (
235
+ <Screen>
236
+ {"home"}
237
+ <ButtonRow>
238
+ <Button id="go" onClick={() => push(Detail)}>
239
+ detail
240
+ </Button>
241
+ </ButtonRow>
242
+ </Screen>
243
+ );
244
+ }
245
+
246
+ const { api, calls } = fakeApi();
247
+ const mw = entry(
248
+ new Composer<Context>()
249
+ .install(jsxDialogs({ home: Home, detail: Detail }))
250
+ .command("go", (ctx) => ctx.dialog.start("home")),
251
+ );
252
+
253
+ await mw(msgCtx(api, "/go", 1), noop);
254
+ const goData = dataAt(calls.find((c) => c.method === "sendMessage")?.params, 0, 0);
255
+
256
+ calls.length = 0;
257
+ await mw(cbCtx(api, goData, 1, 100), noop);
258
+
259
+ const edit1 = calls.find((c) => c.method === "editMessageText");
260
+ assert.equal(edit1?.params.text, "detail");
261
+
262
+ calls.length = 0;
263
+ await mw(cbCtx(api, dataAt(edit1?.params, 0, 0), 1, 100), noop);
264
+
265
+ assert.equal(calls.find((c) => c.method === "editMessageText")?.params.text, "home");
266
+ });