@dex-ai/vue-tui 0.1.10
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/package.json +51 -0
- package/src/app.ts +385 -0
- package/src/components/TuiCheckbox.ts +35 -0
- package/src/components/TuiField.ts +123 -0
- package/src/components/TuiSelect.ts +86 -0
- package/src/components/index.ts +7 -0
- package/src/composables/index.ts +19 -0
- package/src/composables/useFocusList.ts +101 -0
- package/src/composables/useForm.ts +335 -0
- package/src/composables/useSelectList.ts +199 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +131 -0
- package/src/input.ts +603 -0
- package/src/nodes.ts +153 -0
- package/src/panel.ts +51 -0
- package/src/plugin.ts +148 -0
- package/src/register.ts +4 -0
- package/src/render.ts +632 -0
- package/src/renderer.ts +120 -0
- package/src/style.ts +107 -0
- package/src/template.ts +326 -0
- package/src/text-buffer.ts +609 -0
- package/src/theme.ts +44 -0
- package/src/types/form.ts +90 -0
- package/src/widget-renderer.ts +237 -0
- package/src/widget.ts +326 -0
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createRenderer } from "@vue/runtime-core";
|
|
2
|
+
import type { RendererOptions } from "@vue/runtime-core";
|
|
3
|
+
import {
|
|
4
|
+
createElement,
|
|
5
|
+
createTextNode,
|
|
6
|
+
createComment,
|
|
7
|
+
insertChild,
|
|
8
|
+
removeChild,
|
|
9
|
+
} from "./nodes";
|
|
10
|
+
import type { TNode, TElement, TText } from "./nodes";
|
|
11
|
+
|
|
12
|
+
// Vue's RendererOptions<HostNode, HostElement>
|
|
13
|
+
type Options = RendererOptions<TNode, TElement>;
|
|
14
|
+
|
|
15
|
+
function buildOptions(onUpdate: () => void): Options {
|
|
16
|
+
return {
|
|
17
|
+
// -----------------------------------------------------------------------
|
|
18
|
+
// Element creation
|
|
19
|
+
// -----------------------------------------------------------------------
|
|
20
|
+
createElement(type: string): TElement {
|
|
21
|
+
return createElement(type);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
createText(text: string): TText {
|
|
25
|
+
return createTextNode(text);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
createComment(text: string) {
|
|
29
|
+
return createComment(text);
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// -----------------------------------------------------------------------
|
|
33
|
+
// Text / props mutation
|
|
34
|
+
// -----------------------------------------------------------------------
|
|
35
|
+
setText(node: TNode, text: string): void {
|
|
36
|
+
if (node.type === "text") {
|
|
37
|
+
(node as TText).text = text;
|
|
38
|
+
onUpdate();
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
setElementText(el: TElement, text: string): void {
|
|
43
|
+
// Replace all children with a single text node
|
|
44
|
+
el.children = [];
|
|
45
|
+
const textNode = createTextNode(text);
|
|
46
|
+
textNode.parent = el;
|
|
47
|
+
el.children.push(textNode);
|
|
48
|
+
onUpdate();
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
patchProp(
|
|
52
|
+
el: TElement,
|
|
53
|
+
key: string,
|
|
54
|
+
_prevValue: unknown,
|
|
55
|
+
nextValue: unknown,
|
|
56
|
+
): void {
|
|
57
|
+
el.props[key] = nextValue;
|
|
58
|
+
onUpdate();
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// -----------------------------------------------------------------------
|
|
62
|
+
// Tree mutations
|
|
63
|
+
// -----------------------------------------------------------------------
|
|
64
|
+
insert(child: TNode, parent: TElement, anchor: TNode | null = null): void {
|
|
65
|
+
insertChild(parent, child, anchor);
|
|
66
|
+
onUpdate();
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
remove(child: TNode): void {
|
|
70
|
+
if (child.parent !== null) {
|
|
71
|
+
removeChild(child.parent, child);
|
|
72
|
+
onUpdate();
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// -----------------------------------------------------------------------
|
|
77
|
+
// Queries (Vue uses these to walk the DOM)
|
|
78
|
+
// -----------------------------------------------------------------------
|
|
79
|
+
parentNode(node: TNode): TElement | null {
|
|
80
|
+
return node.parent;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
nextSibling(node: TNode): TNode | null {
|
|
84
|
+
return node.nextSibling;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
querySelector(_selector: string): TElement | null {
|
|
88
|
+
// Not needed for terminal rendering
|
|
89
|
+
return null;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
setScopeId(el: TElement, id: string): void {
|
|
93
|
+
el.props["data-v-" + id] = true;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// -----------------------------------------------------------------------
|
|
97
|
+
// Clone (used for static hoisting)
|
|
98
|
+
// -----------------------------------------------------------------------
|
|
99
|
+
cloneNode(node: TNode): TNode {
|
|
100
|
+
if (node.type === "text") {
|
|
101
|
+
return createTextNode((node as TText).text);
|
|
102
|
+
}
|
|
103
|
+
if (node.type === "comment") {
|
|
104
|
+
return createComment(String(node["text"] ?? ""));
|
|
105
|
+
}
|
|
106
|
+
const el = node as TElement;
|
|
107
|
+
const cloned = createElement(el.tag, { ...el.props });
|
|
108
|
+
for (const child of el.children) {
|
|
109
|
+
const clonedChild = this.cloneNode!(child) as TNode;
|
|
110
|
+
insertChild(cloned, clonedChild, null);
|
|
111
|
+
}
|
|
112
|
+
return cloned;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createTerminalRenderer(onUpdate: () => void) {
|
|
118
|
+
const { createApp } = createRenderer<TNode, TElement>(buildOptions(onUpdate));
|
|
119
|
+
return { createApp };
|
|
120
|
+
}
|
package/src/style.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** The full CSS-like style interface for terminal elements. */
|
|
2
|
+
export interface TerminalStyle {
|
|
3
|
+
// Text
|
|
4
|
+
fontWeight?: "bold" | "normal" | undefined;
|
|
5
|
+
fontStyle?: "italic" | "normal" | undefined;
|
|
6
|
+
textDecoration?: "underline" | "line-through" | "none" | undefined;
|
|
7
|
+
opacity?: number | undefined; // 1 = normal, 0.5 = dim
|
|
8
|
+
|
|
9
|
+
// Colors — named colors from theme, or raw ANSI codes
|
|
10
|
+
color?: string | undefined;
|
|
11
|
+
backgroundColor?: string | undefined;
|
|
12
|
+
|
|
13
|
+
// Sizing (chars for width, lines for height)
|
|
14
|
+
width?: number | "100%" | "auto" | undefined;
|
|
15
|
+
minWidth?: number | undefined;
|
|
16
|
+
maxWidth?: number | undefined;
|
|
17
|
+
height?: number | "100%" | "auto" | undefined;
|
|
18
|
+
minHeight?: number | undefined;
|
|
19
|
+
maxHeight?: number | undefined;
|
|
20
|
+
|
|
21
|
+
// Spacing (chars horizontal, lines vertical)
|
|
22
|
+
marginTop?: number | undefined;
|
|
23
|
+
marginBottom?: number | undefined;
|
|
24
|
+
marginLeft?: number | undefined;
|
|
25
|
+
marginRight?: number | undefined;
|
|
26
|
+
paddingTop?: number | undefined;
|
|
27
|
+
paddingBottom?: number | undefined;
|
|
28
|
+
paddingLeft?: number | undefined;
|
|
29
|
+
paddingRight?: number | undefined;
|
|
30
|
+
|
|
31
|
+
// Border
|
|
32
|
+
borderTop?: "solid" | "none" | undefined;
|
|
33
|
+
borderBottom?: "solid" | "none" | undefined;
|
|
34
|
+
borderLeft?: "solid" | "heavy" | "none" | undefined;
|
|
35
|
+
borderRight?: "solid" | "heavy" | "none" | undefined;
|
|
36
|
+
borderColor?: string | undefined;
|
|
37
|
+
borderTopColor?: string | undefined;
|
|
38
|
+
borderBottomColor?: string | undefined;
|
|
39
|
+
borderLeftColor?: string | undefined;
|
|
40
|
+
borderRightColor?: string | undefined;
|
|
41
|
+
|
|
42
|
+
// Layout
|
|
43
|
+
display?: "block" | "inline" | "none" | undefined;
|
|
44
|
+
|
|
45
|
+
// Text wrapping
|
|
46
|
+
whiteSpace?: "normal" | "nowrap" | undefined;
|
|
47
|
+
|
|
48
|
+
// Overflow
|
|
49
|
+
overflow?: "hidden" | "scroll" | "collapse" | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Theme {
|
|
53
|
+
colors: Record<string, string>; // name → ANSI code
|
|
54
|
+
symbols: {
|
|
55
|
+
divider: string; // "─"
|
|
56
|
+
prompt: string; // "❯"
|
|
57
|
+
bullet: string; // "●"
|
|
58
|
+
spinner: string[]; // ["◆", "◇"]
|
|
59
|
+
};
|
|
60
|
+
reset: string; // "\x1b[0m"
|
|
61
|
+
bold: string; // "\x1b[1m"
|
|
62
|
+
dim: string; // "\x1b[2m"
|
|
63
|
+
italic: string; // "\x1b[3m"
|
|
64
|
+
underline: string; // "\x1b[4m"
|
|
65
|
+
strikethrough: string; // "\x1b[9m"
|
|
66
|
+
reverse: string; // "\x1b[7m"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolve a color string (theme name or raw ANSI) to an ANSI escape. */
|
|
70
|
+
export function resolveColor(color: string | undefined, theme: Theme): string {
|
|
71
|
+
if (color === undefined) return "";
|
|
72
|
+
// If it starts with \x1b it's already a raw ANSI escape
|
|
73
|
+
if (color.startsWith("\x1b")) return color;
|
|
74
|
+
// Look up in theme colors
|
|
75
|
+
const resolved = theme.colors[color];
|
|
76
|
+
if (resolved !== undefined) return resolved;
|
|
77
|
+
// Return as-is (e.g. user passed a raw escape without \x1b prefix — unlikely but safe)
|
|
78
|
+
return color;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve a TerminalStyle into ANSI open/close codes for inline text. */
|
|
82
|
+
export function resolveTextStyle(
|
|
83
|
+
style: TerminalStyle,
|
|
84
|
+
theme: Theme,
|
|
85
|
+
): { open: string; close: string } {
|
|
86
|
+
let open = "";
|
|
87
|
+
const needsReset =
|
|
88
|
+
style.fontWeight === "bold" ||
|
|
89
|
+
style.fontStyle === "italic" ||
|
|
90
|
+
style.textDecoration === "underline" ||
|
|
91
|
+
style.textDecoration === "line-through" ||
|
|
92
|
+
style.opacity !== undefined ||
|
|
93
|
+
style.color !== undefined ||
|
|
94
|
+
style.backgroundColor !== undefined;
|
|
95
|
+
|
|
96
|
+
if (style.fontWeight === "bold") open += theme.bold;
|
|
97
|
+
if (style.fontStyle === "italic") open += theme.italic;
|
|
98
|
+
if (style.textDecoration === "underline") open += theme.underline;
|
|
99
|
+
if (style.textDecoration === "line-through") open += theme.strikethrough;
|
|
100
|
+
if (style.opacity !== undefined && style.opacity < 1) open += theme.dim;
|
|
101
|
+
if (style.color !== undefined) open += resolveColor(style.color, theme);
|
|
102
|
+
if (style.backgroundColor !== undefined)
|
|
103
|
+
open += resolveColor(style.backgroundColor, theme);
|
|
104
|
+
|
|
105
|
+
const close = needsReset ? theme.reset : "";
|
|
106
|
+
return { open, close };
|
|
107
|
+
}
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { h } from "@vue/runtime-core";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Tagged template for TUI components
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const PLACEHOLDER_PREFIX = "\x00__TUI_";
|
|
8
|
+
const PLACEHOLDER_SUFFIX = "__\x00";
|
|
9
|
+
const PLACEHOLDER_RE = /\x00__TUI_(\d+)__\x00/g;
|
|
10
|
+
|
|
11
|
+
// Void elements that don't need closing tags
|
|
12
|
+
const VOID_ELEMENTS = new Set(["spacer"]);
|
|
13
|
+
|
|
14
|
+
// Shorthand attribute → style mapping
|
|
15
|
+
const STYLE_SHORTHANDS: Record<string, (value: string | true) => Record<string, unknown>> = {
|
|
16
|
+
color: (v) => ({ color: v }),
|
|
17
|
+
bg: (v) => ({ backgroundColor: typeof v === "string" ? (v.startsWith("bg-") ? v : `bg-${v}`) : v }),
|
|
18
|
+
bold: () => ({ fontWeight: "bold" }),
|
|
19
|
+
italic: () => ({ fontStyle: "italic" }),
|
|
20
|
+
underline: () => ({ textDecoration: "underline" }),
|
|
21
|
+
strikethrough: () => ({ textDecoration: "line-through" }),
|
|
22
|
+
dim: () => ({ opacity: 0.5 }),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Parser types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface ParsedNode {
|
|
30
|
+
type: "element" | "text";
|
|
31
|
+
tag?: string;
|
|
32
|
+
attrs?: Record<string, unknown>;
|
|
33
|
+
children?: ParsedNode[];
|
|
34
|
+
text?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// html tagged template
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Tagged template literal for TUI components.
|
|
43
|
+
*
|
|
44
|
+
* Usage:
|
|
45
|
+
* ```ts
|
|
46
|
+
* html`<line><text color="accent" bold>hello</text></line>`
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* Supports interpolations for dynamic values:
|
|
50
|
+
* ```ts
|
|
51
|
+
* html`<text cursorAt=${col}>${text}</text>`
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function html(strings: TemplateStringsArray, ...values: unknown[]): unknown {
|
|
55
|
+
// Build the raw string with placeholders for interpolated values
|
|
56
|
+
let raw = "";
|
|
57
|
+
for (let i = 0; i < strings.length; i++) {
|
|
58
|
+
raw += strings[i];
|
|
59
|
+
if (i < values.length) {
|
|
60
|
+
raw += `${PLACEHOLDER_PREFIX}${i}${PLACEHOLDER_SUFFIX}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const nodes = parse(raw.trim(), values);
|
|
65
|
+
|
|
66
|
+
if (nodes.length === 0) return null;
|
|
67
|
+
if (nodes.length === 1) return toVNode(nodes[0]!, values);
|
|
68
|
+
return nodes.map((n) => toVNode(n, values));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Parser
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function parse(input: string, values: unknown[]): ParsedNode[] {
|
|
76
|
+
let pos = 0;
|
|
77
|
+
const nodes: ParsedNode[] = [];
|
|
78
|
+
|
|
79
|
+
while (pos < input.length) {
|
|
80
|
+
if (input[pos] === "<") {
|
|
81
|
+
// Could be opening tag, closing tag, or self-closing
|
|
82
|
+
if (input[pos + 1] === "/") {
|
|
83
|
+
// Closing tag — stop parsing children (handled by caller)
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
const result = parseElement(input, pos, values);
|
|
87
|
+
if (result) {
|
|
88
|
+
nodes.push(result.node);
|
|
89
|
+
pos = result.end;
|
|
90
|
+
} else {
|
|
91
|
+
// Malformed — treat as text
|
|
92
|
+
const textEnd = input.indexOf("<", pos + 1);
|
|
93
|
+
const end = textEnd === -1 ? input.length : textEnd;
|
|
94
|
+
nodes.push({ type: "text", text: input.slice(pos, end) });
|
|
95
|
+
pos = end;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
// Text content
|
|
99
|
+
const textEnd = input.indexOf("<", pos);
|
|
100
|
+
const end = textEnd === -1 ? input.length : textEnd;
|
|
101
|
+
const text = input.slice(pos, end);
|
|
102
|
+
// Only keep text that has meaningful content or placeholders
|
|
103
|
+
// Strip pure whitespace between tags (indentation/newlines)
|
|
104
|
+
if (text.includes(PLACEHOLDER_PREFIX) || text.replace(/[\s\t\n\r]+/g, " ").trim()) {
|
|
105
|
+
nodes.push({ type: "text", text: text.replace(/[\s\t\n\r]+/g, " ") });
|
|
106
|
+
}
|
|
107
|
+
pos = end;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return nodes;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseElement(input: string, start: number, values: unknown[]): { node: ParsedNode; end: number } | null {
|
|
115
|
+
// Match opening tag: <tagName ...attrs...> or <tagName ...attrs... />
|
|
116
|
+
const tagMatch = input.slice(start).match(/^<([a-zA-Z][a-zA-Z0-9-]*)/);
|
|
117
|
+
if (!tagMatch) return null;
|
|
118
|
+
|
|
119
|
+
const tag = tagMatch[1]!;
|
|
120
|
+
let pos = start + tagMatch[0]!.length;
|
|
121
|
+
|
|
122
|
+
// Parse attributes
|
|
123
|
+
const attrs: Record<string, unknown> = {};
|
|
124
|
+
pos = parseAttributes(input, pos, attrs, values);
|
|
125
|
+
|
|
126
|
+
// Check for self-closing or void
|
|
127
|
+
const selfClose = input.slice(pos).startsWith("/>");
|
|
128
|
+
const isVoid = VOID_ELEMENTS.has(tag);
|
|
129
|
+
|
|
130
|
+
if (selfClose) {
|
|
131
|
+
pos += 2; // skip />
|
|
132
|
+
return { node: { type: "element", tag, attrs, children: [] }, end: pos };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Skip >
|
|
136
|
+
if (input[pos] === ">") {
|
|
137
|
+
pos += 1;
|
|
138
|
+
} else {
|
|
139
|
+
return null; // malformed
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (isVoid) {
|
|
143
|
+
return { node: { type: "element", tag, attrs, children: [] }, end: pos };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Parse children until closing tag
|
|
147
|
+
const children = parseChildren(input, pos, tag, values);
|
|
148
|
+
pos = children.end;
|
|
149
|
+
|
|
150
|
+
return { node: { type: "element", tag, attrs, children: children.nodes }, end: pos };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseChildren(input: string, start: number, parentTag: string, values: unknown[]): { nodes: ParsedNode[]; end: number } {
|
|
154
|
+
const closingTag = `</${parentTag}>`;
|
|
155
|
+
const nodes: ParsedNode[] = [];
|
|
156
|
+
let pos = start;
|
|
157
|
+
|
|
158
|
+
while (pos < input.length) {
|
|
159
|
+
// Check for closing tag
|
|
160
|
+
if (input.slice(pos).startsWith(closingTag)) {
|
|
161
|
+
return { nodes, end: pos + closingTag.length };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (input[pos] === "<") {
|
|
165
|
+
if (input[pos + 1] === "/") {
|
|
166
|
+
// Unexpected closing tag — stop
|
|
167
|
+
const endIdx = input.indexOf(">", pos);
|
|
168
|
+
return { nodes, end: endIdx === -1 ? input.length : endIdx + 1 };
|
|
169
|
+
}
|
|
170
|
+
const result = parseElement(input, pos, values);
|
|
171
|
+
if (result) {
|
|
172
|
+
nodes.push(result.node);
|
|
173
|
+
pos = result.end;
|
|
174
|
+
} else {
|
|
175
|
+
pos++;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
// Text content
|
|
179
|
+
const nextTag = input.indexOf("<", pos);
|
|
180
|
+
const end = nextTag === -1 ? input.length : nextTag;
|
|
181
|
+
const text = input.slice(pos, end);
|
|
182
|
+
// Strip pure whitespace between tags (indentation/newlines)
|
|
183
|
+
if (text.includes(PLACEHOLDER_PREFIX) || text.replace(/[\s\t\n\r]+/g, " ").trim()) {
|
|
184
|
+
nodes.push({ type: "text", text: text.replace(/[\s\t\n\r]+/g, " ") });
|
|
185
|
+
}
|
|
186
|
+
pos = end;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { nodes, end: pos };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseAttributes(input: string, start: number, attrs: Record<string, unknown>, values: unknown[]): number {
|
|
194
|
+
let pos = start;
|
|
195
|
+
|
|
196
|
+
while (pos < input.length) {
|
|
197
|
+
// Skip whitespace
|
|
198
|
+
while (pos < input.length && /\s/.test(input[pos]!)) pos++;
|
|
199
|
+
|
|
200
|
+
// End of attributes?
|
|
201
|
+
if (input[pos] === ">" || input[pos] === "/") break;
|
|
202
|
+
|
|
203
|
+
// Attribute name
|
|
204
|
+
const nameMatch = input.slice(pos).match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
|
205
|
+
if (!nameMatch) break;
|
|
206
|
+
|
|
207
|
+
const name = nameMatch[1]!;
|
|
208
|
+
pos += name.length;
|
|
209
|
+
|
|
210
|
+
// Check for = value
|
|
211
|
+
if (input[pos] === "=") {
|
|
212
|
+
pos++; // skip =
|
|
213
|
+
|
|
214
|
+
if (input[pos] === '"') {
|
|
215
|
+
// Quoted string value
|
|
216
|
+
pos++; // skip opening "
|
|
217
|
+
const endQuote = input.indexOf('"', pos);
|
|
218
|
+
if (endQuote === -1) break;
|
|
219
|
+
const val = input.slice(pos, endQuote);
|
|
220
|
+
attrs[name] = resolvePlaceholders(val, values);
|
|
221
|
+
pos = endQuote + 1;
|
|
222
|
+
} else if (input[pos] === "'") {
|
|
223
|
+
// Single-quoted string value
|
|
224
|
+
pos++; // skip opening '
|
|
225
|
+
const endQuote = input.indexOf("'", pos);
|
|
226
|
+
if (endQuote === -1) break;
|
|
227
|
+
const val = input.slice(pos, endQuote);
|
|
228
|
+
attrs[name] = resolvePlaceholders(val, values);
|
|
229
|
+
pos = endQuote + 1;
|
|
230
|
+
} else if (input.slice(pos).startsWith(PLACEHOLDER_PREFIX)) {
|
|
231
|
+
// Direct placeholder value (for objects, numbers, etc.)
|
|
232
|
+
const phEnd = input.indexOf(PLACEHOLDER_SUFFIX, pos);
|
|
233
|
+
if (phEnd === -1) break;
|
|
234
|
+
const idxStr = input.slice(pos + PLACEHOLDER_PREFIX.length, phEnd);
|
|
235
|
+
const idx = parseInt(idxStr, 10);
|
|
236
|
+
attrs[name] = values[idx];
|
|
237
|
+
pos = phEnd + PLACEHOLDER_SUFFIX.length;
|
|
238
|
+
} else {
|
|
239
|
+
// Unquoted value (until whitespace, >, or /)
|
|
240
|
+
const valMatch = input.slice(pos).match(/^[^\s>\/]+/);
|
|
241
|
+
if (valMatch) {
|
|
242
|
+
attrs[name] = resolvePlaceholders(valMatch[0]!, values);
|
|
243
|
+
pos += valMatch[0]!.length;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// Boolean attribute (e.g. `bold`, `italic`)
|
|
248
|
+
attrs[name] = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return pos;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Placeholder resolution
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
function resolvePlaceholders(str: string, values: unknown[]): unknown {
|
|
260
|
+
// If the entire string is a single placeholder, return the value directly
|
|
261
|
+
const singleMatch = str.match(/^\x00__TUI_(\d+)__\x00$/);
|
|
262
|
+
if (singleMatch) {
|
|
263
|
+
return values[parseInt(singleMatch[1]!, 10)];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Otherwise, string-replace placeholders
|
|
267
|
+
if (str.includes(PLACEHOLDER_PREFIX)) {
|
|
268
|
+
return str.replace(PLACEHOLDER_RE, (_, idxStr: string) => {
|
|
269
|
+
return String(values[parseInt(idxStr, 10)] ?? "");
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return str;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// VNode conversion
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
function toVNode(node: ParsedNode, values: unknown[]): unknown {
|
|
281
|
+
if (node.type === "text") {
|
|
282
|
+
const text = node.text ?? "";
|
|
283
|
+
// Resolve any placeholders in text
|
|
284
|
+
const resolved = resolvePlaceholders(text, values);
|
|
285
|
+
return resolved;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const tag = node.tag!;
|
|
289
|
+
const rawAttrs = node.attrs ?? {};
|
|
290
|
+
const children = (node.children ?? []).map((c) => toVNode(c, values));
|
|
291
|
+
|
|
292
|
+
// Separate shorthand style attrs from regular props
|
|
293
|
+
const props: Record<string, unknown> = {};
|
|
294
|
+
let style: Record<string, unknown> = {};
|
|
295
|
+
|
|
296
|
+
for (const [key, value] of Object.entries(rawAttrs)) {
|
|
297
|
+
if (key === "style") {
|
|
298
|
+
// Merge with existing style
|
|
299
|
+
if (typeof value === "object" && value !== null) {
|
|
300
|
+
style = { ...style, ...(value as Record<string, unknown>) };
|
|
301
|
+
}
|
|
302
|
+
} else if (key in STYLE_SHORTHANDS) {
|
|
303
|
+
const styleFn = STYLE_SHORTHANDS[key]!;
|
|
304
|
+
const result = styleFn(value as string | true);
|
|
305
|
+
style = { ...style, ...result };
|
|
306
|
+
} else {
|
|
307
|
+
props[key] = value;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Only add style if non-empty
|
|
312
|
+
if (Object.keys(style).length > 0) {
|
|
313
|
+
props["style"] = style;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// For text elements with only text children, join them
|
|
317
|
+
if (tag === "text" && children.length > 0 && children.every((c) => typeof c === "string")) {
|
|
318
|
+
return h(tag, props, children.join(""));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (children.length === 0) {
|
|
322
|
+
return h(tag, props);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return h(tag, props, children as any[]);
|
|
326
|
+
}
|