From 36e756b7c3f679b9866b2dbb8e05a3d9f0c8d0ea Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:51:41 +0100 Subject: [PATCH] wip: rework it all to use render() functions This way we can keep an ast-like thingymajig around until we want to actually render. --- packages/jsx/src/components/confirm.ts | 12 +++++- packages/jsx/src/components/field.ts | 40 ++++++++++--------- packages/jsx/src/components/form.ts | 28 +++++++------ packages/jsx/src/components/note.ts | 38 ++++++++++-------- packages/jsx/src/components/option.ts | 36 +++++++++-------- packages/jsx/src/components/password.ts | 12 +++++- packages/jsx/src/components/select.ts | 30 ++++++++------ packages/jsx/src/components/text.ts | 12 +++++- packages/jsx/src/index.ts | 20 ++++++++-- packages/jsx/src/types.ts | 10 ++++- packages/jsx/src/utils.ts | 7 ++-- packages/jsx/test/components/confirm.test.tsx | 9 +++-- packages/jsx/test/components/field.test.tsx | 16 ++++---- packages/jsx/test/components/form.test.tsx | 16 ++++---- packages/jsx/test/components/note.test.tsx | 30 ++++++++------ .../jsx/test/components/password.test.tsx | 9 +++-- packages/jsx/test/components/select.test.tsx | 21 +++++----- packages/jsx/test/components/text.test.tsx | 12 ++++-- packages/jsx/test/jsx.test.tsx | 14 +++---- 19 files changed, 227 insertions(+), 145 deletions(-) diff --git a/packages/jsx/src/components/confirm.ts b/packages/jsx/src/components/confirm.ts index 13c6da3..0c6bd13 100644 --- a/packages/jsx/src/components/confirm.ts +++ b/packages/jsx/src/components/confirm.ts @@ -1,8 +1,16 @@ import type { ConfirmOptions } from '@clack/prompts'; import { confirm } from '@clack/prompts'; +import type { JSX } from '../types.js'; export type ConfirmProps = ConfirmOptions; -export function Confirm(props: ConfirmProps): () => ReturnType { - return () => confirm(props); +export function Confirm(props: ConfirmProps): JSX.Element { + return { + render: (options) => + confirm({ + input: options?.input, + output: options?.output, + ...props, + }), + }; } diff --git a/packages/jsx/src/components/field.ts b/packages/jsx/src/components/field.ts index 834642c..e68b146 100644 --- a/packages/jsx/src/components/field.ts +++ b/packages/jsx/src/components/field.ts @@ -12,30 +12,32 @@ export interface FieldProps { children?: JSX.Element | JSX.Element[] | string; } -export function Field(props: FieldProps): () => Promise { - return async () => { - let value: unknown = undefined; +export function Field(props: FieldProps): JSX.Element { + return { + render: async (options) => { + let value: unknown = undefined; - if (props.children) { - const resolvedChildren = await resolveChildren(props.children); - const valueArr: unknown[] = []; + if (props.children) { + const resolvedChildren = await resolveChildren(props.children, options); + const valueArr: unknown[] = []; - for (const child of resolvedChildren) { - if (!isCancel(child)) { - valueArr.push(child); + for (const child of resolvedChildren) { + if (!isCancel(child)) { + valueArr.push(child); + } } - } - if (valueArr.length === 1) { - value = valueArr[0]; - } else { - value = valueArr; + if (valueArr.length === 1) { + value = valueArr[0]; + } else { + value = valueArr; + } } - } - return { - name: props.name, - value, - }; + return { + name: props.name, + value, + }; + }, }; } diff --git a/packages/jsx/src/components/form.ts b/packages/jsx/src/components/form.ts index 49253c6..49db85b 100644 --- a/packages/jsx/src/components/form.ts +++ b/packages/jsx/src/components/form.ts @@ -10,24 +10,26 @@ function isChildLike(child: unknown): child is { name: PropertyKey; value: unkno return typeof child === 'object' && child !== null && 'name' in child && 'value' in child; } -export function Form(props: FormProps): () => Promise> { - return async () => { - const results: Record = {}; +export function Form(props: FormProps): JSX.Element { + return { + render: async (options) => { + const results: Record = {}; - if (props.children) { - const resolvedChildren = await resolveChildren(props.children); + if (props.children) { + const resolvedChildren = await resolveChildren(props.children, options); - for (const child of resolvedChildren) { - if (isCancel(child)) { - continue; - } + for (const child of resolvedChildren) { + if (isCancel(child)) { + continue; + } - if (isChildLike(child)) { - results[child.name] = child.value; + if (isChildLike(child)) { + results[child.name] = child.value; + } } } - } - return results; + return results; + }, }; } diff --git a/packages/jsx/src/components/note.ts b/packages/jsx/src/components/note.ts index 2142dde..a48258f 100644 --- a/packages/jsx/src/components/note.ts +++ b/packages/jsx/src/components/note.ts @@ -9,25 +9,31 @@ export interface NoteProps extends NoteOptions { title?: string; } -export function Note(props: NoteProps): () => Promise { - return async () => { - let message = ''; +export function Note(props: NoteProps): JSX.Element { + return { + render: async (options) => { + let message = ''; - if (props.children) { - const messages: string[] = []; - const children = await resolveChildren(props.children); - for (const child of children) { - // TODO (43081j): handle cancelling of children - if (isCancel(child)) { - continue; + if (props.children) { + const messages: string[] = []; + const children = await resolveChildren(props.children, options); + for (const child of children) { + // TODO (43081j): handle cancelling of children + if (isCancel(child)) { + continue; + } + messages.push(String(child)); } - messages.push(String(child)); + message = messages.join('\n'); + } else if (props.message) { + message = props.message; } - message = messages.join('\n'); - } else if (props.message) { - message = props.message; - } - note(message, props.title, props); + note(message, props.title, { + input: options?.input, + output: options?.output, + ...props, + }); + }, }; } diff --git a/packages/jsx/src/components/option.ts b/packages/jsx/src/components/option.ts index 9eec3f2..cbec246 100644 --- a/packages/jsx/src/components/option.ts +++ b/packages/jsx/src/components/option.ts @@ -8,27 +8,29 @@ export interface OptionProps { children?: JSX.Element | JSX.Element[] | string; } -export function Option(props: OptionProps): () => Promise> { - return async () => { - const { children, ...opts } = props; +export function Option(props: OptionProps): JSX.Element { + return { + render: async (options) => { + const { children, ...opts } = props; - if (children) { - const resolvedChildren = await resolveChildren(children); - const childStrings: string[] = []; + if (children) { + const resolvedChildren = await resolveChildren(children, options); + const childStrings: string[] = []; - for (const child of resolvedChildren) { - if (isCancel(child)) { - continue; + for (const child of resolvedChildren) { + if (isCancel(child)) { + continue; + } + childStrings.push(String(child)); } - childStrings.push(String(child)); - } - return { - ...opts, - label: childStrings.join('\n'), - } as PromptOption; - } + return { + ...opts, + label: childStrings.join('\n'), + } as PromptOption; + } - return opts as PromptOption; + return opts as PromptOption; + }, }; } diff --git a/packages/jsx/src/components/password.ts b/packages/jsx/src/components/password.ts index 4de77aa..60c532c 100644 --- a/packages/jsx/src/components/password.ts +++ b/packages/jsx/src/components/password.ts @@ -1,8 +1,16 @@ import type { PasswordOptions } from '@clack/prompts'; import { password } from '@clack/prompts'; +import type { JSX } from '../types.js'; export type PasswordProps = PasswordOptions; -export function Password(props: PasswordProps): () => ReturnType { - return () => password(props); +export function Password(props: PasswordProps): JSX.Element { + return { + render: (options) => + password({ + input: options?.input, + output: options?.output, + ...props, + }), + }; } diff --git a/packages/jsx/src/components/select.ts b/packages/jsx/src/components/select.ts index 8d7e6a7..612c7e7 100644 --- a/packages/jsx/src/components/select.ts +++ b/packages/jsx/src/components/select.ts @@ -11,21 +11,25 @@ const isOptionLike = (obj: unknown): obj is Option => { return obj !== null && typeof obj === 'object' && Object.hasOwnProperty.call(obj, 'value'); }; -export function Select(props: SelectProps): () => ReturnType { - return async () => { - const { children, ...opts } = props; - const options: Option[] = []; - const resolvedChildren = await resolveChildren(props.children); +export function Select(props: SelectProps): JSX.Element { + return { + render: async (renderOptions) => { + const { children, ...opts } = props; + const options: Option[] = []; + const resolvedChildren = await resolveChildren(props.children, renderOptions); - for (const child of resolvedChildren) { - if (isOptionLike(child)) { - options.push(child); + for (const child of resolvedChildren) { + if (isOptionLike(child)) { + options.push(child); + } } - } - return select({ - ...opts, - options, - }); + return select({ + input: renderOptions?.input, + output: renderOptions?.output, + ...opts, + options, + }); + }, }; } diff --git a/packages/jsx/src/components/text.ts b/packages/jsx/src/components/text.ts index 495f7b5..7f800bb 100644 --- a/packages/jsx/src/components/text.ts +++ b/packages/jsx/src/components/text.ts @@ -1,8 +1,16 @@ import type { TextOptions } from '@clack/prompts'; import { text } from '@clack/prompts'; +import type { JSX } from '../types.js'; export type TextProps = TextOptions; -export function Text(props: TextProps): () => ReturnType { - return () => text(props); +export function Text(props: TextProps): JSX.Element { + return { + render: (options) => + text({ + input: options?.input, + output: options?.output, + ...props, + }), + }; } diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts index a40dc28..4a8de09 100644 --- a/packages/jsx/src/index.ts +++ b/packages/jsx/src/index.ts @@ -6,7 +6,7 @@ import { Option, type OptionProps } from './components/option.js'; import { Password, type PasswordProps } from './components/password.js'; import { Select, type SelectProps } from './components/select.js'; import { Text, type TextProps } from './components/text.js'; -import type { JSX } from './types.js'; +import type { JSX, RenderFunction, RenderOptions } from './types.js'; export type { JSX }; export { @@ -29,7 +29,9 @@ export { }; export function Fragment(props: { children: JSX.Element | JSX.Element[] }): JSX.Element { - return () => Promise.resolve(props.children); + return { + render: () => Promise.resolve(props.children), + }; } export type Component = @@ -47,11 +49,21 @@ function jsx( ): JSX.Element; function jsx(fn: T, props: Parameters[0], _key?: string): JSX.Element; function jsx(tagOrFn: string | Component, props: unknown, _key?: string): JSX.Element { + let render: RenderFunction; if (typeof tagOrFn === 'function') { - return (tagOrFn as (props: unknown) => JSX.Element)(props); + const renderFn = (tagOrFn as (props: unknown) => JSX.Element)(props); + render = (options) => renderFn.render(options); + } else { + render = () => Promise.resolve(null); } - return () => Promise.resolve(null); + return { + render, + }; } export { jsx }; export const jsxDEV = jsx; + +export async function render(node: JSX.Element, options?: RenderOptions): Promise { + await node.render(options); +} diff --git a/packages/jsx/src/types.ts b/packages/jsx/src/types.ts index 0da9cd7..60acc04 100644 --- a/packages/jsx/src/types.ts +++ b/packages/jsx/src/types.ts @@ -1,7 +1,15 @@ +import type { CommonOptions } from '@clack/prompts'; + +export interface RenderOptions extends CommonOptions {} + +export type RenderFunction = (options?: RenderOptions) => Promise; + namespace JSX { export type IntrinsicElements = never; - export type Element = () => Promise; + export type Element = { + render: RenderFunction; + }; } export type { JSX }; diff --git a/packages/jsx/src/utils.ts b/packages/jsx/src/utils.ts index 6b96af2..b33cc2c 100644 --- a/packages/jsx/src/utils.ts +++ b/packages/jsx/src/utils.ts @@ -1,13 +1,14 @@ -import type { JSX } from './types.js'; +import type { JSX, RenderOptions } from './types.js'; export async function resolveChildren( - children: JSX.Element[] | JSX.Element | string + children: JSX.Element[] | JSX.Element | string, + options?: RenderOptions ): Promise { const arr = Array.isArray(children) ? children : [children]; const results: unknown[] = []; for (const child of arr) { - const result = typeof child === 'string' ? child : await child(); + const result = typeof child === 'string' ? child : await child.render(options); results.push(result); } diff --git a/packages/jsx/test/components/confirm.test.tsx b/packages/jsx/test/components/confirm.test.tsx index caca6c7..12d45c3 100644 --- a/packages/jsx/test/components/confirm.test.tsx +++ b/packages/jsx/test/components/confirm.test.tsx @@ -12,7 +12,8 @@ describe('Confirm', () => { }); test('can set message', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); @@ -20,7 +21,8 @@ describe('Confirm', () => { }); test('can set active text', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); @@ -28,7 +30,8 @@ describe('Confirm', () => { }); test('can set inactive text', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); diff --git a/packages/jsx/test/components/field.test.tsx b/packages/jsx/test/components/field.test.tsx index 9ecb7c0..2d050e3 100644 --- a/packages/jsx/test/components/field.test.tsx +++ b/packages/jsx/test/components/field.test.tsx @@ -12,11 +12,12 @@ describe('Field', () => { }); test('renders and resolves children', async () => { - const task = ( + const element = ( - + - )(); + ); + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); @@ -32,12 +33,13 @@ describe('Field', () => { }); test('resolves multiple children into array', async () => { - const task = ( + const element = ( - - + + - )(); + ); + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/components/form.test.tsx b/packages/jsx/test/components/form.test.tsx index 40393a5..b9844b1 100644 --- a/packages/jsx/test/components/form.test.tsx +++ b/packages/jsx/test/components/form.test.tsx @@ -12,13 +12,14 @@ describe('Form', () => { }); test('renders and resolves object', async () => { - const task = ( + const element = (
- +
- )(); + ); + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); @@ -32,16 +33,17 @@ describe('Form', () => { expect(output.buffer).toMatchSnapshot(); }); test('renders and resolves multiple fields', async () => { - const task = ( + const element = (
- + - +
- )(); + ); + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/components/note.test.tsx b/packages/jsx/test/components/note.test.tsx index 1181051..19adad0 100644 --- a/packages/jsx/test/components/note.test.tsx +++ b/packages/jsx/test/components/note.test.tsx @@ -12,25 +12,28 @@ describe('Note', () => { }); test('can render string message', async () => { - const task = ; - await task(); + const element = ; + const task = element.render({ output }); + await task; expect(output.buffer).toMatchSnapshot(); }); test('can render children as message', async () => { - const task = a message; - await task(); + const element = a message; + const task = element.render({ output }); + await task; expect(output.buffer).toMatchSnapshot(); }); test('can render complex results as message', async () => { - const task = ( - - + const element = ( + + - )(); + ); + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); await task; @@ -38,12 +41,13 @@ describe('Note', () => { }); test('can render multiple children as message', async () => { - const task = ( - - - + const element = ( + + + - )(); + ); + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); await nextTick(); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/components/password.test.tsx b/packages/jsx/test/components/password.test.tsx index a552252..9ffdb20 100644 --- a/packages/jsx/test/components/password.test.tsx +++ b/packages/jsx/test/components/password.test.tsx @@ -12,7 +12,8 @@ describe('Password', () => { }); test('renders password input', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); @@ -23,7 +24,8 @@ describe('Password', () => { }); test('renders user input', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); @@ -36,7 +38,8 @@ describe('Password', () => { }); test('can set custom mask', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', 'a', { name: 'a' }); input.emit('keypress', 'b', { name: 'b' }); diff --git a/packages/jsx/test/components/select.test.tsx b/packages/jsx/test/components/select.test.tsx index 0a98c10..c5e6671 100644 --- a/packages/jsx/test/components/select.test.tsx +++ b/packages/jsx/test/components/select.test.tsx @@ -12,12 +12,13 @@ describe('Select', () => { }); test('renders options', async () => { - const task = ( - - )(); + ); + const task = element.render({ input, output }); await nextTick(); input.emit('keypress', '', { name: 'return' }); @@ -46,8 +48,8 @@ describe('Select', () => { }); test('renders options with hints', async () => { - const task = ( - @@ -55,7 +57,8 @@ describe('Select', () => { Eight o eight - )(); + ); + const task = element.render({ input, output }); await nextTick(); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/components/text.test.tsx b/packages/jsx/test/components/text.test.tsx index 169b050..dc0a5f3 100644 --- a/packages/jsx/test/components/text.test.tsx +++ b/packages/jsx/test/components/text.test.tsx @@ -12,7 +12,8 @@ describe('Text', () => { }); test('renders text input', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); @@ -23,7 +24,8 @@ describe('Text', () => { }); test('can set placeholder', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); @@ -34,7 +36,8 @@ describe('Text', () => { }); test('can set default value', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); @@ -45,7 +48,8 @@ describe('Text', () => { }); test('can set initial value', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); diff --git a/packages/jsx/test/jsx.test.tsx b/packages/jsx/test/jsx.test.tsx index 0c93c9e..34539b1 100644 --- a/packages/jsx/test/jsx.test.tsx +++ b/packages/jsx/test/jsx.test.tsx @@ -12,26 +12,26 @@ describe('jsx', () => { }); test('can render', async () => { - const task = jsx(Confirm, { + const element = jsx(Confirm, { message: 'foo?', - input, - output, - })(); + }); + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); }); test('can render JSX', async () => { - const task = ()(); + const element = ; + const task = element.render({ input, output }); input.emit('keypress', '', { name: 'return' }); const result = await task; expect(result).to.equal(true); }); test('unknown elements are null', async () => { - const task = jsx('unknown-nonsense' as never, {} as never); - const result = await task(); + const element = jsx('unknown-nonsense' as never, {} as never); + const result = await element.render(); expect(result).to.equal(null); }); }); -- 2.51.2