chatkit-bun 0.0.2

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.
Files changed (52) hide show
  1. package/README.md +202 -0
  2. package/package.json +40 -0
  3. package/src/actions.ts +39 -0
  4. package/src/agents/accumulate.ts +43 -0
  5. package/src/agents/annotations.ts +157 -0
  6. package/src/agents/context.ts +190 -0
  7. package/src/agents/converter.ts +290 -0
  8. package/src/agents/index.ts +25 -0
  9. package/src/agents/stream.ts +1053 -0
  10. package/src/agents/types.ts +30 -0
  11. package/src/agents/workflows.ts +220 -0
  12. package/src/errors.ts +19 -0
  13. package/src/http.ts +60 -0
  14. package/src/index.ts +11 -0
  15. package/src/serialization.ts +75 -0
  16. package/src/server.ts +874 -0
  17. package/src/sqlite-store.ts +400 -0
  18. package/src/store.ts +98 -0
  19. package/src/types/core.ts +322 -0
  20. package/src/types/server.ts +396 -0
  21. package/src/widgets/components.ts +188 -0
  22. package/src/widgets/diff.ts +151 -0
  23. package/src/widgets/index.ts +6 -0
  24. package/src/widgets/serialization.ts +46 -0
  25. package/src/widgets/stream.ts +104 -0
  26. package/src/widgets/template.ts +180 -0
  27. package/src/widgets/types.ts +52 -0
  28. package/types/actions.d.ts +19 -0
  29. package/types/agents/accumulate.d.ts +4 -0
  30. package/types/agents/annotations.d.ts +21 -0
  31. package/types/agents/context.d.ts +35 -0
  32. package/types/agents/converter.d.ts +60 -0
  33. package/types/agents/index.d.ts +9 -0
  34. package/types/agents/stream.d.ts +4 -0
  35. package/types/agents/types.d.ts +26 -0
  36. package/types/agents/workflows.d.ts +34 -0
  37. package/types/errors.d.ts +11 -0
  38. package/types/http.d.ts +6 -0
  39. package/types/index.d.ts +11 -0
  40. package/types/serialization.d.ts +8 -0
  41. package/types/server.d.ts +73 -0
  42. package/types/sqlite-store.d.ts +43 -0
  43. package/types/store.d.ts +45 -0
  44. package/types/types/core.d.ts +1220 -0
  45. package/types/types/server.d.ts +5841 -0
  46. package/types/widgets/components.d.ts +144 -0
  47. package/types/widgets/diff.d.ts +7 -0
  48. package/types/widgets/index.d.ts +6 -0
  49. package/types/widgets/serialization.d.ts +2 -0
  50. package/types/widgets/stream.d.ts +10 -0
  51. package/types/widgets/template.d.ts +19 -0
  52. package/types/widgets/types.d.ts +24 -0
