openui-telegram 0.0.0-stage → 1.0.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pavel Voronin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, 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,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
1
+ # OpenUI Telegram
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Compile OpenUI templates and data into HTML for Telegram Widgets. Define your components, choose a static snapshot or an updatable widget, then send the resulting HTML through your bot.
4
+
5
+ Requires Node.js **22.15+, 24 or 26 LTS**, Zod 4 and `@openuidev/lang-core` 1.1.0.
6
+
7
+ ```sh
8
+ npm install openui-telegram @openuidev/lang-core zod
9
+ ```
10
+
11
+ ## Static HTML
12
+
13
+ ```ts
14
+ import { z } from 'zod';
15
+ import { createLibrary } from '@openuidev/lang-core';
16
+ import {
17
+ compileWidget, defineWidgetComponent,
18
+ createInterface, createWidgetController,
19
+ } from 'openui-telegram';
20
+
21
+ const Text = defineWidgetComponent({
22
+ name: 'Text', description: 'Text',
23
+ props: z.object({ value: z.string() }),
24
+ render: ({ props, text }) => `<p>${text(props.value)}</p>`,
25
+ });
26
+ const library = createLibrary({ components: [Text] });
27
+ const source = '$amount = 0\nroot = Text("Total: " + $amount)';
28
+ const initialState = { state: { $amount: 1200 }, forms: {} };
29
+
30
+ const card = await compileWidget({
31
+ mode: 'static', library, source, initialState,
32
+ });
33
+ // card.html contains "Total: 1200", with no library JavaScript.
34
+ ```
35
+
36
+ `mode` is required. In `static`, `$variables` and expressions are evaluated once during compilation. Actions and browser lifecycle hooks produce error diagnostics. Queries use supplied saved results or defaults; compilation never calls tools.
37
+
38
+ Before sending HTML, check `diagnostics` for errors and `fits` for Telegram's document limits. `measurement` reports actual byte sizes and remaining space. Components return trusted HTML; use `text(value)` to escape data.
39
+
40
+ ## Updatable widgets
41
+
42
+ Using the same library and template:
43
+
44
+ ```ts
45
+ const widget = await compileWidget({
46
+ mode: 'updatable', instanceId: 'order', library, source, initialState,
47
+ });
48
+ const session = createInterface(library, source, {
49
+ instanceId: 'order', initialState: widget.initialData.state,
50
+ });
51
+ const controller = createWidgetController(session.runtime, {
52
+ initialFrame: widget.initialData.frame,
53
+ });
54
+
55
+ // Send widget.html once. Deliver this string as Telegram chat_data:
56
+ const chatData = controller.encode(await controller.frame());
57
+
58
+ // Later, change application state and deliver the next chat_data:
59
+ session.runtime.setState('$amount', 1500);
60
+ const nextChatData = controller.encode(await controller.frame());
61
+
62
+ // In your handler for a widget request routed to this instance:
63
+ async function handleRequest(requestData: string) {
64
+ const completed = await controller.handle(requestData);
65
+ const data = controller.encode(await controller.frame());
66
+ return { completed, data }; // Deliver data to Telegram and answer the request.
67
+ }
68
+
69
+ // When the application no longer needs this widget:
70
+ // controller.dispose();
71
+ ```
72
+
73
+ `updatable` includes a compact client; OpenUI executes in your application. Plain HTML updates need no `clientEntry`. Your application owns the bot, request authorization, delivery and storage. Use a unique instance ID of 1–32 ASCII letters, digits, `_` or `-`, and serialize requests and frame delivery for each instance.
74
+
75
+ - Pass `onOperation: async ({ tool, args }) => ...` to `createInterface` to execute queries and mutations. `onAction` handles custom actions and conversation events. Subscribe with `session.runtime.subscribe(callback)` to deliver asynchronous query results without waiting for another tap.
76
+ - `session.update(source)` replaces a program; `session.patch(source)` edits it while retaining unaffected state and operations. `session.push(chunk, complete)` accepts streamed source.
77
+ - To resume an existing message, persist `session.source`, `controller.save()` and `controller.checkpoint()` together. Restore the same instance ID/program version and pass the checkpoint to the new controller; see the [restart recipe](https://github.com/pavel-voronin/openui-telegram/blob/main/examples/README.md#restart-and-delivery). Saved mutations are not replayed.
78
+
79
+ Working examples: [static HTML](https://github.com/pavel-voronin/openui-telegram/blob/main/examples/hello.ts) and [static/updatable cards](https://github.com/pavel-voronin/openui-telegram/blob/main/examples/cards.ts).
80
+
81
+ Try `npm run demo:simple` for a counter or `npm run demo:full` for the capability gallery. Both run locally without credentials. [Demo instructions](https://github.com/pavel-voronin/openui-telegram/blob/main/examples/README.md) also cover sending them through a real Telegram bot.
82
+
83
+ ## Components and browser hooks
84
+
85
+ `defineWidgetComponent` infers props from its Zod schema. Its `render` context provides `text`, `renderNode` for children, `action` for a delegated button attribute, and `field`/`form` for values and validation. Mark bound props with `reactive: ['value']` and register fields during `render`.
86
+
87
+ For browser behavior such as canvas drawing or pointer controls, add `mount(context)` and/or `update(context)`. They receive the DOM `element` and current props. `context.on(target, event, listener)` manages listeners; `mount` may return a cleanup function. Lifecycle components must render one root element.
88
+
89
+ Put these implementations in a module exporting `components`, use the same implementations in your component definitions, and pass that module's path as `clientEntry`. See [browser-components.ts](https://github.com/pavel-voronin/openui-telegram/blob/main/examples/browser-components.ts). Keep Zod schemas in the application. Browser helpers are exported from `openui-telegram/client`. Reconciliation applies changes to server-rendered content while preserving locally added DOM and unchanged client-modified attributes; removed roots still dispose their resources.
90
+
91
+ The compiler includes the client capabilities and component CSS reachable from the initial program, including conditional branches. Reserve hooks and styles needed by later patches with `clientComponents: ['Chart']`, or use `clientSpecialization: false` to retain the full client, registry and styles. An update requiring unreserved hooks or component CSS is rejected before changing the visible frame. The compiler's `css` option is always global. Adding browser code or unreserved styles after sending requires a new widget.
92
+
93
+ ## Telegram limits
94
+
95
+ HTML must fit **both 32,768 bytes uncompressed and 4,096 bytes compressed**; requests are limited to **64 bytes** and state/answers to **1,024 bytes**. Oversized documents produce diagnostics; oversized encoded updates throw `TransportSizeError`. Delivering a larger view requires an explicit new widget; see the [overflow example](https://github.com/pavel-voronin/openui-telegram/blob/main/examples/README.md#oversized-updates). `fits` checks size only; `unsupported-element` diagnoses known removed tags in initial HTML. Diagnostics do not certify CSS, dynamic DOM or sandbox behavior: test your UI in Telegram.
96
+
97
+ Inline scripts, styles and assets: widgets cannot fetch network resources. Native keyboard inputs are unavailable. Links must open synchronously from a tap; links after asynchronous work are rejected. Updatable interactions require your application and a Telegram round trip. The built-in client consumes `chatData`, not shared `publicData`. Telegram currently enables production widgets only in private bot chats; other contexts require its test server. This package is ESM-only.
98
+
99
+ See the [Telegram Widget documentation](https://core.telegram.org/bots/widgets) and [OpenUI language documentation](https://www.openui.com/docs/openui-lang/specification).
100
+
101
+ ## Development
102
+
103
+ Run `npm ci` and `npm run check`. This builds the library and examples, checks types, runs tests with mandatory 100% line/branch/function coverage, and installs the packed library in a separate consumer project. Package verification needs registry access; compilation and unit tests need no credentials or network.
104
+
105
+ Source is in `src/`, tests in `test/`, and examples in `examples/`. `npm run build` cleans and regenerates `dist/`. Keep generated output uncommitted and add focused tests for behavior changes. CI runs the same checks on Linux and Windows for supported Node versions, including the minimum 22.15.0 and 24.0.0. CI also runs weekly on Sunday at 20:00 UTC, checking the latest releases in each supported Node branch. See [CHANGELOG.md](https://github.com/pavel-voronin/openui-telegram/blob/main/CHANGELOG.md) for release notes.
106
+
107
+ Stable releases publish to npm after all CI checks pass when a pushed tag matches `v<package.json version>`. A manual Actions run checks publication with `--dry-run` and never publishes. After the first authenticated publish, configure an npm [trusted publisher](https://docs.npmjs.com/trusted-publishers/) for owner `pavel-voronin`, repository `openui-telegram`, workflow `ci.yml`, no environment, with direct `npm publish` allowed. Later releases use OIDC without an npm token.
@@ -0,0 +1,9 @@
1
+ // Full public browser/local client. The compiler replaces these constants only
2
+ // inside a widget bundle, after inspecting its bundled lifecycle callbacks.
3
+ export const CLIENT_LIFECYCLE = true;
4
+ export const CLIENT_FIELDS = true;
5
+ export const CLIENT_FORMS = true;
6
+ export const CLIENT_MOUNT = true;
7
+ export const CLIENT_CLEANUPS = true;
8
+ export const CLIENT_EVENTS = true;
9
+ export const CLIENT_TEXT = true;
@@ -0,0 +1 @@
1
+ export declare function text(value: unknown): string;
@@ -0,0 +1,6 @@
1
+ // Kept free of language runtime imports so the compact driver stays self-contained.
2
+ export function text(value) {
3
+ return String(value ?? '').replace(/[&<>"']/g, c => ({
4
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
5
+ })[c]);
6
+ }
@@ -0,0 +1,16 @@
1
+ import type { WidgetFrame, WidgetInput } from './controller.js';
2
+ import type { WidgetComponent } from './runtime.js';
3
+ import type { WidgetUpdate } from './wire-apply.js';
4
+ export { text } from './client-text.js';
5
+ /** The compiler supplies availableStyles; standalone adapters own their CSS unless they supply this manifest. */
6
+ export declare function mountWidgetClient(initial: WidgetFrame, components: Record<string, WidgetComponent>, options?: {
7
+ root: HTMLElement;
8
+ request(input: WidgetInput): Promise<WidgetFrame | WidgetUpdate>;
9
+ openUrl?(url: string): void;
10
+ onDiagnostic?(diagnostic: unknown): void;
11
+ }, availableStyles?: readonly string[]): {
12
+ apply: (update: WidgetFrame | WidgetUpdate) => boolean;
13
+ errors: unknown[];
14
+ readonly current: WidgetFrame;
15
+ dispose(): void;
16
+ };
package/dist/client.js ADDED
@@ -0,0 +1,196 @@
1
+ import { text } from './client-text.js';
2
+ import { applyWidgetUpdate } from './wire-apply.js';
3
+ import { reconcileDOM, restoreDOM } from './dom.js';
4
+ import { CLIENT_LIFECYCLE, CLIENT_FIELDS, CLIENT_FORMS, CLIENT_MOUNT, CLIENT_CLEANUPS, CLIENT_EVENTS, CLIENT_TEXT } from './client-features.js';
5
+ export { text } from './client-text.js';
6
+ /** The compiler supplies availableStyles; standalone adapters own their CSS unless they supply this manifest. */
7
+ export function mountWidgetClient(initial, components, options, availableStyles) {
8
+ const env = options ? undefined : globalThis.TelegramWidget, host = options ?? globalThis.OpenUIHost;
9
+ const root = options?.root ?? document.getElementById('openui-root');
10
+ let frame = initial, stopped = false;
11
+ const resources = CLIENT_LIFECYCLE ? new Map() : undefined;
12
+ const errors = [];
13
+ const error = (e) => { errors.push(e); host?.onDiagnostic?.({ source: 'transport', code: 'client-error', message: String(e) }); };
14
+ const dispose = (id) => {
15
+ const resource = resources.get(id);
16
+ resources.delete(id);
17
+ if (CLIENT_CLEANUPS)
18
+ for (const cleanup of resource.cleanup.reverse()) {
19
+ try {
20
+ cleanup();
21
+ }
22
+ catch (e) {
23
+ error(e);
24
+ }
25
+ }
26
+ restoreDOM(resource.element);
27
+ };
28
+ const request = (input) => {
29
+ const data = JSON.stringify(input);
30
+ const bytes = encodeURIComponent(data).replace(/%[0-9A-F]{2}/g, 'x').length;
31
+ if (bytes > 64) {
32
+ error(new Error(`Request has ${bytes} bytes, limit is 64`));
33
+ return;
34
+ }
35
+ try {
36
+ const result = env ? env.request(data).then((answer) => answer.result && JSON.parse(answer.result)) : host?.request(input);
37
+ Promise.resolve(result).then(update => { if (update)
38
+ apply(update); }).catch(error);
39
+ }
40
+ catch (e) {
41
+ error(e);
42
+ }
43
+ };
44
+ const draw = () => {
45
+ if (CLIENT_LIFECYCLE)
46
+ for (const [id, resource] of resources)
47
+ if (!frame.c[id] || frame.c[id].type !== resource.type)
48
+ dispose(id);
49
+ reconcileDOM(root, frame.h);
50
+ if (CLIENT_LIFECYCLE)
51
+ for (const [id, record] of Object.entries(frame.c)) {
52
+ const implementation = components[record.type], element = root.querySelector(`[data-oui-component="${id}"]`);
53
+ if (!element || !implementation) {
54
+ error(new Error(`Missing lifecycle implementation or root: ${record.type}`));
55
+ continue;
56
+ }
57
+ let resource = resources.get(id);
58
+ if (resource && resource.element !== element) {
59
+ dispose(id);
60
+ resource = undefined;
61
+ }
62
+ const fresh = !resource;
63
+ if (!resource) {
64
+ const cleanup = CLIENT_CLEANUPS ? [] : undefined;
65
+ const context = {
66
+ id, props: {}, statementId: record.statementId, scope: record.scope, element,
67
+ ...(CLIENT_TEXT ? { text } : {}),
68
+ ...(CLIENT_FIELDS ? { field: (name, _binding, options) => {
69
+ const scope = options?.scope ?? context.scope ?? '';
70
+ const slot = frame.c[id]?.fields?.findIndex(field => field.name === name && field.scope === scope) ?? -1;
71
+ if (slot < 0)
72
+ throw new Error('Register component fields during render before using lifecycle hooks');
73
+ const current = () => {
74
+ const field = frame.c[id]?.fields?.find(field => field.name === name && field.scope === scope) ?? frame.c[id]?.fields?.[slot];
75
+ if (!field)
76
+ throw new Error('Field is no longer registered');
77
+ return field;
78
+ };
79
+ return {
80
+ get name() { return current().name; },
81
+ get isReactive() { return current().reactive; },
82
+ get value() { const field = current(); return frame.f[`${field.scope}\0${field.name}`]; },
83
+ setValue(value) {
84
+ const field = current();
85
+ request([frame.i, frame.v, frame.r, 'v', field.handle, value]);
86
+ },
87
+ };
88
+ } } : {}),
89
+ ...(CLIENT_FORMS ? { form: (scope = context.scope ?? '') => ({
90
+ values: Object.fromEntries(Object.entries(frame.f).filter(([key]) => key.startsWith(`${scope}\0`))
91
+ .map(([key, value]) => [key.slice(scope.length + 1), value])), errors: Object.hasOwn(frame.e, scope) ? frame.e[scope] : {},
92
+ }) } : {}),
93
+ ...(CLIENT_EVENTS ? { on(target, event, handler) {
94
+ target.addEventListener(event, handler);
95
+ cleanup.push(() => target.removeEventListener(event, handler));
96
+ } } : {}),
97
+ };
98
+ resource = { element, type: record.type, context, ...(CLIENT_CLEANUPS ? { cleanup } : {}), signature: '' };
99
+ resources.set(id, resource);
100
+ }
101
+ // Keep the props object live for listeners installed by mount().
102
+ const props = resource.context.props;
103
+ for (const key of Object.keys(props))
104
+ if (!Object.prototype.hasOwnProperty.call(record.props, key))
105
+ delete props[key];
106
+ Object.assign(props, record.props);
107
+ resource.context.scope = record.scope;
108
+ resource.context.statementId = record.statementId;
109
+ const signature = JSON.stringify([record.props, frame.f, frame.e, record.scope]);
110
+ try {
111
+ if (CLIENT_MOUNT && fresh) {
112
+ const cleanup = implementation.mount?.(resource.context);
113
+ if (cleanup)
114
+ resource.cleanup.push(cleanup);
115
+ }
116
+ if (fresh || signature !== resource.signature)
117
+ implementation.update?.(resource.context);
118
+ resource.signature = signature;
119
+ }
120
+ catch (e) {
121
+ error(e);
122
+ }
123
+ }
124
+ if (CLIENT_LIFECYCLE)
125
+ for (const [id, resource] of resources)
126
+ if (!root.contains(resource.element))
127
+ dispose(id);
128
+ };
129
+ const apply = (update) => {
130
+ if (stopped || update.i !== frame.i || update.v < frame.v || update.v === frame.v && update.r <= frame.r)
131
+ return false;
132
+ const base = 'd' in update && update.b[0] === initial.v && update.b[1] === initial.r ? initial : frame;
133
+ const next = 'd' in update ? applyWidgetUpdate(base, update) : update;
134
+ for (const name of next.s ?? [])
135
+ if (availableStyles && !availableStyles.includes(name))
136
+ throw new Error(`Styles for ${name} need a new widget; reserve them with clientComponents before compilation`);
137
+ for (const record of Object.values(next.c))
138
+ if (!CLIENT_LIFECYCLE || !components[record.type])
139
+ throw new Error(`Component ${record.type} needs a new widget; reserve it with clientComponents before compilation`);
140
+ frame = next;
141
+ draw();
142
+ return true;
143
+ };
144
+ const changed = () => { try {
145
+ if (env.chatData)
146
+ apply(JSON.parse(env.chatData));
147
+ }
148
+ catch (e) {
149
+ error(e);
150
+ } };
151
+ const click = (event) => {
152
+ let node = event.target;
153
+ while (node && node !== root) {
154
+ const action = node.getAttribute('data-oui-action');
155
+ if (action !== null) {
156
+ const link = frame.l[action];
157
+ if (link) {
158
+ try {
159
+ if (env) {
160
+ const telegram = /^https:\/\/(?:t\.me|telegram\.me|telegram\.dog)(?::443)?(?=[/?#]|$)/i.exec(link);
161
+ if (telegram)
162
+ env.openTelegramLink('https://t.me' + link.slice(telegram[0].length));
163
+ else
164
+ env.openLink(link);
165
+ }
166
+ else
167
+ host?.openUrl?.(link);
168
+ }
169
+ catch (e) {
170
+ error(e);
171
+ }
172
+ }
173
+ else
174
+ request([frame.i, frame.v, frame.r, 'a', action]);
175
+ return;
176
+ }
177
+ node = node.parentElement;
178
+ }
179
+ };
180
+ root.addEventListener('click', click);
181
+ env?.onEvent('chatDataChanged', changed);
182
+ draw();
183
+ if (env)
184
+ changed();
185
+ env?.ready?.();
186
+ return { apply, errors, get current() { return frame; },
187
+ dispose() {
188
+ stopped = true;
189
+ if (CLIENT_LIFECYCLE)
190
+ for (const id of [...resources.keys()])
191
+ dispose(id);
192
+ root.removeEventListener('click', click);
193
+ env?.offEvent('chatDataChanged', changed);
194
+ },
195
+ };
196
+ }
@@ -0,0 +1,94 @@
1
+ import type { Library } from '@openuidev/lang-core';
2
+ import type { Diagnostic, RuntimeOptions, WidgetComponent } from './runtime.js';
3
+ import type { WidgetClientFeatures } from './host-client-bundle.js';
4
+ interface CompileBase extends Omit<RuntimeOptions, 'instanceId'> {
5
+ library: Library<WidgetComponent>;
6
+ source: string;
7
+ /** Module exporting components (WidgetComponent map) with client lifecycle implementations.
8
+ * Keep schemas on the host; this module and every imported resource are bundled inline. */
9
+ clientEntry?: string;
10
+ /** Default true: include only reachable lifecycle implementations/services. */
11
+ clientSpecialization?: boolean;
12
+ /** Reserve additional lifecycle components for later program updates/patches. */
13
+ clientComponents?: string[];
14
+ css?: string;
15
+ }
16
+ export type CompileOptions = CompileBase & ({
17
+ mode: 'static';
18
+ instanceId?: string;
19
+ } | {
20
+ mode: 'updatable';
21
+ instanceId: string;
22
+ });
23
+ export declare function compileWidget(options: CompileOptions): Promise<{
24
+ mode: "static" | "updatable";
25
+ html: string;
26
+ initialData: {
27
+ instanceId: string;
28
+ version: number;
29
+ program: import("@openuidev/lang-core").ParseResult;
30
+ state: import("./runtime.js").SavedState;
31
+ frame: import("./controller.js").WidgetFrame;
32
+ clientFeatures: WidgetClientFeatures | undefined;
33
+ };
34
+ diagnostics: Diagnostic[];
35
+ measurement: {
36
+ html: {
37
+ bytes: number;
38
+ limit: number;
39
+ accuracy: string;
40
+ };
41
+ compressedHtml: {
42
+ bytes: number;
43
+ limit: number;
44
+ accuracy: string;
45
+ };
46
+ clientRuntime: {
47
+ bytes: number;
48
+ compressedBytes: number;
49
+ accuracy: string;
50
+ scope: string;
51
+ containsLanguageRuntime: boolean;
52
+ };
53
+ headroom: {
54
+ htmlBytes: number;
55
+ compressedHtmlBytes: number;
56
+ accuracy: string;
57
+ note: string;
58
+ };
59
+ initialData: {
60
+ bytes: number;
61
+ accuracy: string;
62
+ };
63
+ transportFrame: {
64
+ bytes: number;
65
+ limit: number;
66
+ accuracy: string;
67
+ encoding: string;
68
+ required: boolean;
69
+ };
70
+ fullFrame: {
71
+ bytes: number;
72
+ accuracy: string;
73
+ };
74
+ client: {
75
+ accuracy: string;
76
+ checks: string[];
77
+ };
78
+ };
79
+ fits: boolean;
80
+ profile: {
81
+ readonly profile: 1;
82
+ readonly documentationChecked: '2026-11-11';
83
+ readonly openui: '1.1.0';
84
+ readonly language: '1.0 (inherits 0.5)';
85
+ readonly html: 32768;
86
+ readonly compressedHtml: 4096;
87
+ readonly request: 64;
88
+ readonly response: 1024;
89
+ readonly state: 1024;
90
+ };
91
+ message: import("@openuidev/lang-core").ParsedMessage;
92
+ parts: import("./message.js").ResponsePart[];
93
+ }>;
94
+ export {};
@@ -0,0 +1,157 @@
1
+ import { build } from 'esbuild';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { deflateSync } from 'node:zlib';
4
+ import { resolve } from 'node:path';
5
+ import { createInterface } from './session.js';
6
+ import { TELEGRAM_PROFILE } from './transport.js';
7
+ import { createWidgetController } from './controller.js';
8
+ import { responseParts } from './message.js';
9
+ import { createWidgetUpdate } from './wire.js';
10
+ import { javascriptData } from './javascript-data.js';
11
+ import { hostClientPlugin, widgetClientFeatures } from './host-client-bundle.js';
12
+ const scriptJSON = javascriptData;
13
+ /** Inspect markup, skipping comments and raw script/style content. Not a sandbox validator. */
14
+ function unsupportedElements(html) {
15
+ const forbidden = new Set(('input select textarea option optgroup fieldset legend datalist output progress meter dialog audio video source track ' +
16
+ 'iframe frame frameset object embed link meta base portal template slot math ruby rt rp center font big tt strike nobr').split(' '));
17
+ const found = new Set();
18
+ const tokens = /<!--[\s\S]*?(?:-->|$)|<\/?([a-z][\w:-]*)\b(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi;
19
+ let match;
20
+ while ((match = tokens.exec(html))) {
21
+ const tag = match[1]?.toLowerCase();
22
+ if (!tag)
23
+ continue;
24
+ const closing = match[0].startsWith('</');
25
+ if (closing)
26
+ continue;
27
+ if (forbidden.has(tag))
28
+ found.add(tag);
29
+ if (tag === 'script' || tag === 'style') {
30
+ const close = new RegExp(`</${tag}\\s*>`, 'gi');
31
+ close.lastIndex = tokens.lastIndex;
32
+ if (!close.exec(html))
33
+ break;
34
+ tokens.lastIndex = close.lastIndex;
35
+ }
36
+ }
37
+ return [...found];
38
+ }
39
+ export async function compileWidget(options) {
40
+ if (options.mode !== 'static' && options.mode !== 'updatable')
41
+ throw new TypeError('mode must be "static" or "updatable"');
42
+ if (options.mode === 'updatable' && !options.instanceId)
43
+ throw new TypeError('updatable mode requires instanceId');
44
+ const instanceId = options.instanceId ?? 'static';
45
+ if (!/^[a-zA-Z0-9_-]{1,32}$/.test(instanceId))
46
+ throw new TypeError('instanceId must be 1–32 ASCII letters, digits, _ or -');
47
+ const clientPath = options.clientEntry ? resolve(options.clientEntry) : undefined;
48
+ const diagnostics = [];
49
+ // Compilation never performs external operations. Queries still expose their defaults.
50
+ const session = createInterface(options.library, options.source, {
51
+ instanceId, version: options.version, initialState: options.initialState,
52
+ validators: options.validators,
53
+ executeQueries: false,
54
+ onDiagnostic: d => diagnostics.push(d),
55
+ });
56
+ try {
57
+ const parsed = session.runtime.parsed;
58
+ const frame = await createWidgetController(session.runtime).frame();
59
+ const initialHtml = frame.h;
60
+ diagnostics.push(...parsed.meta.errors.map(error => ({ ...error, source: 'parser' })));
61
+ if (parsed.meta.incomplete)
62
+ diagnostics.push({ source: 'parser', code: 'incomplete', message: 'Program is incomplete' });
63
+ for (const statementId of parsed.meta.unresolved)
64
+ diagnostics.push({ source: 'parser', code: 'unresolved', statementId, message: `Unresolved reference: ${statementId}` });
65
+ const initialData = { instanceId, version: options.version ?? 1,
66
+ program: parsed, state: session.runtime.snapshot(), frame, clientFeatures: undefined };
67
+ const needsClient = options.mode === 'updatable';
68
+ if (!needsClient) {
69
+ if (session.runtime.hasActions)
70
+ diagnostics.push({ source: 'compiler', code: 'static-action',
71
+ message: 'Static HTML cannot execute OpenUI actions. Use mode: "updatable" for interactive controls.' });
72
+ for (const component of new Set(Object.values(frame.c).map(record => record.type)))
73
+ diagnostics.push({
74
+ source: 'compiler', code: 'static-client-hook', component,
75
+ message: `${component} requires client mount/update hooks. Use mode: "updatable" or a render-only component.`
76
+ });
77
+ if (parsed.queryStatements.length)
78
+ diagnostics.push({ source: 'compiler', code: 'static-query-snapshot', severity: 'warning',
79
+ message: 'Static compilation does not execute tools or deliver later Query results; it renders supplied saved results or query defaults.' });
80
+ if (options.clientComponents?.length || options.clientSpecialization === false)
81
+ diagnostics.push({ source: 'compiler', code: 'static-client-options',
82
+ message: 'Client capabilities cannot be enabled in mode: "static". Use mode: "updatable".' });
83
+ }
84
+ const selected = widgetClientFeatures(parsed, options.library, Object.values(frame.c).map(record => record.type), needsClient ? options.clientComponents : undefined, !needsClient || options.clientSpecialization !== false);
85
+ const hostFeatures = needsClient ? selected : undefined;
86
+ initialData.clientFeatures = hostFeatures;
87
+ let javascript = '';
88
+ let clientRuntime = { bytes: 0, compressedBytes: 0, accuracy: 'standalone-exact',
89
+ scope: 'Library client only; excludes components and bootstrap. Compression is not additive.', containsLanguageRuntime: false };
90
+ const requiresEntry = !!hostFeatures?.components.length;
91
+ if (needsClient && (!requiresEntry || clientPath)) {
92
+ const bootstrap = `
93
+ import { mountWidgetClient } from ${JSON.stringify(fileURLToPath(new URL('./client.js', import.meta.url)))};
94
+ ${!clientPath ? 'const components = {};' : hostFeatures.specialized ? `import {components as clientComponents} from ${JSON.stringify(clientPath)};
95
+ const components = {${hostFeatures.components.map(name => `${scriptJSON(name)}:clientComponents[${scriptJSON(name)}]`).join(',')}};` :
96
+ `import { components } from ${JSON.stringify(clientPath)};`}
97
+ globalThis.OpenUIWidget = mountWidgetClient(${scriptJSON(frame)}, components, undefined, ${scriptJSON(hostFeatures.styles)});
98
+ globalThis.addEventListener('pagehide', () => globalThis.OpenUIWidget.dispose());
99
+ `;
100
+ const bundled = await build({ stdin: { contents: bootstrap, resolveDir: process.cwd(), loader: 'js' },
101
+ bundle: true, write: false, format: 'iife', platform: 'browser', minify: true, target: 'es2023',
102
+ legalComments: 'none', metafile: true, plugins: [hostClientPlugin(hostFeatures, options.clientEntry)] });
103
+ javascript = bundled.outputFiles[0].text;
104
+ {
105
+ const service = await build({ stdin: { contents: `import {mountWidgetClient} from ${JSON.stringify(fileURLToPath(new URL('./client.js', import.meta.url)))}; globalThis.OpenUIClient=mountWidgetClient;`,
106
+ resolveDir: process.cwd(), loader: 'js' }, bundle: true, write: false, format: 'iife', platform: 'browser',
107
+ minify: true, target: 'es2023', metafile: true,
108
+ plugins: [hostClientPlugin(hostFeatures, options.clientEntry)] });
109
+ const code = service.outputFiles[0].text;
110
+ clientRuntime = { ...clientRuntime, bytes: Buffer.byteLength(code), compressedBytes: deflateSync(code, { level: 9 }).length,
111
+ containsLanguageRuntime: Object.keys(service.metafile.inputs).some(path => path.includes('@openuidev/lang-core')) };
112
+ }
113
+ if (Object.values(bundled.metafile.outputs).some(output => output.imports.length))
114
+ throw new Error('Widget bundle must not retain external imports');
115
+ }
116
+ else if (needsClient)
117
+ diagnostics.push({ source: 'compiler', code: 'client-entry-required',
118
+ message: `Supply clientEntry for client component implementations: ${hostFeatures.components.join(', ')}. Plain updatable HTML needs no clientEntry.` });
119
+ const css = (options.css ?? '') + selected.styles.map(name => options.library.components[name].component.css).join('\n');
120
+ if (/<\/style/i.test(css))
121
+ throw new Error('CSS must not contain a closing style tag');
122
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style></head><body><div id="openui-root">${initialHtml}</div>${javascript ? `<script>${javascript.replace(/<\/script/gi, '<\\/script')}</script>` : ''}</body></html>`;
123
+ const measurement = {
124
+ html: { bytes: Buffer.byteLength(html), limit: 32768, accuracy: 'exact' },
125
+ compressedHtml: { bytes: deflateSync(html, { level: 9 }).length, limit: 4096, accuracy: 'exact' },
126
+ clientRuntime,
127
+ headroom: { htmlBytes: 32768 - Buffer.byteLength(html), compressedHtmlBytes: 4096 - deflateSync(html, { level: 9 }).length,
128
+ accuracy: 'exact', note: 'Remaining whole-document limits; not a guarantee for any particular added content.' },
129
+ initialData: { bytes: Buffer.byteLength(JSON.stringify(initialData)), accuracy: 'exact' },
130
+ transportFrame: { bytes: Buffer.byteLength(JSON.stringify(createWidgetUpdate(frame, { ...frame, r: frame.r + 1 }))),
131
+ limit: 1024, accuracy: 'exact', encoding: 'delta-from-bootstrap',
132
+ required: needsClient },
133
+ fullFrame: { bytes: Buffer.byteLength(JSON.stringify(frame)), accuracy: 'exact' },
134
+ client: { accuracy: 'client-only', checks: ['DOM nodes', 'CPU', 'memory', 'canvas', 'layout', 'gestures'] },
135
+ };
136
+ for (const [field, measured] of Object.entries(measurement))
137
+ if ('limit' in measured && measured.bytes > measured.limit &&
138
+ (!('required' in measured) || measured.required))
139
+ diagnostics.push({ source: field === 'transportFrame' ? 'transport' : 'platform', code: 'size-limit', path: field,
140
+ message: `${field}: ${measured.bytes} bytes exceeds Telegram's ${measured.limit}-byte limit` });
141
+ if (html.includes('\0'))
142
+ diagnostics.push({ source: 'platform', code: 'invalid-html', message: 'Telegram forbids NUL in HTML' });
143
+ for (const tag of unsupportedElements(initialHtml))
144
+ diagnostics.push({ source: 'platform', code: 'unsupported-element', path: tag,
145
+ message: `Telegram profile 1 removes <${tag}>. Use supported HTML or component-defined pointer controls.` });
146
+ if (/<script\b[^>]*\bsrc\s*=|<link\b[^>]*\bhref\s*=|(?:src\s*=\s*["']?|url\(\s*["']?)https?:|@import\b/i.test(html))
147
+ diagnostics.push({ source: 'platform', code: 'network-resource',
148
+ message: 'Telegram does not load network resources. Inline scripts, CSS and data: resources instead.' });
149
+ const fits = measurement.html.bytes <= measurement.html.limit &&
150
+ measurement.compressedHtml.bytes <= measurement.compressedHtml.limit;
151
+ return { mode: options.mode, html, initialData, diagnostics, measurement, fits, profile: TELEGRAM_PROFILE,
152
+ message: session.message, parts: responseParts(session.message.content) };
153
+ }
154
+ finally {
155
+ session.dispose();
156
+ }
157
+ }