diff --git a/.agents/docs/component-authoring.md b/.agents/docs/component-authoring.md new file mode 100644 index 0000000..d08a887 --- /dev/null +++ b/.agents/docs/component-authoring.md @@ -0,0 +1,122 @@ +# Component Authoring: SFC vs Render Function + +vue-tui's public components (`Box`, `Text`, `Spacer`, `Static`, `Newline`, `Transform`) are +authored as **Vue ` + + diff --git a/packages/runtime-tests/integration/pty/fixtures/tsconfig.json b/packages/runtime-tests/integration/pty/fixtures/tsconfig.json index d73bd17..bae913f 100644 --- a/packages/runtime-tests/integration/pty/fixtures/tsconfig.json +++ b/packages/runtime-tests/integration/pty/fixtures/tsconfig.json @@ -8,5 +8,6 @@ "strict": true, "skipLibCheck": true, "types": ["node"] - } + }, + "include": ["./*.tsx", "./*.vue"] } diff --git a/packages/runtime-tests/package.json b/packages/runtime-tests/package.json index 98688a8..0a62dca 100644 --- a/packages/runtime-tests/package.json +++ b/packages/runtime-tests/package.json @@ -8,7 +8,7 @@ "test:integration": "vp test", "test:pty": "vp test run --config vitest.pty.config.ts --passWithNoTests", "check:type": "tsc --noEmit && vp run check:fixtures", - "check:fixtures": "tsc -p integration/pty/fixtures/tsconfig.json --noEmit" + "check:fixtures": "vue-tsc -p integration/pty/fixtures/tsconfig.json --noEmit" }, "devDependencies": { "@types/node": "^25.9.1", @@ -24,6 +24,7 @@ "tsx": "catalog:", "typescript": "^6.0.3", "vite-plus": "^0.1.22", - "vue": "^3.5.34" + "vue": "^3.5.34", + "vue-tsc": "catalog:" } } diff --git a/packages/runtime/package.json b/packages/runtime/package.json index cbc4c86..1d210bf 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -19,7 +19,7 @@ "build": "vp pack", "dev": "vp pack --watch", "test": "vp test", - "check:type": "tsc --noEmit", + "check:type": "vue-tsc --noEmit", "prepublishOnly": "vp run build" }, "dependencies": { @@ -45,8 +45,10 @@ "@types/stack-utils": "^2.0.3", "@vitejs/plugin-vue-jsx": "catalog:", "typescript": "^6.0.3", + "unplugin-vue": "catalog:", "vite-plus": "^0.1.20", - "vue": "^3.4.0" + "vue": "^3.4.0", + "vue-tsc": "catalog:" }, "peerDependencies": { "vue": "^3.4.0" diff --git a/packages/runtime/src/components/box-props.ts b/packages/runtime/src/components/box-props.ts new file mode 100644 index 0000000..1cb9b66 --- /dev/null +++ b/packages/runtime/src/components/box-props.ts @@ -0,0 +1,208 @@ +import { type ExtractPublicPropTypes, type PropType } from "vue"; +import cliBoxes from "cli-boxes"; + +type Spacing = number; +type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse"; +type FlexWrap = "nowrap" | "wrap" | "wrap-reverse"; +type Align = "flex-start" | "center" | "flex-end" | "stretch" | "baseline"; +type AlignSelf = "auto" | "flex-start" | "center" | "flex-end" | "stretch" | "baseline"; +type AlignContent = + | "flex-start" + | "center" + | "flex-end" + | "stretch" + | "space-between" + | "space-around" + | "space-evenly"; +type Justify = + | "flex-start" + | "center" + | "flex-end" + | "space-between" + | "space-around" + | "space-evenly"; +type BorderStyle = + | "single" + | "double" + | "round" + | "bold" + | "singleDouble" + | "doubleSingle" + | "classic" + | "arrow"; + +// Matches the shape of the cliBoxes value type — the same alias used in paint.ts. +// Exported so consumers can type their custom border objects (Ink parity, G13). +export type BoxStyle = (typeof cliBoxes)[keyof cliBoxes.Boxes]; + +// The layout-only subset of BoxProps — exported so `` (and consumers) can +// type a `style` object against the same flex/spacing/size keys a `` accepts, +// without the color/border/aria surface. (Type aliases hoist, so referencing +// BoxProps here — defined at the bottom of this file — is fine.) +export type BoxLayoutStyle = Pick< + BoxProps, + | "flexDirection" + | "flexGrow" + | "flexShrink" + | "flexBasis" + | "flexWrap" + | "alignItems" + | "alignSelf" + | "justifyContent" + | "gap" + | "columnGap" + | "rowGap" + | "width" + | "height" + | "minWidth" + | "minHeight" + | "maxWidth" + | "maxHeight" + | "aspectRatio" + | "alignContent" + | "position" + | "top" + | "right" + | "bottom" + | "left" + | "margin" + | "marginX" + | "marginY" + | "marginTop" + | "marginBottom" + | "marginLeft" + | "marginRight" + | "padding" + | "paddingX" + | "paddingY" + | "paddingTop" + | "paddingBottom" + | "paddingLeft" + | "paddingRight" + | "overflow" + | "overflowX" + | "overflowY" + | "display" +>; + +export type AriaRole = + | "button" + | "checkbox" + | "combobox" + | "list" + | "listbox" + | "listitem" + | "menu" + | "menuitem" + | "option" + | "progressbar" + | "radio" + | "radiogroup" + | "tab" + | "tablist" + | "table" + | "textbox" + | "timer" + | "toolbar"; + +export interface AriaState { + busy?: boolean; + checked?: boolean; + disabled?: boolean; + expanded?: boolean; + multiline?: boolean; + multiselectable?: boolean; + readonly?: boolean; + required?: boolean; + selected?: boolean; +} + +export const boxProps = { + flexDirection: String as PropType, + flexGrow: Number, + flexShrink: Number, + flexBasis: [Number, String], + flexWrap: String as PropType, + alignItems: String as PropType, + alignSelf: String as PropType, + justifyContent: String as PropType, + gap: Number, + columnGap: Number, + rowGap: Number, + + width: [Number, String], + height: [Number, String], + minWidth: [Number, String], + minHeight: [Number, String], + maxWidth: [Number, String], + maxHeight: [Number, String], + aspectRatio: Number, + alignContent: String as PropType, + position: String as PropType<"absolute" | "relative" | "static">, + top: [Number, String], + right: [Number, String], + bottom: [Number, String], + left: [Number, String], + + margin: Number as PropType, + marginX: Number, + marginY: Number, + marginTop: Number, + marginBottom: Number, + marginLeft: Number, + marginRight: Number, + padding: Number, + paddingX: Number, + paddingY: Number, + paddingTop: Number, + paddingBottom: Number, + paddingLeft: Number, + paddingRight: Number, + + // Accept either a preset name string or a full custom BoxStyle object (Ink parity, G13). + // Ink types borderStyle as `keyof Boxes | BoxStyle`; we mirror that here. + borderStyle: [String, Object] as PropType, + borderColor: String, + // `default: undefined` is intentional and load-bearing: Vue's boolean-casting + // rule coerces absent Boolean props to `false` only when there is no explicit + // default. Adding `default: undefined` suppresses that coercion so absent + // per-edge dim props arrive in the paint pass as `undefined`, not `false`. + // This lets `edgeDim = (perEdge ?? generalDim)` correctly fall back to the + // general value only when the per-edge prop was truly omitted — mirroring + // Ink render-border.ts:54 which uses real-undefined via React's prop model + // (G16). The `Boolean` type is kept so Vue still accepts bare-attribute + // `` in templates (coerces `""` → `true`) and passes + // TypeScript type-checking for consumers. + borderDimColor: { type: Boolean as PropType, default: undefined }, + borderTopDimColor: { type: Boolean as PropType, default: undefined }, + borderBottomDimColor: { type: Boolean as PropType, default: undefined }, + borderLeftDimColor: { type: Boolean as PropType, default: undefined }, + borderRightDimColor: { type: Boolean as PropType, default: undefined }, + borderTop: { type: Boolean, default: true }, + borderBottom: { type: Boolean, default: true }, + borderLeft: { type: Boolean, default: true }, + borderRight: { type: Boolean, default: true }, + borderTopColor: String, + borderBottomColor: String, + borderLeftColor: String, + borderRightColor: String, + borderBackgroundColor: String, + borderTopBackgroundColor: String, + borderBottomBackgroundColor: String, + borderLeftBackgroundColor: String, + borderRightBackgroundColor: String, + + backgroundColor: String, + overflow: String as PropType<"visible" | "hidden">, + overflowX: String as PropType<"visible" | "hidden">, + overflowY: String as PropType<"visible" | "hidden">, + display: String as PropType<"flex" | "none">, + + ariaLabel: String, + ariaHidden: Boolean, + ariaRole: String as PropType, + ariaState: Object as PropType, +}; + +/** Props accepted by `` — the vue-tui analogue of Ink's `BoxProps`. */ +export type BoxProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/components/box-validate.ts b/packages/runtime/src/components/box-validate.ts new file mode 100644 index 0000000..92b1822 --- /dev/null +++ b/packages/runtime/src/components/box-validate.ts @@ -0,0 +1,148 @@ +import cliBoxes from "cli-boxes"; +import { assertValidBackgroundColor, assertValidForegroundColor } from "../paint/text-style.ts"; +import type { BoxProps, BoxStyle } from "./box-props.ts"; + +/** + * Eager render-time validation for ``. Runs every render and throws into the + * error boundary on invalid input — exactly as box.ts's render fn did. Returns + * `true` so it can gate a `v-if`. Callers must skip it for a screen-reader-hidden + * Box (a non-emitted node never colorizes — same ordering as box.ts). + */ +export function assertBoxValid(props: BoxProps): true { + // --- backgroundColor validation (A12) --- + // + // Validate the bg-style props during RENDER so a chalk-modifier name (the + // exact case Ink's colorize.ts throws on) is caught by vue-tui's error + // boundary, not the post-flush paint pass where a throw wedges Vue's + // scheduler (cf. the borderStyle fix #124). See assertValidBackgroundColor / + // Ink colorize.ts (40b3a75). + // + // Placed AFTER the screen-reader-hidden early-return to mirror Ink WHERE it + // throws: Ink's render-node-to-output never reaches render-background / + // render-border for a node it didn't emit, and a screen-reader-hidden Box + // emits nothing — so colorize never runs for it. Below, we additionally + // gate each value the way Ink's renderers do, so vue throws exactly where + // Ink would colorize, and not elsewhere. + + // Box's OWN backgroundColor: Ink's render-background.ts only colorizes when + // the resolved content area is > 0 (after subtracting drawn borders); it + // bails early otherwise. We can't know the content area at render time + // (layout hasn't run), so we validate eagerly. ACCEPTED tiny over-throw: a + // degenerate-size Box (content area <= 0, e.g. width/height collapses to + // border-only or 0) with a chalk-modifier-name backgroundColor throws here + // where Ink would silently skip the fill. This is a niche case on + // clearly-invalid input (a modifier name is never a valid bg); matching + // Ink's layout-time gate at render time is not worth the complexity. + assertValidBackgroundColor(props.backgroundColor); + + // Border backgrounds: Ink's render-border.ts only colorizes a border bg + // when (a) borderStyle is truthy (line 28 gate) AND (b) the specific edge + // is DRAWN (`border !== false`), and the value it passes per edge is + // `borderBackgroundColor ?? borderBackgroundColor` (the per-edge + // value with the general value as fallback — lines 44-52, 80/100/112/126). + // So we validate exactly the resolved bg of each DRAWN edge — never the + // general value on its own, and never an edge whose border isn't drawn. + // Consequences (matching Ink, not over-throwing): + // - no borderStyle → no border colorize at all → nothing validated. + // - borderTop={false} → top edge not drawn → its resolved bg not checked. + // - a bad general borderBackgroundColor but every DRAWN edge overrides it + // with a valid per-edge value → general value never reaches colorize → + // not validated → no throw. + // ACCEPTED irreducible over-throw (same class as the content-area note + // above): top/bottom edge bg is validated eagerly, but Ink only colorizes a + // top/bottom border string when it's non-empty — a degenerate box (e.g. + // width=0 with no left/right border) produces an empty string Ink skips. + // Matching that needs layout, unavailable at render. Niche + invalid-input-only. + if (props.borderStyle) { + const stringStyle = (value: unknown) => (typeof value === "string" ? value : undefined); + const generalBg = stringStyle(props.borderBackgroundColor); + // Per-edge foreground: Ink's render-border colorizes each drawn edge's glyphs + // with `borderColor ?? borderColor` (the same per-edge-with-general + // fallback as the bg path). A foreground name chalk has but can't call (e.g. + // "level") throws there; we validate it eagerly per DRAWN edge. + const generalFg = stringStyle(props.borderColor); + if (props.borderTop !== false) { + assertValidForegroundColor(stringStyle(props.borderTopColor) ?? generalFg, "borderTopColor"); + assertValidBackgroundColor( + stringStyle(props.borderTopBackgroundColor) ?? generalBg, + "borderTopBackgroundColor", + ); + } + if (props.borderBottom !== false) { + assertValidForegroundColor( + stringStyle(props.borderBottomColor) ?? generalFg, + "borderBottomColor", + ); + assertValidBackgroundColor( + stringStyle(props.borderBottomBackgroundColor) ?? generalBg, + "borderBottomBackgroundColor", + ); + } + if (props.borderLeft !== false) { + assertValidForegroundColor( + stringStyle(props.borderLeftColor) ?? generalFg, + "borderLeftColor", + ); + assertValidBackgroundColor( + stringStyle(props.borderLeftBackgroundColor) ?? generalBg, + "borderLeftBackgroundColor", + ); + } + if (props.borderRight !== false) { + assertValidForegroundColor( + stringStyle(props.borderRightColor) ?? generalFg, + "borderRightColor", + ); + assertValidBackgroundColor( + stringStyle(props.borderRightBackgroundColor) ?? generalBg, + "borderRightBackgroundColor", + ); + } + } + + // NOTE: this component-level validation covers the public ``/`` + // API only. A raw host-op call (`h("box", { backgroundColor: "bold" })`) + // bypasses it; the paint layer keeps its silent degrade-to-bare-text there + // rather than throwing (a throw in the post-flush paint pass wedges Vue's + // scheduler). Same accepted limitation as the borderStyle fix (#124). + + // Validate borderStyle during RENDER so an unknown name is caught by + // vue-tui's error boundary (onErrorCaptured → ErrorOverview), exactly like + // any other component render error. Ink crashes on an unknown borderStyle + // with a raw TypeError during paint (render-border.ts reads box.topLeft off + // cliBoxes[name] === undefined); we align to that "throw on unknown" contract + // but do it here rather than in paint, where a throw would unwind through + // Vue's post-flush commit and wedge its scheduler. Only a NON-EMPTY unknown + // STRING throws: a falsy value (false/undefined/"" = no border) and a custom + // BoxStyle OBJECT are both valid and pass through. (audit 2.3) + // `borderStyle.length > 0` (not `!== ""`): once `typeof === "string"` narrows + // the prop to the BorderStyle keyof union, an `!== ""` literal comparison has + // "no overlap" per TS (the union has no `""` member). The empty string is only + // reachable via a TS-bypass; `.length > 0` excludes it without tripping that. + // + // We validate the RESOLVED box has a real BoxStyle SHAPE rather than testing + // `borderStyle in cliBoxes`, because `in` has two false-accept holes that let + // a non-box value reach paint (which then reads `.top`/`.topLeft` glyph + // strings off it): + // 1. cli-boxes' default export carries a CJS-interop `default` self-key, so + // `"default" in cliBoxes` is true — but cliBoxes.default is the WHOLE + // boxes object, not a BoxStyle (it has no string `top`). + // 2. `in` walks the prototype chain, so Object.prototype members + // ("toString", "constructor", "hasOwnProperty", …) report as "in + // cliBoxes" while resolving to a function/undefined — never a BoxStyle. + // Resolving `cliBoxes[name]` and requiring an object with a string `top` + // rejects unknown names (undefined), "default" (whole object, no string + // `top`), and inherited props, while accepting every real preset. + const borderStyle = props.borderStyle; + if (typeof borderStyle === "string" && borderStyle.length > 0) { + // Cast via `unknown` to add a string index signature: cliBoxes is typed as + // the `Boxes` keyof object (no index signature), so a direct cast is a TS2352 + // "insufficient overlap" error. Same cast paint.ts uses to look up by name. + const resolved = (cliBoxes as unknown as Record)[borderStyle]; + if (typeof resolved !== "object" || resolved === null || typeof resolved.top !== "string") { + throw new Error(`Unknown borderStyle: ${JSON.stringify(borderStyle)}`); + } + } + + return true; +} diff --git a/packages/runtime/src/components/box.ts b/packages/runtime/src/components/box.ts deleted file mode 100644 index c6910b5..0000000 --- a/packages/runtime/src/components/box.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { defineComponent, h, inject, type ExtractPublicPropTypes, type PropType } from "vue"; -import cliBoxes from "cli-boxes"; -import { AppContextKey } from "../context.ts"; -import { assertValidBackgroundColor, assertValidForegroundColor } from "../paint/text-style.ts"; -import type { WithChildren } from "./with-children.ts"; - -type Spacing = number; -type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse"; -type FlexWrap = "nowrap" | "wrap" | "wrap-reverse"; -type Align = "flex-start" | "center" | "flex-end" | "stretch" | "baseline"; -type AlignSelf = "auto" | "flex-start" | "center" | "flex-end" | "stretch" | "baseline"; -type AlignContent = - | "flex-start" - | "center" - | "flex-end" - | "stretch" - | "space-between" - | "space-around" - | "space-evenly"; -type Justify = - | "flex-start" - | "center" - | "flex-end" - | "space-between" - | "space-around" - | "space-evenly"; -type BorderStyle = - | "single" - | "double" - | "round" - | "bold" - | "singleDouble" - | "doubleSingle" - | "classic" - | "arrow"; - -// Matches the shape of the cliBoxes value type — the same alias used in paint.ts. -// Exported so consumers can type their custom border objects (Ink parity, G13). -export type BoxStyle = (typeof cliBoxes)[keyof cliBoxes.Boxes]; - -export type BoxLayoutStyle = Pick< - BoxProps, - | "flexDirection" - | "flexGrow" - | "flexShrink" - | "flexBasis" - | "flexWrap" - | "alignItems" - | "alignSelf" - | "justifyContent" - | "gap" - | "columnGap" - | "rowGap" - | "width" - | "height" - | "minWidth" - | "minHeight" - | "maxWidth" - | "maxHeight" - | "aspectRatio" - | "alignContent" - | "position" - | "top" - | "right" - | "bottom" - | "left" - | "margin" - | "marginX" - | "marginY" - | "marginTop" - | "marginBottom" - | "marginLeft" - | "marginRight" - | "padding" - | "paddingX" - | "paddingY" - | "paddingTop" - | "paddingBottom" - | "paddingLeft" - | "paddingRight" - | "overflow" - | "overflowX" - | "overflowY" - | "display" ->; - -export type AriaRole = - | "button" - | "checkbox" - | "combobox" - | "list" - | "listbox" - | "listitem" - | "menu" - | "menuitem" - | "option" - | "progressbar" - | "radio" - | "radiogroup" - | "tab" - | "tablist" - | "table" - | "textbox" - | "timer" - | "toolbar"; - -export interface AriaState { - busy?: boolean; - checked?: boolean; - disabled?: boolean; - expanded?: boolean; - multiline?: boolean; - multiselectable?: boolean; - readonly?: boolean; - required?: boolean; - selected?: boolean; -} - -const boxProps = { - flexDirection: String as PropType, - flexGrow: Number, - flexShrink: Number, - flexBasis: [Number, String], - flexWrap: String as PropType, - alignItems: String as PropType, - alignSelf: String as PropType, - justifyContent: String as PropType, - gap: Number, - columnGap: Number, - rowGap: Number, - - width: [Number, String], - height: [Number, String], - minWidth: [Number, String], - minHeight: [Number, String], - maxWidth: [Number, String], - maxHeight: [Number, String], - aspectRatio: Number, - alignContent: String as PropType, - position: String as PropType<"absolute" | "relative" | "static">, - top: [Number, String], - right: [Number, String], - bottom: [Number, String], - left: [Number, String], - - margin: Number as PropType, - marginX: Number, - marginY: Number, - marginTop: Number, - marginBottom: Number, - marginLeft: Number, - marginRight: Number, - padding: Number, - paddingX: Number, - paddingY: Number, - paddingTop: Number, - paddingBottom: Number, - paddingLeft: Number, - paddingRight: Number, - - // Accept either a preset name string or a full custom BoxStyle object (Ink parity, G13). - // Ink types borderStyle as `keyof Boxes | BoxStyle`; we mirror that here. - borderStyle: [String, Object] as PropType, - borderColor: String, - // `default: undefined` is intentional and load-bearing: Vue's boolean-casting - // rule coerces absent Boolean props to `false` only when there is no explicit - // default. Adding `default: undefined` suppresses that coercion so absent - // per-edge dim props arrive in the paint pass as `undefined`, not `false`. - // This lets `edgeDim = (perEdge ?? generalDim)` correctly fall back to the - // general value only when the per-edge prop was truly omitted — mirroring - // Ink render-border.ts:54 which uses real-undefined via React's prop model - // (G16). The `Boolean` type is kept so Vue still accepts bare-attribute - // `` in templates (coerces `""` → `true`) and passes - // TypeScript type-checking for consumers. - borderDimColor: { type: Boolean as PropType, default: undefined }, - borderTopDimColor: { type: Boolean as PropType, default: undefined }, - borderBottomDimColor: { type: Boolean as PropType, default: undefined }, - borderLeftDimColor: { type: Boolean as PropType, default: undefined }, - borderRightDimColor: { type: Boolean as PropType, default: undefined }, - borderTop: { type: Boolean, default: true }, - borderBottom: { type: Boolean, default: true }, - borderLeft: { type: Boolean, default: true }, - borderRight: { type: Boolean, default: true }, - borderTopColor: String, - borderBottomColor: String, - borderLeftColor: String, - borderRightColor: String, - borderBackgroundColor: String, - borderTopBackgroundColor: String, - borderBottomBackgroundColor: String, - borderLeftBackgroundColor: String, - borderRightBackgroundColor: String, - - backgroundColor: String, - overflow: String as PropType<"visible" | "hidden">, - overflowX: String as PropType<"visible" | "hidden">, - overflowY: String as PropType<"visible" | "hidden">, - display: String as PropType<"flex" | "none">, - - ariaLabel: String, - ariaHidden: Boolean, - ariaRole: String as PropType, - ariaState: Object as PropType, -}; - -const BoxImpl = defineComponent({ - name: "Box", - props: boxProps, - setup(props, { slots }) { - const appCtx = inject(AppContextKey, null); - - return () => { - const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false; - - // When screen reader is enabled and aria-hidden is set, render nothing. - if (isScreenReaderEnabled && props.ariaHidden) { - return null; - } - - // --- backgroundColor validation (A12) --- - // - // Validate the bg-style props during RENDER so a chalk-modifier name (the - // exact case Ink's colorize.ts throws on) is caught by vue-tui's error - // boundary, not the post-flush paint pass where a throw wedges Vue's - // scheduler (cf. the borderStyle fix #124). See assertValidBackgroundColor / - // Ink colorize.ts (40b3a75). - // - // Placed AFTER the screen-reader-hidden early-return to mirror Ink WHERE it - // throws: Ink's render-node-to-output never reaches render-background / - // render-border for a node it didn't emit, and a screen-reader-hidden Box - // emits nothing — so colorize never runs for it. Below, we additionally - // gate each value the way Ink's renderers do, so vue throws exactly where - // Ink would colorize, and not elsewhere. - - // Box's OWN backgroundColor: Ink's render-background.ts only colorizes when - // the resolved content area is > 0 (after subtracting drawn borders); it - // bails early otherwise. We can't know the content area at render time - // (layout hasn't run), so we validate eagerly. ACCEPTED tiny over-throw: a - // degenerate-size Box (content area <= 0, e.g. width/height collapses to - // border-only or 0) with a chalk-modifier-name backgroundColor throws here - // where Ink would silently skip the fill. This is a niche case on - // clearly-invalid input (a modifier name is never a valid bg); matching - // Ink's layout-time gate at render time is not worth the complexity. - assertValidBackgroundColor(props.backgroundColor); - - // Border backgrounds: Ink's render-border.ts only colorizes a border bg - // when (a) borderStyle is truthy (line 28 gate) AND (b) the specific edge - // is DRAWN (`border !== false`), and the value it passes per edge is - // `borderBackgroundColor ?? borderBackgroundColor` (the per-edge - // value with the general value as fallback — lines 44-52, 80/100/112/126). - // So we validate exactly the resolved bg of each DRAWN edge — never the - // general value on its own, and never an edge whose border isn't drawn. - // Consequences (matching Ink, not over-throwing): - // - no borderStyle → no border colorize at all → nothing validated. - // - borderTop={false} → top edge not drawn → its resolved bg not checked. - // - a bad general borderBackgroundColor but every DRAWN edge overrides it - // with a valid per-edge value → general value never reaches colorize → - // not validated → no throw. - // ACCEPTED irreducible over-throw (same class as the content-area note - // above): top/bottom edge bg is validated eagerly, but Ink only colorizes a - // top/bottom border string when it's non-empty — a degenerate box (e.g. - // width=0 with no left/right border) produces an empty string Ink skips. - // Matching that needs layout, unavailable at render. Niche + invalid-input-only. - if (props.borderStyle) { - const stringStyle = (value: unknown) => (typeof value === "string" ? value : undefined); - const generalBg = stringStyle(props.borderBackgroundColor); - const generalFg = stringStyle(props.borderColor); - if (props.borderTop !== false) { - assertValidForegroundColor( - stringStyle(props.borderTopColor) ?? generalFg, - "borderTopColor", - ); - assertValidBackgroundColor( - stringStyle(props.borderTopBackgroundColor) ?? generalBg, - "borderTopBackgroundColor", - ); - } - if (props.borderBottom !== false) { - assertValidForegroundColor( - stringStyle(props.borderBottomColor) ?? generalFg, - "borderBottomColor", - ); - assertValidBackgroundColor( - stringStyle(props.borderBottomBackgroundColor) ?? generalBg, - "borderBottomBackgroundColor", - ); - } - if (props.borderLeft !== false) { - assertValidForegroundColor( - stringStyle(props.borderLeftColor) ?? generalFg, - "borderLeftColor", - ); - assertValidBackgroundColor( - stringStyle(props.borderLeftBackgroundColor) ?? generalBg, - "borderLeftBackgroundColor", - ); - } - if (props.borderRight !== false) { - assertValidForegroundColor( - stringStyle(props.borderRightColor) ?? generalFg, - "borderRightColor", - ); - assertValidBackgroundColor( - stringStyle(props.borderRightBackgroundColor) ?? generalBg, - "borderRightBackgroundColor", - ); - } - } - - // NOTE: this component-level validation covers the public ``/`` - // API only. A raw host-op call (`h("box", { backgroundColor: "bold" })`) - // bypasses it; the paint layer keeps its silent degrade-to-bare-text there - // rather than throwing (a throw in the post-flush paint pass wedges Vue's - // scheduler). Same accepted limitation as the borderStyle fix (#124). - - // Validate borderStyle during RENDER so an unknown name is caught by - // vue-tui's error boundary (onErrorCaptured → ErrorOverview), exactly like - // any other component render error. Ink crashes on an unknown borderStyle - // with a raw TypeError during paint (render-border.ts reads box.topLeft off - // cliBoxes[name] === undefined); we align to that "throw on unknown" contract - // but do it here rather than in paint, where a throw would unwind through - // Vue's post-flush commit and wedge its scheduler. Only a NON-EMPTY unknown - // STRING throws: a falsy value (false/undefined/"" = no border) and a custom - // BoxStyle OBJECT are both valid and pass through. (audit 2.3) - // `borderStyle.length > 0` (not `!== ""`): once `typeof === "string"` narrows - // the prop to the BorderStyle keyof union, an `!== ""` literal comparison has - // "no overlap" per TS (the union has no `""` member). The empty string is only - // reachable via a TS-bypass; `.length > 0` excludes it without tripping that. - // - // We validate the RESOLVED box has a real BoxStyle SHAPE rather than testing - // `borderStyle in cliBoxes`, because `in` has two false-accept holes that let - // a non-box value reach paint (which then reads `.top`/`.topLeft` glyph - // strings off it): - // 1. cli-boxes' default export carries a CJS-interop `default` self-key, so - // `"default" in cliBoxes` is true — but cliBoxes.default is the WHOLE - // boxes object, not a BoxStyle (it has no string `top`). - // 2. `in` walks the prototype chain, so Object.prototype members - // ("toString", "constructor", "hasOwnProperty", …) report as "in - // cliBoxes" while resolving to a function/undefined — never a BoxStyle. - // Resolving `cliBoxes[name]` and requiring an object with a string `top` - // rejects unknown names (undefined), "default" (whole object, no string - // `top`), and inherited props, while accepting every real preset. - const borderStyle = props.borderStyle; - if (typeof borderStyle === "string" && borderStyle.length > 0) { - // Cast via `unknown` to add a string index signature: cliBoxes is typed as - // the `Boxes` keyof object (no index signature), so a direct cast is a TS2352 - // "insufficient overlap" error. Same cast paint.ts uses to look up by name. - const resolved = (cliBoxes as unknown as Record)[borderStyle]; - if (typeof resolved !== "object" || resolved === null || typeof resolved.top !== "string") { - throw new Error(`Unknown borderStyle: ${JSON.stringify(borderStyle)}`); - } - } - - const ariaLabel = props.ariaLabel; - const label = ariaLabel ? h("text", null, ariaLabel) : undefined; - - return h("box", props as never, isScreenReaderEnabled && label ? [label] : slots.default?.()); - }; - }, -}); - -export const Box = BoxImpl as WithChildren; - -/** Props accepted by `` — the vue-tui analogue of Ink's `BoxProps`. */ -export type BoxProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/components/box.vue b/packages/runtime/src/components/box.vue new file mode 100644 index 0000000..0b24e67 --- /dev/null +++ b/packages/runtime/src/components/box.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/runtime/src/components/error-overview.ts b/packages/runtime/src/components/error-overview.ts index 637d88d..7c9e7c1 100644 --- a/packages/runtime/src/components/error-overview.ts +++ b/packages/runtime/src/components/error-overview.ts @@ -3,8 +3,8 @@ import { cwd } from "node:process"; import { defineComponent, h, type PropType } from "vue"; import StackUtils from "stack-utils"; import codeExcerpt, { type CodeExcerpt } from "code-excerpt"; -import { Box } from "./box.ts"; -import { Text } from "./text.ts"; +import Box from "./box.vue"; +import Text from "./text.vue"; // Ported from Ink's src/components/ErrorOverview.tsx (v7.0.4). We use the // and wrapper components (not raw host elements) because Ink does, and diff --git a/packages/runtime/src/components/newline-props.ts b/packages/runtime/src/components/newline-props.ts new file mode 100644 index 0000000..1825463 --- /dev/null +++ b/packages/runtime/src/components/newline-props.ts @@ -0,0 +1,6 @@ +import type { ExtractPublicPropTypes } from "vue"; + +export const newlineProps = { count: { type: Number, default: 1 } }; + +/** Props accepted by `` — the vue-tui analogue of Ink's `NewlineProps`. */ +export type NewlineProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/components/newline.ts b/packages/runtime/src/components/newline.ts deleted file mode 100644 index 38f4a15..0000000 --- a/packages/runtime/src/components/newline.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { defineComponent, getCurrentInstance, h, type ExtractPublicPropTypes } from "vue"; - -const newlineProps = { count: { type: Number, default: 1 } }; - -export const Newline = defineComponent({ - name: "Newline", - props: newlineProps, - setup(props) { - return () => { - const content = "\n".repeat(props.count); - // Inside a Text parent, render as inline virtual-text. - // Outside Text, render as "text" (yoga carrier) so Newline participates - // in layout standalone, matching Ink's ink-text behavior. - if (isInsideText()) { - return h("virtual-text", {}, content); - } - return h("text", {}, content); - }; - }, -}); - -function isInsideText(): boolean { - let parent = getCurrentInstance()?.parent; - while (parent) { - const name = parent.type && (parent.type as { name?: string }).name; - // A is also a text context: Ink models it as an ink-text host, - // so a directly inside a standalone renders inline - // (an inline line break in the transform's text), not as a standalone yoga - // "text" node. (G58) - if (name === "Text" || name === "Transform") return true; - parent = parent.parent; - } - return false; -} - -/** Props accepted by `` — the vue-tui analogue of Ink's `NewlineProps`. */ -export type NewlineProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/components/newline.vue b/packages/runtime/src/components/newline.vue new file mode 100644 index 0000000..0ac4a5f --- /dev/null +++ b/packages/runtime/src/components/newline.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/runtime/src/components/spacer-props.ts b/packages/runtime/src/components/spacer-props.ts new file mode 100644 index 0000000..10761e2 --- /dev/null +++ b/packages/runtime/src/components/spacer-props.ts @@ -0,0 +1,9 @@ +import type { ExtractPublicPropTypes } from "vue"; + +// Spacer takes no props (it is a fixed flex-grow box). The empty object keeps the +// same `*-props.ts` + `ExtractPublicPropTypes` pattern the other components use, so +// `keyof SpacerProps` is `never` — matching Ink's empty `SpacerProps`. +export const spacerProps = {}; + +/** Props accepted by `` — the vue-tui analogue of Ink's `SpacerProps`. */ +export type SpacerProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/components/spacer.ts b/packages/runtime/src/components/spacer.ts deleted file mode 100644 index f8db033..0000000 --- a/packages/runtime/src/components/spacer.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineComponent, h, type ExtractPublicPropTypes } from "vue"; - -const spacerProps = {}; - -const SpacerImpl = defineComponent({ - name: "Spacer", - props: spacerProps, - setup() { - return () => h("box", { flexGrow: 1, flexShrink: 1 }); - }, -}); - -/** Props accepted by `` — the vue-tui analogue of Ink's `SpacerProps`. */ -export type SpacerProps = ExtractPublicPropTypes; - -export const Spacer = SpacerImpl as typeof SpacerImpl & { - new (): { $props: SpacerProps & { children?: never } }; -}; diff --git a/packages/runtime/src/components/spacer.vue b/packages/runtime/src/components/spacer.vue new file mode 100644 index 0000000..0072f13 --- /dev/null +++ b/packages/runtime/src/components/spacer.vue @@ -0,0 +1,7 @@ + + + diff --git a/packages/runtime/src/components/static-props.ts b/packages/runtime/src/components/static-props.ts new file mode 100644 index 0000000..ce14646 --- /dev/null +++ b/packages/runtime/src/components/static-props.ts @@ -0,0 +1,35 @@ +import type { ExtractPublicPropTypes, PropType, VNodeChild } from "vue"; +import type { BoxLayoutStyle } from "./box-props.ts"; + +/** The `{ item, index }` object a `` scoped slot receives per item. */ +export interface StaticSlotProps { + item: T; + index: number; +} + +/** A `` default scoped slot: rendered once per item. */ +export type StaticSlot = (props: StaticSlotProps) => VNodeChild; + +/** Accepted `` children: a bare scoped slot or a `{ default }` slot object. */ +export type StaticChildren = StaticSlot | { default: StaticSlot }; + +/** ``'s `style` surface — the same layout style keys a `` accepts. */ +export type StaticStyle = BoxLayoutStyle; + +export const staticProps = { + // `required: true as const` (not bare `true`): a standalone `const` widens + // `true` -> `boolean`, dropping `items` from the required keys. The literal keeps + // `items` required, matching Ink's `StaticProps`. + items: { type: Array as PropType, required: true as const }, + style: { type: Object as PropType, default: undefined }, +}; + +type StaticBaseProps = ExtractPublicPropTypes; + +/** + * Props accepted by `` — the vue-tui analogue of Ink's `StaticProps`. + * Generic over the item type so `items: T[]` flows into the scoped slot's `item`. + */ +export type StaticProps = Omit & { + items: T[]; +}; diff --git a/packages/runtime/src/components/static.ts b/packages/runtime/src/components/static.ts deleted file mode 100644 index b941453..0000000 --- a/packages/runtime/src/components/static.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - defineComponent, - h, - shallowRef, - watch, - type ExtractPublicPropTypes, - type PropType, - type SlotsType, - type VNodeChild, -} from "vue"; -import type { BoxLayoutStyle } from "./box.ts"; - -export interface StaticSlotProps { - item: T; - index: number; -} - -export type StaticSlot = (props: StaticSlotProps) => VNodeChild; - -export type StaticChildren = StaticSlot | { default: StaticSlot }; - -export type StaticStyle = BoxLayoutStyle; - -const staticProps = { - // `required: true as const` (not bare `true`): a standalone `const` widens - // `true` → `boolean`, which would drop `items` from ExtractPublicPropTypes' - // required keys (and from the component's own `props.items` typing). The - // literal keeps `items` required, matching Ink's `StaticProps`. - items: { type: Array as PropType, required: true as const }, - style: { type: Object as PropType, default: undefined }, -}; - -const StaticImpl = defineComponent({ - name: "Static", - props: staticProps, - slots: Object as SlotsType<{ - default: StaticSlot; - }>, - setup(props, { slots }) { - const defaultStyle: StaticStyle = { - position: "absolute", - flexDirection: "column", - }; - - // Mirrors Ink's `const [index, setIndex] = useState(0)`. Only items at or - // after `cursor` are rendered; once written, the renderer advances the - // cursor (via the onWritten callback below) so written items unmount. - // shallowRef is sufficient — we only ever reassign the number. - const cursor = shallowRef(0); - - // Invoked by the renderer AFTER a commit has painted the freshly-written - // items. Together with the watch below this is the vue-tui analogue of Ink's - // post-commit `useLayoutEffect(() => setIndex(items.length), [items.length])`. - // - // This callback handles the GROW / steady-state direction: advancing the - // cursor only AFTER paint guarantees freshly-appended items are written - // before they are sliced out and unmounted. It must run post-paint, never - // during render, which is why it can't be a plain watcher. We SET the cursor - // to items.length (not max-with-current); assigning an equal number is a - // reactivity no-op (Vue triggers on Object.is inequality), so the common - // resync-to-same-length case can't loop. - const onWritten = () => { - cursor.value = (props.items as unknown[]).length; - }; - - // Handles the SHRINK direction, mirroring Ink's effect firing on every - // [items.length] change — including decreases. When items shrink, the - // already-rendered Static children may already be empty (sliced out), so no - // host mutation occurs and no commit/onWritten fires; the cursor would stay - // stranded above the new length and silently drop any later-appended items - // (e.g. [A,B] cursor→2, shrink to [A], grow to [A,C] → slice(2)=[] drops C). - // Lowering the cursor on shrink is safe without waiting for a paint: shrinking - // never needs to write anything, it only re-syncs the slice window down. - watch( - () => (props.items as unknown[]).length, - (len) => { - if (len < cursor.value) cursor.value = len; - }, - ); - - return () => { - const merged = { ...defaultStyle, ...props.style }; - const items = props.items as unknown[]; - const start = cursor.value; - const itemsToRender = items.slice(start); - return h( - "static", - { ...merged, internal_onWritten: onWritten }, - itemsToRender.map((item, i) => slots.default?.({ item, index: start + i })), - ); - }; - }, -}); - -/** Props accepted by `` — the vue-tui analogue of Ink's `StaticProps`. */ -type StaticBaseProps = ExtractPublicPropTypes; - -export type StaticProps = Omit & { - items: T[]; -}; - -export const Static = StaticImpl as typeof StaticImpl & { - new (): { - $props: StaticProps & { children?: StaticChildren }; - $slots: { - default?: StaticSlot; - }; - }; -}; diff --git a/packages/runtime/src/components/static.vue b/packages/runtime/src/components/static.vue new file mode 100644 index 0000000..5edc59c --- /dev/null +++ b/packages/runtime/src/components/static.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/runtime/src/components/text-props.ts b/packages/runtime/src/components/text-props.ts new file mode 100644 index 0000000..f880056 --- /dev/null +++ b/packages/runtime/src/components/text-props.ts @@ -0,0 +1,26 @@ +import { type ExtractPublicPropTypes, type PropType } from "vue"; + +type WrapMode = + | "wrap" + | "hard" + | "truncate" + | "truncate-end" + | "truncate-middle" + | "truncate-start"; + +export const textProps = { + color: String, + backgroundColor: String, + dimColor: Boolean, + bold: Boolean, + italic: Boolean, + underline: Boolean, + strikethrough: Boolean, + inverse: Boolean, + wrap: { type: String as PropType, default: "wrap" }, + ariaLabel: String, + ariaHidden: Boolean, +}; + +/** Props accepted by `` — the vue-tui analogue of Ink's `TextProps`. */ +export type TextProps = ExtractPublicPropTypes; diff --git a/packages/runtime/src/components/text.ts b/packages/runtime/src/components/text.ts deleted file mode 100644 index 6b9e9f3..0000000 --- a/packages/runtime/src/components/text.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - Comment, - Text as VueText, - defineComponent, - getCurrentInstance, - h, - inject, - isVNode, - type ExtractPublicPropTypes, - type PropType, - type VNode, -} from "vue"; -import { AppContextKey } from "../context.ts"; -import { assertValidBackgroundColor, assertValidForegroundColor } from "../paint/text-style.ts"; -import type { WithChildren } from "./with-children.ts"; - -type WrapMode = - | "wrap" - | "hard" - | "truncate" - | "truncate-end" - | "truncate-middle" - | "truncate-start"; - -const textProps = { - color: String, - backgroundColor: String, - dimColor: Boolean, - bold: Boolean, - italic: Boolean, - underline: Boolean, - strikethrough: Boolean, - inverse: Boolean, - wrap: { type: String as PropType, default: "wrap" }, - ariaLabel: String, - ariaHidden: Boolean, -}; - -const TextImpl = defineComponent({ - name: "Text", - props: textProps, - setup(props, { slots }) { - const appCtx = inject(AppContextKey, null); - - return () => { - const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false; - - // When screen reader is enabled and aria-hidden is set, render nothing. - if (isScreenReaderEnabled && props.ariaHidden) { - return null; - } - - const ariaLabel = props.ariaLabel; - const children = isScreenReaderEnabled && ariaLabel ? ariaLabel : slots.default?.(); - - if (children === undefined || children === null) { - return null; - } - - // Validate backgroundColor during RENDER so a chalk-modifier name (the - // exact case Ink's colorize.ts throws on) is caught by vue-tui's error - // boundary, not the post-flush paint pass where a throw wedges the - // scheduler. See assertValidBackgroundColor / Ink colorize.ts (40b3a75). - // - // Gated to mirror Ink WHERE it colorizes. Ink's attaches a - // colorizing `transform` to the ink-text node, but that transform only - // runs on NON-EMPTY text: squash-text-nodes.ts applies it per child only - // when `nodeText.length > 0`, and render-node-to-output.ts writes (and so - // colorizes) only when the squashed `text.length > 0`. So Ink does NOT - // throw for text that squashes to empty — even though it still renders the - // (empty) node. The `children === null/undefined` early-return above only - // catches a literal null/undefined child; a `{""}` empty-string child (or - // a group of only empty/inert children) gets past it, so we additionally - // skip validation when the content would render empty. (A12) - if (wouldRenderNonEmptyText(children)) { - assertValidForegroundColor(props.color); - assertValidBackgroundColor(props.backgroundColor); - } - - const insideText = isInsideText(); - if (insideText) { - return h("virtual-text", props as never, children); - } - // Match Ink's defaults: flexShrink=1 so text nodes shrink when - // they overflow their container (e.g. in no-wrap flex rows). - return h("text", { ...props, flexShrink: 1 } as never, children); - }; - }, -}); - -export const Text = TextImpl as WithChildren; - -/** Props accepted by `` — the vue-tui analogue of Ink's `TextProps`. */ -export type TextProps = ExtractPublicPropTypes; - -/** - * Heuristic for Ink's "would this colorize?" condition: its transform - * only runs on non-empty squashed text (squash-text-nodes.ts gates each child on - * `nodeText.length > 0`; render-node-to-output.ts writes only when the whole - * `text.length > 0`). We can't squash at render time (nested content isn't - * known yet), so we conservatively answer true unless the content is provably - * empty. Provably-empty = an `""`/whitespace-collapsing string, or a children - * array whose every entry is an empty-string text vnode or an inert Comment - * (Vue's materialization of a `null`/`false`/`v-if` child). ANY element/component - * child or any non-empty text makes it true — matching Ink, which would then - * colorize the squashed result. The only residual over-throw is a contrived - * element child that itself squashes to empty (e.g. a nested empty ); that - * is the same irreducible class as Box's content-area gate. - */ -function wouldRenderNonEmptyText(children: string | VNode[]): boolean { - if (typeof children === "string") return children.length > 0; - return !children.every((child) => { - if (!isVNode(child)) return false; - if (child.type === Comment) return true; - // A Vue text vnode carries its string in `children`; empty ⇒ no text. - if (child.type === VueText) return typeof child.children !== "string" || child.children === ""; - return false; - }); -} - -function isInsideText(): boolean { - let parent = getCurrentInstance()?.parent; - while (parent) { - const name = parent.type && (parent.type as { name?: string }).name; - // A is also a text context: Ink models it as an ink-text host, - // so a directly inside a renders inline (as a nested - // ink-text squashed into the transform's text), matching Ink's - // …. (G58) - if (name === "Text" || name === "Transform") return true; - parent = parent.parent; - } - return false; -} diff --git a/packages/runtime/src/components/text.vue b/packages/runtime/src/components/text.vue new file mode 100644 index 0000000..15fb65a --- /dev/null +++ b/packages/runtime/src/components/text.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/runtime/src/components/transform.ts b/packages/runtime/src/components/transform.ts index 1375d48..dbff9ca 100644 --- a/packages/runtime/src/components/transform.ts +++ b/packages/runtime/src/components/transform.ts @@ -4,11 +4,12 @@ import { h, inject, isVNode, + provide, type ExtractPublicPropTypes, type PropType, type VNode, } from "vue"; -import { AppContextKey } from "../context.ts"; +import { AppContextKey, TextContextKey } from "../context.ts"; import type { WithChildren } from "./with-children.ts"; type TransformFn = (line: string, lineIndex: number) => string; @@ -26,6 +27,10 @@ const TransformImpl = defineComponent({ props: transformProps, setup(props, { slots }) { const appCtx = inject(AppContextKey, null); + // A is a text context: Ink models it as an ink-text host, so + // descendant / render inline (squashed into the transform's + // text). It only provides — it never injects. (G58) + provide(TextContextKey, true); return () => { const children = slots.default?.(); diff --git a/packages/runtime/src/composables/useBoxMetrics.ts b/packages/runtime/src/composables/useBoxMetrics.ts index c8a128b..48e1908 100644 --- a/packages/runtime/src/composables/useBoxMetrics.ts +++ b/packages/runtime/src/composables/useBoxMetrics.ts @@ -29,10 +29,47 @@ export interface UseBoxMetricsReturn { readonly hasMeasured: ShallowRef; } +/** + * A component whose root is a `v-if`/`v-else` (e.g. the template-authored ``) + * renders as a Vue Fragment, so its `$el` resolves to the fragment's BOUNDARY anchor + * (an empty `text-leaf`), NOT the real `box` host node. The actual host node lives in + * the component's `subTree`. Walk that vnode tree to the first `el` that is a genuine + * host node — skipping the comment and empty-`text-leaf` anchors a fragment inserts — + * so a ref to a fragment-rooted Box still resolves to its `box` host node. + */ +function hostElFromSubTree(instance: unknown): Record | null { + const subTree = (instance as { subTree?: unknown })?.subTree; + return findHostEl(subTree); +} + +function findHostEl(vnode: unknown): Record | null { + if (!vnode || typeof vnode !== "object") return null; + const vn = vnode as { el?: unknown; component?: { subTree?: unknown }; children?: unknown }; + const el = vn.el as Record | undefined; + // A real host node carries a string `type` AND is not an empty boundary anchor. + if (el && typeof el.type === "string" && el.type !== "comment") { + if (!(el.type === "text-leaf" && el.value === "")) return el; + } + // A nested component (e.g. wrapping another component): descend its subTree. + if (vn.component?.subTree) { + const nested = findHostEl(vn.component.subTree); + if (nested) return nested; + } + // A fragment carries its real children in an array; the box vnode is among them. + if (Array.isArray(vn.children)) { + for (const child of vn.children) { + const found = findHostEl(child); + if (found) return found; + } + } + return null; +} + /** * Resolve a ref value to the underlying TUI node with a yoga property. - * Handles both direct TUI node refs and Vue component instance refs (where - * the TUI node is accessible via `$el`). + * Handles direct TUI node refs, component instance refs whose `$el` IS the host + * node, and fragment-rooted component refs (whose `$el` is a boundary anchor, so + * the host node is found via the component's subTree). */ function resolveYogaNode(value: unknown): { yoga: YogaNode } | null { if (!value) return null; @@ -43,6 +80,9 @@ function resolveYogaNode(value: unknown): { yoga: YogaNode } | null { if (obj.$el && (obj.$el as Record).yoga) { return obj.$el as { yoga: YogaNode }; } + // Fragment-rooted component (template with a root v-if): drill the subTree. + const host = hostElFromSubTree(obj.$); + if (host?.yoga) return host as { yoga: YogaNode }; return null; } @@ -51,10 +91,16 @@ function resolveTuiNode(value: unknown): TuiNode | null { if (!value) return null; const obj = value as Record; if (typeof obj.type === "string") return obj as unknown as TuiNode; - // Vue component instance — root host element is on $el - if (obj.$el && typeof (obj.$el as Record).type === "string") { - return obj.$el as unknown as TuiNode; + // Vue component instance whose `$el` IS a real host node (non-template/unconditional + // root). An empty `text-leaf` `$el` is a fragment boundary anchor, not the element — + // fall through to the subTree drill below so we anchor traversal on the real node. + const el = obj.$el as Record | undefined; + if (el && typeof el.type === "string" && !(el.type === "text-leaf" && el.value === "")) { + return el as unknown as TuiNode; } + // Fragment-rooted component (template with a root v-if): drill the subTree. + const host = hostElFromSubTree(obj.$); + if (host && typeof host.type === "string") return host as unknown as TuiNode; return null; } diff --git a/packages/runtime/src/context.ts b/packages/runtime/src/context.ts index 10645b8..e0ec062 100644 --- a/packages/runtime/src/context.ts +++ b/packages/runtime/src/context.ts @@ -56,3 +56,8 @@ export const AppContextKey: InjectionKey = Symbol("vue-tui:app"); export const FocusContextKey: InjectionKey = Symbol("vue-tui:focus"); export const StdinContextKey: InjectionKey = Symbol("vue-tui:stdin"); export const AnimationSchedulerKey: InjectionKey = Symbol("vue-tui:animation"); +// Provided by and ; injected by and to decide +// whether they render inline `virtual-text` (inside a text context) or a standalone +// yoga `text`. Replaces the former getCurrentInstance() parent-walk — see +// .agents/docs/component-authoring.md. +export const TextContextKey: InjectionKey = Symbol("vue-tui:text-context"); diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts index d534bfe..fb1f079 100644 --- a/packages/runtime/src/host/node-ops.ts +++ b/packages/runtime/src/host/node-ops.ts @@ -418,7 +418,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions` boundary anchor) advances the + * index. Verified against real Ink v7.0.4 (`a{''}b` → `ab[1]`, not + * `ab[2]`). The inverse of static-channel's `isInertStaticAnchor`. + */ +export function advancesLineIndex(child: TuiNode): boolean { + return child.type !== "comment" && !(child.type === "text-leaf" && child.value === ""); +} diff --git a/packages/runtime/src/host/text-measure.test.ts b/packages/runtime/src/host/text-measure.test.ts index 625524a..182fdc9 100644 --- a/packages/runtime/src/host/text-measure.test.ts +++ b/packages/runtime/src/host/text-measure.test.ts @@ -5,8 +5,8 @@ import wrapAnsi from "wrap-ansi"; import { createText, createTextLeaf, createTransform, createVirtualText } from "./nodes.ts"; import { flattenLeaves, measureTextNatural, wrapText } from "./text-measure.ts"; import { renderToString } from "../render-to-string.ts"; -import { Box } from "../components/box.ts"; -import { Text } from "../components/text.ts"; +import Box from "../components/box.vue"; +import Text from "../components/text.vue"; // Minimal ANSI-stripping helper for test assertions (avoids strip-ansi dep). function stripAnsi(s: string): string { diff --git a/packages/runtime/src/host/text-measure.ts b/packages/runtime/src/host/text-measure.ts index cc1c79f..a1d96c9 100644 --- a/packages/runtime/src/host/text-measure.ts +++ b/packages/runtime/src/host/text-measure.ts @@ -5,6 +5,7 @@ import wrapAnsi from "wrap-ansi"; import { tokenizeAnsi } from "../paint/ansi-tokenizer.ts"; import { sanitizeAnsi } from "../paint/sanitize-ansi.ts"; import type { TextProps, TuiNode, TuiText, TuiTransform, TuiVirtualText } from "./nodes.ts"; +import { advancesLineIndex } from "./nodes.ts"; export function flattenLeaves(node: TuiText | TuiVirtualText): string { if (!node.children || node.children.length === 0) return ""; @@ -21,7 +22,7 @@ export function flattenLeaves(node: TuiText | TuiVirtualText): string { let transformIndex = 0; for (const child of node.children) { out += squashTransformChild(child, transformIndex); - if (child.type !== "comment") transformIndex++; + if (advancesLineIndex(child)) transformIndex++; } // Sanitize the measured string so MEASURE+WRAP operate on the SAME bytes PAINT // emits — parity gap #9. Ink's squashTextNodes returns sanitizeAnsi(text) @@ -82,7 +83,7 @@ function squashTransformChild(child: TuiNode, index: number): string { let grandIndex = 0; for (const grandchild of child.children) { innerText += squashTransformChild(grandchild, grandIndex); - if (grandchild.type !== "comment") grandIndex++; + if (advancesLineIndex(grandchild)) grandIndex++; } if (innerText.length > 0 && child.transform) innerText = child.transform(innerText, index); return innerText; @@ -103,7 +104,7 @@ export function flattenTransformLeaves(node: TuiTransform): string { let transformIndex = 0; for (const child of node.children) { out += squashTransformChild(child, transformIndex); - if (child.type !== "comment") transformIndex++; + if (advancesLineIndex(child)) transformIndex++; } // Sanitize for the same reason as flattenLeaves (parity gap #9; see that comment // for the two distinct width/wrap mechanisms): the standalone measure diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index a4dd148..e14c609 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,25 +1,57 @@ +import BoxSfc from "./components/box.vue"; +import TextSfc from "./components/text.vue"; +import StaticSfc from "./components/static.vue"; +import SpacerSfc from "./components/spacer.vue"; +import type { WithChildren } from "./components/with-children.ts"; +import type { StaticChildren, StaticProps, StaticSlot } from "./components/static-props.ts"; +import type { SpacerProps } from "./components/spacer-props.ts"; + export { createApp, type TuiApp, type MountOptions } from "./render.ts"; export { renderToString, type RenderToStringOptions } from "./render-to-string.ts"; -export { - Box, - type AriaRole, - type AriaState, - type BoxLayoutStyle, - type BoxStyle, - type BoxProps, -} from "./components/box.ts"; -export { Text, type TextProps } from "./components/text.ts"; -export { Newline, type NewlineProps } from "./components/newline.ts"; -export { Spacer, type SpacerProps } from "./components/spacer.ts"; -export { - Static, - type StaticChildren, - type StaticProps, - type StaticSlot, - type StaticSlotProps, - type StaticStyle, -} from "./components/static.ts"; +export const Box = BoxSfc as WithChildren; +export type { + AriaRole, + AriaState, + BoxLayoutStyle, + BoxStyle, + BoxProps, +} from "./components/box-props.ts"; +export const Text = TextSfc as WithChildren; +export type { TextProps } from "./components/text-props.ts"; +export { default as Newline } from "./components/newline.vue"; +export type { NewlineProps } from "./components/newline-props.ts"; +// Spacer takes no props and no children; the `children?: never` cast makes +// `x` a type error under the automatic JSX runtime (parity with +// main's spacer.ts typing). `as unknown as` (like Static) REPLACES the SFC type: +// the empty `.vue`'s DefineComponent carries an `any` that would make a `typeof +// SpacerSfc & {…}` intersection redundant (lint: no-redundant-type-constituents). +export const Spacer = SpacerSfc as unknown as { + new (): { $props: SpacerProps & { children?: never } }; +}; +export type { SpacerProps } from "./components/spacer-props.ts"; +// Static exposes typed scoped slots: children receive `{ item, index }` with +// `item` inferred from `items: T[]` (parity with main's static.ts generic cast). +// `as unknown as` REPLACES the SFC's type rather than intersecting it: a `.vue` +// with a scoped `` emits an extra `__VLS_WithSlots` construct signature that +// bakes a NON-generic `default?: (p: { item: unknown }) => …`. Intersecting (or +// keeping `typeof StaticSfc` in the target) leaves that competing signature in +// place and blocks `T` inference (item → any). A clean generic construct signature +// is all JSX/template resolution needs, and it is exactly the public shape main's +// defineComponent-based cast produced. +export const Static = StaticSfc as unknown as { + new (): { + $props: StaticProps & { children?: StaticChildren }; + $slots: { default?: StaticSlot }; + }; +}; +export type { + StaticChildren, + StaticProps, + StaticSlot, + StaticSlotProps, + StaticStyle, +} from "./components/static-props.ts"; export { Transform, type TransformProps } from "./components/transform.ts"; export { useApp, type UseAppReturn } from "./composables/useApp.ts"; diff --git a/packages/runtime/src/overlay.ts b/packages/runtime/src/overlay.ts index 02eae8a..9d79c72 100644 --- a/packages/runtime/src/overlay.ts +++ b/packages/runtime/src/overlay.ts @@ -1,6 +1,6 @@ import { defineComponent, h, inject, type Component, type PropType } from "@vue/runtime-core"; -import { Box } from "./components/box.ts"; -import { Text } from "./components/text.ts"; +import Box from "./components/box.vue"; +import Text from "./components/text.vue"; import { DevStateKey, type DevState } from "./hmr.ts"; const ErrorDisplay = defineComponent({ diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index a7c3802..a2453d6 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -22,7 +22,11 @@ import type { TuiBox, } from "../host/nodes.ts"; import { transformHasYogaChild } from "../host/yoga.ts"; -import { createRoot as createIsoRoot, createBox as createIsoBox } from "../host/nodes.ts"; +import { + createRoot as createIsoRoot, + createBox as createIsoBox, + advancesLineIndex, +} from "../host/nodes.ts"; import { calculateLayoutWithContentGuards } from "../host/layout-guards.ts"; import { wrapText, safeSliceEnd } from "../host/text-measure.ts"; import { attachYoga, detachYoga } from "../host/yoga.ts"; @@ -388,9 +392,10 @@ function squashInlineChildren(children: readonly TuiNode[], inheritedBg: unknown let transformIndex = 0; for (const child of children) { out += squashTransformChild(child, transformIndex, inheritedBg); - // Comments (Vue's null/v-if/false renders) contribute "" and, like React's - // absent childNodes, must NOT advance the transform index. - if (child.type !== "comment") transformIndex++; + // Comments (Vue's null/v-if/false renders) and EMPTY text-leaves (`{''}` / + // template anchors) contribute "" and, like React's absent childNodes, + // must NOT advance the transform index. + if (advancesLineIndex(child)) transformIndex++; } return out; } @@ -445,7 +450,7 @@ function squashTransformChild(child: TuiNode, index: number, inheritedBg: unknow let grandIndex = 0; for (const grandchild of child.children) { innerText += squashTransformChild(grandchild, grandIndex, inheritedBg); - if (grandchild.type !== "comment") grandIndex++; + if (advancesLineIndex(grandchild)) grandIndex++; } if (innerText.length > 0 && child.transform) { innerText = child.transform(innerText, index); diff --git a/packages/runtime/src/paint/screen-reader.ts b/packages/runtime/src/paint/screen-reader.ts index 3223bde..fbcdf81 100644 --- a/packages/runtime/src/paint/screen-reader.ts +++ b/packages/runtime/src/paint/screen-reader.ts @@ -1,5 +1,6 @@ import Yoga from "yoga-layout"; import type { TuiNode, TuiText, TuiVirtualText, TuiBox } from "../host/nodes.ts"; +import { advancesLineIndex } from "../host/nodes.ts"; import { sanitizeAnsi } from "./sanitize-ansi.ts"; /** @@ -23,13 +24,14 @@ function squashChildSR(child: TuiNode, index: number): string { if (child.type === "transform") { let innerText = ""; // Recurse into the transform's children (each may itself be a transform, - // recursed to any depth), skipping Vue comment nodes for the index basis - // (G52), then apply THIS child transform's own fn (it is a child). Matches + // recursed to any depth), skipping Vue comment nodes and empty text-leaves + // for the index basis (G52), then apply THIS child transform's own fn (it is a + // child). Matches // Ink squash-text-nodes.ts:34 (`internal_transform(nodeText, index)`). let grandIndex = 0; for (const grandchild of child.children) { innerText += squashChildSR(grandchild, grandIndex); - if (grandchild.type !== "comment") grandIndex++; + if (advancesLineIndex(grandchild)) grandIndex++; } if (innerText.length > 0 && child.transform) { innerText = child.transform(innerText, index); @@ -58,9 +60,10 @@ function squashTextContent(node: TuiText | TuiVirtualText): string { let index = 0; for (const child of node.children) { text += squashChildSR(child, index); - // Comments (Vue's null/v-if/false renders) contribute nothing and, like - // React's absent childNodes, must NOT advance the transform index. - if (child.type !== "comment") index++; + // Comments (Vue's null/v-if/false renders) and EMPTY text-leaves (`{''}` / + // template anchors) contribute nothing and, like React's absent + // childNodes, must NOT advance the transform index. + if (advancesLineIndex(child)) index++; } // Strip cursor/erase control sequences (keep SGR/OSC) from the squashed text, // exactly as Ink's squashTextNodes returns sanitizeAnsi(text) @@ -196,7 +199,7 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt let squashed = ""; for (const childNode of node.children) { squashed += squashChildSR(childNode, index); - if (childNode.type !== "comment") index++; + if (advancesLineIndex(childNode)) index++; } // A standalone is an `ink-text` node in Ink, squashed via // squashTextNodes which returns sanitizeAnsi(text) (squash-text-nodes.ts:45). diff --git a/packages/runtime/src/paint/static-channel.ts b/packages/runtime/src/paint/static-channel.ts index 57c8be9..572e4c4 100644 --- a/packages/runtime/src/paint/static-channel.ts +++ b/packages/runtime/src/paint/static-channel.ts @@ -34,6 +34,10 @@ export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] { return out; } +function isInertStaticAnchor(child: TuiNode): boolean { + return child.type === "comment" || (child.type === "text-leaf" && child.value === ""); +} + /** * Paint the not-yet-written children of a single node and record them * as written. Returns the painted frame (without trailing "\n"), or "" when @@ -54,6 +58,7 @@ export function paintStaticNode( isScreenReaderEnabled = false, ): string { const fresh = stat.children.filter((child) => !stat.writtenNodes.has(child)); + const paintableFresh = fresh.filter((child) => !isInertStaticAnchor(child)); // Paint (and record as written) only when there is something fresh — but the // prune and onWritten steps below run on EVERY commit, including the empty // commit that follows a cursor advance (children sliced to []). That empty @@ -62,7 +67,7 @@ export function paintStaticNode( // paints, yet Ink's `useLayoutEffect(setIndex(items.length))` still fires and // lowers the cursor so subsequent grows ([A,C]) render and write the new item. let frame = ""; - if (fresh.length > 0) { + if (paintableFresh.length > 0) { if (isScreenReaderEnabled) { // SR mode: linearize the fresh static children to flat plain text instead // of the 2D grid painter — otherwise bordered static items would emit box @@ -76,7 +81,7 @@ export function paintStaticNode( // exactly how screen-reader.ts linearizes a box/root container of these // children (screen-reader.ts:73-82): the separator and child order derive // from the container's resolved flexDirection (defaulting to the - // "column" default set in static.ts). + // "column" default set in static.vue's `merged` computed). const flexDirection = resolvedFlexDirection(stat); // Match screen-reader.ts:76 exactly — row/row-reverse use a space, all // other directions (incl. the column default) use a newline. @@ -84,16 +89,22 @@ export function paintStaticNode( // Match screen-reader.ts:79-82 — *-reverse directions reverse child order. const ordered = flexDirection === "row-reverse" || flexDirection === "column-reverse" - ? [...fresh].reverse() - : fresh; + ? [...paintableFresh].reverse() + : paintableFresh; frame = ordered .map((child) => renderScreenReaderOutput(child, { skipStaticElements: false })) .filter(Boolean) .join(separator); } else { - frame = paintIsolated(fresh, columns, stat); + frame = paintIsolated(paintableFresh, columns, stat); } - for (const child of fresh) stat.writtenNodes.add(child); + for (const child of paintableFresh) stat.writtenNodes.add(child); + } + // Empty text leaves and comments can be framework anchors around a template + // v-for. They render no content, but still need write-once bookkeeping so + // the cursor/prune path behaves like a normal painted batch. + for (const child of fresh) { + if (isInertStaticAnchor(child)) stat.writtenNodes.add(child); } // Prune entries that are no longer mounted so the set can't grow unbounded // over a long-running app (written children get unmounted on the next render). diff --git a/packages/runtime/vite.config.ts b/packages/runtime/vite.config.ts index 4f9c477..a8a53fa 100644 --- a/packages/runtime/vite.config.ts +++ b/packages/runtime/vite.config.ts @@ -1,11 +1,39 @@ import { defineConfig } from "vite-plus"; import vueJsx from "@vitejs/plugin-vue-jsx"; +import Vue from "unplugin-vue/rolldown"; +import VueVite from "unplugin-vue/vite"; + +const HOST_TAGS = ["box", "text", "virtual-text", "static", "transform"]; export default defineConfig({ - plugins: [vueJsx()], + // `VueVite` parses `.vue` SFCs in the TEST/dev graph (unit tests may import the + // .vue components directly, e.g. host/text-measure.test.ts). The `pack` build has + // its own `Vue` rolldown plugin below; both need `isCustomElement` so the host + // tags (`` / `` / …) inside SFC templates compile to raw element + // vnodes instead of being resolved as components. + plugins: [ + vueJsx(), + VueVite({ + template: { + compilerOptions: { + isCustomElement: (tag: string) => HOST_TAGS.includes(tag), + }, + }, + }), + ], pack: { entry: ["src/index.ts", "src/internal.ts"], - dts: true, + plugins: [ + Vue({ + isProduction: true, + template: { + compilerOptions: { + isCustomElement: (tag: string) => HOST_TAGS.includes(tag), + }, + }, + }), + ], + dts: { vue: true }, exports: true, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d1b547..891680c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,9 +42,15 @@ catalogs: tsx: specifier: ^4.22.0 version: 4.22.3 + unplugin-vue: + specifier: ^7.2.0 + version: 7.2.0 vite-plus: specifier: ^0.1.22 version: 0.1.22 + vue-tsc: + specifier: ^3.3.4 + version: 3.3.4 overrides: vite: npm:@voidzero-dev/vite-plus-core@latest @@ -75,10 +81,10 @@ importers: version: 24.12.4 '@vitejs/plugin-vue-jsx': specifier: ^5 - version: 5.1.5(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3)) + version: 5.1.5(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3)) vite: specifier: npm:@voidzero-dev/vite-plus-core@latest - version: '@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' + version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' examples/basic-template: dependencies: @@ -97,10 +103,10 @@ importers: version: 24.12.4 '@vitejs/plugin-vue': specifier: ^6 - version: 6.0.7(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3)) + version: 6.0.7(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3)) vite: specifier: npm:@voidzero-dev/vite-plus-core@latest - version: '@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' + version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' examples/coding-agent: dependencies: @@ -122,10 +128,10 @@ importers: version: 24.12.4 '@vitejs/plugin-vue': specifier: ^6 - version: 6.0.7(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3)) + version: 6.0.7(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3)) vite: specifier: npm:@voidzero-dev/vite-plus-core@latest - version: '@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' + version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' examples/flappy-bird: dependencies: @@ -144,16 +150,16 @@ importers: version: 24.12.4 '@vitejs/plugin-vue': specifier: ^6 - version: 6.0.7(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3)) + version: 6.0.7(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3)) vite: specifier: npm:@voidzero-dev/vite-plus-core@latest - version: '@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' + version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' packages/cli: dependencies: vite: specifier: npm:@voidzero-dev/vite-plus-core@latest - version: '@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' + version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' devDependencies: '@types/node': specifier: 'catalog:' @@ -163,7 +169,7 @@ importers: version: 6.0.3 vite-plus: specifier: 'catalog:' - version: 0.1.22(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3) + version: 0.1.22(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3) packages/runtime: dependencies: @@ -228,12 +234,18 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + unplugin-vue: + specifier: 'catalog:' + version: 7.2.0(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) vite-plus: specifier: ^0.1.20 version: 0.1.22(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.3)) vue: specifier: ^3.4.0 version: 3.5.34(typescript@6.0.3) + vue-tsc: + specifier: 'catalog:' + version: 3.3.4(typescript@6.0.3) packages/runtime-tests: devDependencies: @@ -279,6 +291,9 @@ importers: vue: specifier: ^3.5.34 version: 3.5.34(typescript@6.0.3) + vue-tsc: + specifier: 'catalog:' + version: 3.3.4(typescript@6.0.3) packages/testing: dependencies: @@ -1178,13 +1193,13 @@ packages: yaml: optional: true - '@voidzero-dev/vite-plus-core@0.1.23': - resolution: {integrity: sha512-Twi+95cq1pObzkNR4u6lP7z4gPhtS0/vxeBAdbTvAeA12qlyyFED7mQZnAgaVIN3k1C1ve0997F3/ncUBAwQ8w==} + '@voidzero-dev/vite-plus-core@0.1.24': + resolution: {integrity: sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@tsdown/css': 0.22.0 - '@tsdown/exe': 0.22.0 + '@tsdown/css': 0.22.1 + '@tsdown/exe': 0.22.1 '@types/node': ^20.19.0 || >=22.12.0 '@vitejs/devtools': ^0.1.18 esbuild: ^0.27.0 || ^0.28.0 @@ -1407,6 +1422,15 @@ packages: cpu: [x64] os: [win32] + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vue/babel-helper-vue-transform-on@2.0.1': resolution: {integrity: sha512-uZ66EaFbnnZSYqYEyplWvn46GhZ1KuYSThdT68p+am7MgBNbQ3hphTL9L+xSIsWkdktwhPYLwPgVWqo96jDdRA==} @@ -1435,6 +1459,9 @@ packages: '@vue/compiler-ssr@3.5.34': resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + '@vue/language-core@3.3.4': + resolution: {integrity: sha512-IuHqQ5zGGOE7CXP72VX6A42IVeIzYv4WAhO6arej11TRNqtdZfGyH8Yr2FOCaDX0dSQG+JwULLoFHGY1igYVjQ==} + '@vue/reactivity@3.5.34': resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} @@ -1460,6 +1487,9 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1781,6 +1811,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1848,6 +1881,9 @@ packages: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1963,6 +1999,16 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unplugin-vue@7.2.0: + resolution: {integrity: sha512-6JHWcRbLO3bySOsipQSW9n2VRoPqsx9GLSWBfp7QLPCvpUr/edeAXR7pSl44EEJ+FIp9bG8Zm+xm5dnM/yir4Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^3.2.25 + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2022,6 +2068,15 @@ packages: yaml: optional: true + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-tsc@3.3.4: + resolution: {integrity: sha512-XA/JqmQwS2GZmfgpjOEGdrKwaTSEuPwxpHa7/t6f4yiGrJb3gVHTPb9wBfByMNZwQ+xDXs41b8gaS2DKsOozUw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + vue@3.5.34: resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} peerDependencies: @@ -2037,6 +2092,9 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -2602,14 +2660,14 @@ snapshots: '@types/stack-utils@2.0.3': {} - '@vitejs/plugin-vue-jsx@5.1.5(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3))': + '@vitejs/plugin-vue-jsx@5.1.5(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.1 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0) - vite: '@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' vue: 3.5.34(typescript@6.0.3) transitivePeerDependencies: - supports-color @@ -2638,10 +2696,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.7(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.7(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(vue@3.5.34(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: '@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' vue: 3.5.34(typescript@6.0.3) '@voidzero-dev/vite-plus-core@0.1.21(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)': @@ -2696,7 +2754,7 @@ snapshots: tsx: 4.22.3 typescript: 6.0.3 - '@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)': + '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)': dependencies: '@oxc-project/runtime': 0.133.0 '@oxc-project/types': 0.133.0 @@ -2709,6 +2767,19 @@ snapshots: tsx: 4.22.3 typescript: 6.0.3 + '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)': + dependencies: + '@oxc-project/runtime': 0.133.0 + '@oxc-project/types': 0.133.0 + lightningcss: 1.32.0 + postcss: 8.5.14 + optionalDependencies: + '@types/node': 25.8.0 + esbuild: 0.28.0 + fsevents: 2.3.3 + tsx: 4.22.3 + typescript: 6.0.3 + '@voidzero-dev/vite-plus-darwin-arm64@0.1.21': optional: true @@ -2785,7 +2856,7 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-test@0.1.22(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)': + '@voidzero-dev/vite-plus-test@0.1.22(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 @@ -2799,7 +2870,7 @@ snapshots: tinybench: 2.9.0 tinyexec: 1.2.2 tinyglobby: 0.2.16 - vite: '@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' ws: 8.21.0 optionalDependencies: '@types/node': 24.12.4 @@ -2917,6 +2988,18 @@ snapshots: '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.22': optional: true + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + '@vue/babel-helper-vue-transform-on@2.0.1': {} '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.0)': @@ -2976,6 +3059,16 @@ snapshots: '@vue/compiler-dom': 3.5.34 '@vue/shared': 3.5.34 + '@vue/language-core@3.3.4': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.4 + '@vue/reactivity@3.5.34': dependencies: '@vue/shared': 3.5.34 @@ -3008,6 +3101,8 @@ snapshots: dependencies: humanize-ms: 1.2.1 + alien-signals@3.2.1: {} + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -3277,6 +3372,8 @@ snapshots: ms@2.1.3: {} + muggle-string@0.4.1: {} + nanoid@3.3.12: {} node-addon-api@7.1.1: {} @@ -3367,6 +3464,8 @@ snapshots: patch-console@2.0.0: {} + path-browserify@1.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -3478,6 +3577,42 @@ snapshots: undici-types@7.24.6: {} + unplugin-vue@7.2.0(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)): + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@vue/reactivity': 3.5.34 + obug: 2.1.1 + unplugin: 3.0.0 + vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.8.0)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)' + vue: 3.5.34(typescript@6.0.3) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - yaml + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -3532,12 +3667,12 @@ snapshots: - vite - yaml - vite-plus@0.1.22(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3): + vite-plus@0.1.22(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3): dependencies: '@oxc-project/types': 0.129.0 '@oxlint/plugins': 1.61.0 '@voidzero-dev/vite-plus-core': 0.1.22(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3) - '@voidzero-dev/vite-plus-test': 0.1.22(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.23(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3) + '@voidzero-dev/vite-plus-test': 0.1.22(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3))(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3) oxfmt: 0.48.0 oxlint: 1.63.0(oxlint-tsgolint@0.22.1) oxlint-tsgolint: 0.22.1 @@ -3705,6 +3840,14 @@ snapshots: fsevents: 2.3.3 tsx: 4.22.3 + vscode-uri@3.1.0: {} + + vue-tsc@3.3.4(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.4 + typescript: 6.0.3 + vue@3.5.34(typescript@6.0.3): dependencies: '@vue/compiler-dom': 3.5.34 @@ -3719,6 +3862,8 @@ snapshots: webidl-conversions@3.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5b20ce1..9a4d3e1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,6 +23,8 @@ catalog: terminal-size: ^4.0.1 tsx: ^4.22.0 string-width: ^8.0.0 + unplugin-vue: ^7.2.0 + vue-tsc: ^3.3.4 overrides: vite: "catalog:" vitest: "catalog:"