@@ -0,0 +1,188 @@
1
+ import type { ActionConfig } from "../actions";
2
+ import type { DynamicWidgetComponent, DynamicWidgetRoot, WidgetJson } from "./types";
3
+
4
+ type ActionConfigInput = {
5
+ type: ActionConfig["type"];
6
+ payload?: WidgetJson;
7
+ handler?: ActionConfig["handler"];
8
+ loadingBehavior?: ActionConfig["loadingBehavior"];
9
+ streaming?: ActionConfig["streaming"];
10
+ [key: string]: WidgetJson | undefined;
11
+ };
12
+
13
+ type WidgetPropValue =
14
+ | WidgetJson
15
+ | ActionConfigInput
16
+ | DynamicWidgetComponent
17
+ | DynamicWidgetComponent[]
18
+ | undefined;
19
+ type WidgetProps = Record<string, WidgetPropValue>;
20
+
21
+ function component<TType extends string, TProps extends WidgetProps>(
22
+ type: TType,
23
+ props: TProps,
24
+ ): { type: TType } & TProps {
25
+ return { ...props, type };
26
+ }
27
+
28
+ type Children = DynamicWidgetComponent[];
29
+ type SingleChild = DynamicWidgetComponent;
30
+
31
+ export type BasicProps = WidgetProps & { children?: Children };
32
+ export function Basic(props: BasicProps = {}): DynamicWidgetRoot {
33
+ return component("Basic", props) as DynamicWidgetRoot;
34
+ }
35
+
36
+ export type CardProps = WidgetProps & {
37
+ children: Children;
38
+ asForm?: boolean;
39
+ confirm?: WidgetJson;
40
+ cancel?: WidgetJson;
41
+ };
42
+ export function Card(props: CardProps): DynamicWidgetRoot {
43
+ return component("Card", props) as DynamicWidgetRoot;
44
+ }
45
+
46
+ export type ListViewProps = WidgetProps & { children: DynamicWidgetComponent[] };
47
+ export function ListView(props: ListViewProps): DynamicWidgetRoot {
48
+ return component("ListView", props) as DynamicWidgetRoot;
49
+ }
50
+
51
+ export type ListViewItemProps = WidgetProps & {
52
+ children: Children;
53
+ onClickAction?: ActionConfigInput;
54
+ };
55
+ export function ListViewItem(props: ListViewItemProps): DynamicWidgetComponent {
56
+ return component("ListViewItem", props);
57
+ }
58
+
59
+ export type TextProps = WidgetProps & { value: string; streaming?: boolean };
60
+ export type TextWidget = { type: "Text" } & TextProps;
61
+ export function Text(props: TextProps): TextWidget {
62
+ return component("Text", props);
63
+ }
64
+
65
+ export type MarkdownProps = WidgetProps & { value: string; streaming?: boolean };
66
+ export type MarkdownWidget = { type: "Markdown" } & MarkdownProps;
67
+ export function Markdown(props: MarkdownProps): MarkdownWidget {
68
+ return component("Markdown", props);
69
+ }
70
+
71
+ export type TitleProps = WidgetProps & { value: string };
72
+ export function Title(props: TitleProps): DynamicWidgetComponent {
73
+ return component("Title", props);
74
+ }
75
+
76
+ export type CaptionProps = WidgetProps & { value: string };
77
+ export function Caption(props: CaptionProps): DynamicWidgetComponent {
78
+ return component("Caption", props);
79
+ }
80
+
81
+ export type BadgeProps = WidgetProps & { label: string };
82
+ export function Badge(props: BadgeProps): DynamicWidgetComponent {
83
+ return component("Badge", props);
84
+ }
85
+
86
+ export type BoxProps = WidgetProps & { children?: Children };
87
+ export function Box(props: BoxProps = {}): DynamicWidgetComponent {
88
+ return component("Box", props);
89
+ }
90
+
91
+ export type RowProps = WidgetProps & { children?: Children };
92
+ export function Row(props: RowProps = {}): DynamicWidgetComponent {
93
+ return component("Row", props);
94
+ }
95
+
96
+ export type ColProps = WidgetProps & { children?: Children };
97
+ export function Col(props: ColProps = {}): DynamicWidgetComponent {
98
+ return component("Col", props);
99
+ }
100
+
101
+ export type FormProps = WidgetProps & {
102
+ children?: Children;
103
+ onSubmitAction?: ActionConfigInput;
104
+ };
105
+ export function Form(props: FormProps = {}): DynamicWidgetComponent {
106
+ return component("Form", props);
107
+ }
108
+
109
+ export function Divider(props: WidgetProps = {}): DynamicWidgetComponent {
110
+ return component("Divider", props);
111
+ }
112
+
113
+ export type IconProps = WidgetProps & { name: string };
114
+ export function Icon(props: IconProps): DynamicWidgetComponent {
115
+ return component("Icon", props);
116
+ }
117
+
118
+ export type ImageProps = WidgetProps & { src: string; alt?: string };
119
+ export function Image(props: ImageProps): DynamicWidgetComponent {
120
+ return component("Image", props);
121
+ }
122
+
123
+ export type ButtonProps = WidgetProps & {
124
+ label?: string;
125
+ onClickAction?: ActionConfigInput;
126
+ };
127
+ export function Button(props: ButtonProps = {}): DynamicWidgetComponent {
128
+ return component("Button", props);
129
+ }
130
+
131
+ export function Spacer(props: WidgetProps = {}): DynamicWidgetComponent {
132
+ return component("Spacer", props);
133
+ }
134
+
135
+ export type SelectProps = WidgetProps & { name: string; options: WidgetJson[] };
136
+ export function Select(props: SelectProps): DynamicWidgetComponent {
137
+ return component("Select", props);
138
+ }
139
+
140
+ export type DatePickerProps = WidgetProps & { name: string };
141
+ export function DatePicker(props: DatePickerProps): DynamicWidgetComponent {
142
+ return component("DatePicker", props);
143
+ }
144
+
145
+ export type CheckboxProps = WidgetProps & { name: string; label?: string };
146
+ export function Checkbox(props: CheckboxProps): DynamicWidgetComponent {
147
+ return component("Checkbox", props);
148
+ }
149
+
150
+ export type InputProps = WidgetProps & { name: string };
151
+ export function Input(props: InputProps): DynamicWidgetComponent {
152
+ return component("Input", props);
153
+ }
154
+
155
+ export type LabelProps = WidgetProps & { value: string; fieldName: string };
156
+ export function Label(props: LabelProps): DynamicWidgetComponent {
157
+ return component("Label", props);
158
+ }
159
+
160
+ export type RadioGroupProps = WidgetProps & { name: string; options?: WidgetJson[] };
161
+ export function RadioGroup(props: RadioGroupProps): DynamicWidgetComponent {
162
+ return component("RadioGroup", props);
163
+ }
164
+
165
+ export type TextareaProps = WidgetProps & { name: string };
166
+ export function Textarea(props: TextareaProps): DynamicWidgetComponent {
167
+ return component("Textarea", props);
168
+ }
169
+
170
+ export type TransitionProps = WidgetProps & { children: SingleChild };
171
+ export function Transition(props: TransitionProps): DynamicWidgetComponent {
172
+ return component("Transition", props);
173
+ }
174
+
175
+ export type ChartProps = WidgetProps & {
176
+ data: WidgetJson[];
177
+ series: WidgetJson[];
178
+ xAxis: string;
179
+ showYAxis?: boolean;
180
+ showLegend?: boolean;
181
+ showTooltip?: boolean;
182
+ barGap?: string | number;
183
+ barCategoryGap?: string | number;
184
+ flex?: string | number;
185
+ };
186
+ export function Chart(props: ChartProps): DynamicWidgetComponent {
187
+ return component("Chart", props);
188
+ }
@@ -0,0 +1,151 @@
1
+ import type { ThreadItemUpdate } from "../types/server";
2
+ import { serializeWidget } from "./serialization";
3
+ import type { WidgetRoot } from "./types";
4
+
5
+ type WidgetNode = Record<string, unknown>;
6
+ type WidgetDiffUpdate = Extract<
7
+ ThreadItemUpdate,
8
+ { type: "widget.root.updated" | "widget.streaming_text.value_delta" }
9
+ >;
10
+
11
+ function asNode(value: unknown): WidgetNode {
12
+ return value != null && typeof value === "object" && !Array.isArray(value)
13
+ ? (value as WidgetNode)
14
+ : {};
15
+ }
16
+
17
+ function childrenOf(node: WidgetNode): WidgetNode[] {
18
+ const children = node.children;
19
+ if (Array.isArray(children)) return children.map(asNode);
20
+ if (children != null && typeof children === "object") return [asNode(children)];
21
+ return [];
22
+ }
23
+
24
+ function isStreamingText(node: WidgetNode): boolean {
25
+ return (node.type === "Text" || node.type === "Markdown") && typeof node.value === "string";
26
+ }
27
+
28
+ function jsonEqual(before: unknown, after: unknown): boolean {
29
+ return JSON.stringify(before) === JSON.stringify(after);
30
+ }
31
+
32
+ function isObjectRecord(value: unknown): value is WidgetNode {
33
+ return value != null && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+
36
+ function canValidateStreamingTextUpdate(before: WidgetNode, after: WidgetNode): boolean {
37
+ return (
38
+ isStreamingText(before) &&
39
+ isStreamingText(after) &&
40
+ typeof after.id === "string" &&
41
+ (before.id === after.id || before.id === undefined)
42
+ );
43
+ }
44
+
45
+ function valueRequiresFullReplace(before: unknown, after: unknown, isWidgetNode: boolean): boolean {
46
+ if (Array.isArray(before) && Array.isArray(after)) {
47
+ if (before.length !== after.length) return true;
48
+ for (let index = 0; index < before.length; index += 1) {
49
+ if (valueRequiresFullReplace(before[index], after[index], isWidgetNode)) return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ if (isObjectRecord(before) && isObjectRecord(after)) {
55
+ return requiresFullReplace(before, after, isWidgetNode);
56
+ }
57
+
58
+ return !jsonEqual(before, after);
59
+ }
60
+
61
+ function requiresFullReplace(before: WidgetNode, after: WidgetNode, isWidgetNode: boolean): boolean {
62
+ if (before.type !== after.type || before.key !== after.key) {
63
+ return true;
64
+ }
65
+
66
+ const canValidateStreamingUpdate = isWidgetNode && canValidateStreamingTextUpdate(before, after);
67
+ if (before.id !== after.id && !canValidateStreamingUpdate) {
68
+ return true;
69
+ }
70
+
71
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
72
+ for (const key of keys) {
73
+ const beforeValue = before[key];
74
+ const afterValue = after[key];
75
+
76
+ if (canValidateStreamingUpdate && (key === "id" || key === "value")) continue;
77
+ if (valueRequiresFullReplace(beforeValue, afterValue, isWidgetNode && key === "children")) {
78
+ return true;
79
+ }
80
+ }
81
+
82
+ return false;
83
+ }
84
+
85
+ function streamingTextById(root: WidgetNode): Map<string, WidgetNode> {
86
+ const nodes = new Map<string, WidgetNode>();
87
+
88
+ function visit(node: WidgetNode): void {
89
+ if (isStreamingText(node) && typeof node.id === "string") {
90
+ nodes.set(node.id, node);
91
+ }
92
+ for (const child of childrenOf(node)) visit(child);
93
+ }
94
+
95
+ visit(root);
96
+ return nodes;
97
+ }
98
+
99
+ function validateStreamingTextUpdates(
100
+ beforeNodes: Map<string, WidgetNode>,
101
+ afterNodes: Map<string, WidgetNode>,
102
+ ): void {
103
+ for (const [id, afterNode] of afterNodes) {
104
+ const beforeNode = beforeNodes.get(id);
105
+ if (!beforeNode) {
106
+ throw new Error(
107
+ `Node ${id} was not present when the widget was initially rendered. All nodes with ID must persist across all widget updates.`,
108
+ );
109
+ }
110
+
111
+ const beforeValue = String(beforeNode.value);
112
+ const afterValue = String(afterNode.value);
113
+ if (!afterValue.startsWith(beforeValue)) {
114
+ throw new Error(
115
+ `Node ${id} was updated with a new value that is not a prefix of the initial value. All widget updates must be cumulative.`,
116
+ );
117
+ }
118
+ }
119
+ }
120
+
121
+ export function diffWidget(before: WidgetRoot, after: WidgetRoot): WidgetDiffUpdate[] {
122
+ const beforeRoot = serializeWidget(before);
123
+ const afterRoot = serializeWidget(after);
124
+ const beforeNodes = streamingTextById(beforeRoot);
125
+ const afterNodes = streamingTextById(afterRoot);
126
+
127
+ if (requiresFullReplace(beforeRoot, afterRoot, true)) {
128
+ return [{ type: "widget.root.updated", widget: afterRoot }];
129
+ }
130
+
131
+ validateStreamingTextUpdates(beforeNodes, afterNodes);
132
+
133
+ const updates: WidgetDiffUpdate[] = [];
134
+ for (const [id, afterNode] of afterNodes) {
135
+ const beforeNode = beforeNodes.get(id);
136
+ if (!beforeNode) continue;
137
+
138
+ const beforeValue = String(beforeNode.value);
139
+ const afterValue = String(afterNode.value);
140
+ if (beforeValue === afterValue) continue;
141
+
142
+ updates.push({
143
+ type: "widget.streaming_text.value_delta",
144
+ component_id: id,
145
+ delta: afterValue.slice(beforeValue.length),
146
+ done: afterNode.streaming !== true,
147
+ });
148
+ }
149
+
150
+ return updates;
151
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./components";
2
+ export * from "./diff";
3
+ export * from "./serialization";
4
+ export * from "./stream";
5
+ export * from "./template";
6
+ export * from "./types";
@@ -0,0 +1,46 @@
1
+ import { DynamicWidgetRootSchema, type WidgetJson, type WidgetRoot } from "./types";
2
+
3
+ function omitUndefinedDeep(value: unknown): WidgetJson {
4
+ if (Array.isArray(value)) {
5
+ return value
6
+ .filter((item) => item !== undefined)
7
+ .map((item) => omitUndefinedDeep(item)) as WidgetJson[];
8
+ }
9
+
10
+ if (value != null && typeof value === "object") {
11
+ const source =
12
+ typeof (value as { toJSON?: () => unknown }).toJSON === "function"
13
+ ? (value as { toJSON: () => unknown }).toJSON()
14
+ : value;
15
+
16
+ if (source == null || typeof source !== "object" || Array.isArray(source)) {
17
+ return omitUndefinedDeep(source);
18
+ }
19
+
20
+ const entries = Object.entries(source as Record<string, unknown>)
21
+ .filter(([, entryValue]) => entryValue !== undefined)
22
+ .map(([key, entryValue]) => [key, omitUndefinedDeep(entryValue)] as const);
23
+
24
+ return Object.fromEntries(entries) as WidgetJson;
25
+ }
26
+
27
+ if (
28
+ typeof value === "string" ||
29
+ typeof value === "number" ||
30
+ typeof value === "boolean" ||
31
+ value === null
32
+ ) {
33
+ return value;
34
+ }
35
+
36
+ throw new TypeError(`Unsupported widget value: ${String(value)}`);
37
+ }
38
+
39
+ export function serializeWidget(widget: WidgetRoot): Record<string, unknown> {
40
+ const parsed = DynamicWidgetRootSchema.parse(widget);
41
+ const serialized = omitUndefinedDeep(parsed);
42
+ if (serialized == null || typeof serialized !== "object" || Array.isArray(serialized)) {
43
+ throw new TypeError("Widget root must serialize to an object.");
44
+ }
45
+ return serialized as Record<string, unknown>;
46
+ }
@@ -0,0 +1,104 @@
1
+ import type { StoreItemType } from "../store";
2
+ import { defaultGenerateId } from "../store";
3
+ import type { ThreadItem, ThreadMetadata } from "../types/core";
4
+ import type { ThreadStreamEvent } from "../types/server";
5
+ import { diffWidget } from "./diff";
6
+ import { serializeWidget } from "./serialization";
7
+ import type { WidgetRoot } from "./types";
8
+
9
+ export interface StreamWidgetOptions {
10
+ copyText?: string | null;
11
+ generateId?: (itemType: StoreItemType) => string;
12
+ now?: () => string;
13
+ }
14
+
15
+ type ResolvedStreamWidgetOptions = Required<Pick<StreamWidgetOptions, "generateId" | "now">> &
16
+ Pick<StreamWidgetOptions, "copyText">;
17
+
18
+ function isAsyncIterable(value: unknown): value is AsyncIterable<WidgetRoot> {
19
+ return (
20
+ value != null &&
21
+ typeof (value as Partial<AsyncIterable<WidgetRoot>>)[Symbol.asyncIterator] === "function"
22
+ );
23
+ }
24
+
25
+ function makeWidgetItem(
26
+ thread: ThreadMetadata,
27
+ itemId: string,
28
+ createdAt: string,
29
+ widget: WidgetRoot,
30
+ options: ResolvedStreamWidgetOptions,
31
+ ): ThreadItem {
32
+ return {
33
+ id: itemId,
34
+ type: "widget",
35
+ thread_id: thread.id,
36
+ created_at: createdAt,
37
+ widget: serializeWidget(widget),
38
+ copy_text: options.copyText ?? undefined,
39
+ };
40
+ }
41
+
42
+ export async function* streamWidget(
43
+ thread: ThreadMetadata,
44
+ widgetOrAsyncIterable: WidgetRoot | AsyncIterable<WidgetRoot>,
45
+ options: StreamWidgetOptions = {},
46
+ ): AsyncIterable<ThreadStreamEvent> {
47
+ const resolvedOptions: ResolvedStreamWidgetOptions = {
48
+ generateId: options.generateId ?? defaultGenerateId,
49
+ now: options.now ?? (() => new Date().toISOString()),
50
+ copyText: options.copyText,
51
+ };
52
+ const itemId = resolvedOptions.generateId("message");
53
+ const createdAt = resolvedOptions.now();
54
+
55
+ if (!isAsyncIterable(widgetOrAsyncIterable)) {
56
+ yield {
57
+ type: "thread.item.done",
58
+ item: makeWidgetItem(thread, itemId, createdAt, widgetOrAsyncIterable, resolvedOptions),
59
+ };
60
+ return;
61
+ }
62
+
63
+ const iterator = widgetOrAsyncIterable[Symbol.asyncIterator]();
64
+ let completed = false;
65
+ try {
66
+ const first = await iterator.next();
67
+ if (first.done) {
68
+ completed = true;
69
+ throw new Error("streamWidget async iterable must yield an initial widget.");
70
+ }
71
+
72
+ let lastState = first.value;
73
+ yield {
74
+ type: "thread.item.added",
75
+ item: makeWidgetItem(thread, itemId, createdAt, lastState, resolvedOptions),
76
+ };
77
+
78
+ for (;;) {
79
+ const next = await iterator.next();
80
+ if (next.done) {
81
+ completed = true;
82
+ break;
83
+ }
84
+
85
+ for (const update of diffWidget(lastState, next.value)) {
86
+ yield {
87
+ type: "thread.item.updated",
88
+ item_id: itemId,
89
+ update,
90
+ };
91
+ }
92
+ lastState = next.value;
93
+ }
94
+
95
+ yield {
96
+ type: "thread.item.done",
97
+ item: makeWidgetItem(thread, itemId, createdAt, lastState, resolvedOptions),
98
+ };
99
+ } finally {
100
+ if (!completed) {
101
+ await iterator.return?.();
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,180 @@
1
+ import { dirname, isAbsolute, join } from "node:path";
2
+
3
+ import nunjucks from "nunjucks";
4
+
5
+ import { serializeWidget } from "./serialization";
6
+ import {
7
+ BasicRootSchema,
8
+ DynamicWidgetRootSchema,
9
+ type BasicRoot,
10
+ type DynamicWidgetRoot,
11
+ } from "./types";
12
+
13
+ const env = new nunjucks.Environment(undefined, {
14
+ autoescape: false,
15
+ throwOnUndefined: true,
16
+ });
17
+
18
+ env.addFilter("tojson", (value: unknown) => {
19
+ if (value === undefined) {
20
+ throw new Error("Missing template variable.");
21
+ }
22
+ return JSON.stringify(value);
23
+ });
24
+
25
+ export interface WidgetTemplateDefinition {
26
+ version: string;
27
+ name: string;
28
+ template: string;
29
+ dataSchema?: Record<string, unknown>;
30
+ jsonSchema?: Record<string, unknown>;
31
+ }
32
+
33
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
34
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
35
+ return false;
36
+ }
37
+
38
+ const prototype = Object.getPrototypeOf(value);
39
+ return prototype === Object.prototype || prototype === null;
40
+ }
41
+
42
+ function callerDirectory(): string | null {
43
+ const stack = new Error().stack ?? "";
44
+ const lines = stack.split("\n").slice(2);
45
+
46
+ for (const line of lines) {
47
+ const match = line.match(/\(?((?:file:\/\/)?\/[^:)]+):\d+:\d+\)?/);
48
+ if (!match) continue;
49
+
50
+ const matchedPath = match[1];
51
+ if (matchedPath == null) continue;
52
+
53
+ const path = decodeURI(matchedPath.replace(/^file:\/\//, ""));
54
+ if (!path.includes("/src/widgets/template.")) {
55
+ return dirname(path);
56
+ }
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ function resolveWidgetPath(path: string): string {
63
+ if (isAbsolute(path)) {
64
+ return path;
65
+ }
66
+
67
+ const callerPath = callerDirectory();
68
+ if (callerPath != null) {
69
+ return join(callerPath, path);
70
+ }
71
+
72
+ return join(process.cwd(), path);
73
+ }
74
+
75
+ function normalizeData(data: unknown): Record<string, unknown> {
76
+ if (data == null) {
77
+ return {};
78
+ }
79
+
80
+ if (typeof data === "object" && typeof (data as { toJSON?: () => unknown }).toJSON === "function") {
81
+ const json = (data as { toJSON: () => unknown }).toJSON();
82
+ if (isPlainObject(json)) {
83
+ return json;
84
+ }
85
+ throw new TypeError("Widget template data toJSON() must return a plain object.");
86
+ }
87
+
88
+ if (isPlainObject(data)) {
89
+ return data;
90
+ }
91
+
92
+ throw new TypeError("Widget template data must be a plain object.");
93
+ }
94
+
95
+ function parseRenderedTemplate(rendered: string, name: string): unknown {
96
+ try {
97
+ return JSON.parse(rendered);
98
+ } catch (error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ throw new SyntaxError(`Rendered widget template "${name}" is not valid JSON: ${message}`);
101
+ }
102
+ }
103
+
104
+ function renderableTemplate(source: string): string {
105
+ let output = "";
106
+ let index = 0;
107
+
108
+ while (index < source.length) {
109
+ const expressionStart = source.indexOf("{{", index);
110
+ const blockStart = source.indexOf("{%", index);
111
+ const starts = [expressionStart, blockStart].filter((start) => start !== -1);
112
+ if (starts.length === 0) {
113
+ output += source.slice(index);
114
+ break;
115
+ }
116
+
117
+ const tagStart = Math.min(...starts);
118
+ const tagEndMarker = tagStart === expressionStart ? "}}" : "%}";
119
+ const tagEnd = source.indexOf(tagEndMarker, tagStart + 2);
120
+ if (tagEnd === -1) {
121
+ output += source.slice(index);
122
+ break;
123
+ }
124
+
125
+ const tagCloseEnd = tagEnd + tagEndMarker.length;
126
+ const tag = source
127
+ .slice(tagStart, tagCloseEnd)
128
+ .replace(/\b([A-Za-z_$][\w$]*)\[(\d+):\]/g, "$1.slice($2)");
129
+
130
+ output += source.slice(index, tagStart) + tag;
131
+ index = tagCloseEnd;
132
+ }
133
+
134
+ return output;
135
+ }
136
+
137
+ export class WidgetTemplate {
138
+ readonly version: string;
139
+ readonly name: string;
140
+ readonly template: string;
141
+ readonly dataSchema: Record<string, unknown>;
142
+ readonly jsonSchema?: Record<string, unknown>;
143
+
144
+ constructor(definition: WidgetTemplateDefinition) {
145
+ if (definition.version !== "1.0") {
146
+ throw new Error(`Unsupported widget spec version: ${definition.version}`);
147
+ }
148
+ if (typeof definition.name !== "string" || definition.name.length === 0) {
149
+ throw new TypeError("Widget template name is required.");
150
+ }
151
+ if (typeof definition.template !== "string" || definition.template.length === 0) {
152
+ throw new TypeError("Widget template source is required.");
153
+ }
154
+
155
+ this.version = definition.version;
156
+ this.name = definition.name;
157
+ this.template = definition.template;
158
+ this.jsonSchema = definition.jsonSchema;
159
+ this.dataSchema = definition.dataSchema ?? definition.jsonSchema ?? {};
160
+ }
161
+
162
+ static async fromFile(path: string): Promise<WidgetTemplate> {
163
+ const definition = await Bun.file(resolveWidgetPath(path)).json();
164
+ return new WidgetTemplate(definition as WidgetTemplateDefinition);
165
+ }
166
+
167
+ build(data?: unknown): DynamicWidgetRoot {
168
+ const rendered = env.renderString(renderableTemplate(this.template), normalizeData(data));
169
+ const parsed = parseRenderedTemplate(rendered, this.name);
170
+ const widget = DynamicWidgetRootSchema.parse(parsed);
171
+ return serializeWidget(widget) as DynamicWidgetRoot;
172
+ }
173
+
174
+ buildBasic(data?: unknown): BasicRoot {
175
+ const rendered = env.renderString(renderableTemplate(this.template), normalizeData(data));
176
+ const parsed = parseRenderedTemplate(rendered, this.name);
177
+ const widget = BasicRootSchema.parse(parsed);
178
+ return serializeWidget(widget) as BasicRoot;
179
+ }
180
+ }