@markii/ansi 0.0.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/ansi.d.ts +26 -0
  2. package/dist/ansi.js +51 -0
  3. package/dist/components/badge.d.ts +1 -1
  4. package/dist/components/badge.js +5 -4
  5. package/dist/components/callout.d.ts +24 -13
  6. package/dist/components/callout.js +45 -21
  7. package/dist/components/card.d.ts +24 -2
  8. package/dist/components/card.js +41 -12
  9. package/dist/components/cell.d.ts +5 -11
  10. package/dist/components/cell.js +11 -15
  11. package/dist/components/chart.d.ts +4 -10
  12. package/dist/components/chart.js +9 -16
  13. package/dist/components/details-string.d.ts +26 -0
  14. package/dist/components/details-string.js +39 -0
  15. package/dist/components/details.d.ts +30 -10
  16. package/dist/components/details.js +27 -25
  17. package/dist/components/divider.js +9 -11
  18. package/dist/components/figure.d.ts +5 -16
  19. package/dist/components/figure.js +9 -19
  20. package/dist/components/index.d.ts +14 -10
  21. package/dist/components/index.js +19 -16
  22. package/dist/components/kbd.d.ts +1 -1
  23. package/dist/components/kbd.js +3 -2
  24. package/dist/components/layout-wrapper.d.ts +7 -22
  25. package/dist/components/layout-wrapper.js +9 -23
  26. package/dist/components/progress.d.ts +3 -11
  27. package/dist/components/progress.js +6 -14
  28. package/dist/components/rating.d.ts +1 -2
  29. package/dist/components/rating.js +2 -3
  30. package/dist/components/row.d.ts +33 -20
  31. package/dist/components/row.js +33 -51
  32. package/dist/components/stat.d.ts +3 -8
  33. package/dist/components/stat.js +4 -9
  34. package/dist/components/tab.d.ts +9 -8
  35. package/dist/components/tab.js +12 -10
  36. package/dist/components/table-grid.js +1 -2
  37. package/dist/components/table.d.ts +5 -10
  38. package/dist/components/table.js +7 -12
  39. package/dist/components/tabs-string.d.ts +17 -0
  40. package/dist/components/tabs-string.js +30 -0
  41. package/dist/components/tabs.d.ts +29 -14
  42. package/dist/components/tabs.js +37 -30
  43. package/dist/index.d.ts +2 -4
  44. package/dist/index.js +6 -7
  45. package/dist/ink-string.d.ts +12 -0
  46. package/dist/ink-string.js +41 -0
  47. package/dist/interactive.d.ts +41 -0
  48. package/dist/interactive.js +64 -0
  49. package/dist/layout.js +1 -2
  50. package/dist/registry.d.ts +91 -100
  51. package/dist/registry.js +17 -9
  52. package/dist/render.d.ts +39 -24
  53. package/dist/render.js +676 -373
  54. package/dist/text-grid.d.ts +54 -0
  55. package/dist/text-grid.js +249 -0
  56. package/package.json +7 -4
  57. package/dist/box.d.ts +0 -47
  58. package/dist/box.js +0 -209
  59. package/dist/measure.d.ts +0 -25
  60. package/dist/measure.js +0 -108
@@ -1,26 +1,39 @@
1
- import type { AnsiComponent } from '../registry.js';
1
+ import type { AnsiComponent, DirectiveAttributes } from '../registry.js';
2
+ declare const TEXT_ALIGNS: readonly ["left", "center", "right"];
3
+ export type RowTextAlign = (typeof TEXT_ALIGNS)[number];
2
4
  /** Below this width, cells stack vertically instead of sitting side by side — a named constant per the batch brief, not a magic number. */
3
5
  export declare const ROW_COLUMN_THRESHOLD = 60;
6
+ export interface RowLayout {
7
+ align: RowTextAlign;
8
+ columnCount: number;
9
+ colWidth: number;
10
+ stacked: boolean;
11
+ }
4
12
  /**
5
- * `:::row{cols=2|3|4 text=left|center|right} ... :::` — docs/format.md's one
6
- * layout container. An absent/invalid `cols` auto-fits: every direct block
7
- * child becomes one column, all in a single row of columns. A `cols` value
8
- * smaller than the child count wraps into further grid rows, `cols` cells at
9
- * a time — the terminal counterpart of the CSS grid wrap a live host does.
10
- * Below `ROW_COLUMN_THRESHOLD` columns, or with only one cell, cells stack
11
- * vertically instead (a plain blank-line-separated list) — a fixed-width
12
- * terminal has no responsive reflow, so this is the one width breakpoint
13
- * that stands in for it.
13
+ * `:::row{cols=2|3|4 text=left|center|right} ... :::`'s pure layout
14
+ * decision: how many columns, how wide each one is, and whether the row
15
+ * stacks. `render.tsx`'s walk calls this directly (rather than `row`'s own
16
+ * registered component) so it can build each cell's real Ink subtree at the
17
+ * correct width BEFORE assembling the row's flex `Box` — see `render.tsx`'s
18
+ * top comment and `registry.ts`'s doc comment on why this one directive is
19
+ * handled at the walk level. Kept here, not in `render.tsx`, so
20
+ * `components/contract-drift.test.ts`'s literal source scan of `row.tsx`
21
+ * still finds the `attributes.cols`/`attributes.text` reads.
14
22
  *
15
- * Each cell is one of the directive's own top-level children
16
- * (`registry.ts`'s `AnsiChildren.parts`: a `:::cell` directive grouping
17
- * several blocks, or any other block standing for itself, per docs/format.md's
18
- * "a row counts its direct block children as its cells"), rendered through
19
- * that part's own `render({ width })` at the ACTUAL column width decided
20
- * below, rather than rendered once at the row's full width and then
21
- * re-wrapped into a narrower column. A cell containing a self-drawing box
22
- * (a nested `card`/`table`) therefore draws that box at the real column
23
- * width in the first place, instead of having an already-drawn frame
24
- * mangled by a later re-wrap.
23
+ * An absent/invalid `cols` auto-fits: every cell becomes one column, all in
24
+ * a single grid row. A `cols` value smaller than the cell count wraps into
25
+ * further grid rows, `cols` cells at a time. Below `ROW_COLUMN_THRESHOLD`
26
+ * columns, or with only one cell, cells stack vertically instead.
27
+ */
28
+ export declare function resolveRowLayout(attributes: DirectiveAttributes, cellCount: number, width: number): RowLayout;
29
+ /**
30
+ * `row`'s registered component: a passthrough. `render.tsx`'s walk builds
31
+ * the row's REAL flex-`Box` grid (or, for a `row` reached through STRING
32
+ * mode nested inside a self-drawing container, a flattened string) directly
33
+ * and hands it down as `children`, so the registered component's own job is
34
+ * just to return what it was given. Kept as a real registration (rather
35
+ * than omitted) so alias resolution, the contract-drift coverage test, and
36
+ * an unexpected direct invocation all still see a real `row` component.
25
37
  */
26
38
  export declare const Row: AnsiComponent;
39
+ export {};
@@ -1,4 +1,3 @@
1
- import { columns } from '../box.js';
2
1
  const COLS_VALUES = ['2', '3', '4'];
3
2
  function isColsValue(value) {
4
3
  return COLS_VALUES.includes(value);
@@ -9,59 +8,42 @@ function isTextAlign(value) {
9
8
  }
10
9
  /** Below this width, cells stack vertically instead of sitting side by side — a named constant per the batch brief, not a magic number. */
11
10
  export const ROW_COLUMN_THRESHOLD = 60;
12
- /** Spaces between adjacent columns. */
13
- const GUTTER = 1;
14
11
  /**
15
- * `:::row{cols=2|3|4 text=left|center|right} ... :::` — docs/format.md's one
16
- * layout container. An absent/invalid `cols` auto-fits: every direct block
17
- * child becomes one column, all in a single row of columns. A `cols` value
18
- * smaller than the child count wraps into further grid rows, `cols` cells at
19
- * a time — the terminal counterpart of the CSS grid wrap a live host does.
20
- * Below `ROW_COLUMN_THRESHOLD` columns, or with only one cell, cells stack
21
- * vertically instead (a plain blank-line-separated list) — a fixed-width
22
- * terminal has no responsive reflow, so this is the one width breakpoint
23
- * that stands in for it.
12
+ * `:::row{cols=2|3|4 text=left|center|right} ... :::`'s pure layout
13
+ * decision: how many columns, how wide each one is, and whether the row
14
+ * stacks. `render.tsx`'s walk calls this directly (rather than `row`'s own
15
+ * registered component) so it can build each cell's real Ink subtree at the
16
+ * correct width BEFORE assembling the row's flex `Box` — see `render.tsx`'s
17
+ * top comment and `registry.ts`'s doc comment on why this one directive is
18
+ * handled at the walk level. Kept here, not in `render.tsx`, so
19
+ * `components/contract-drift.test.ts`'s literal source scan of `row.tsx`
20
+ * still finds the `attributes.cols`/`attributes.text` reads.
24
21
  *
25
- * Each cell is one of the directive's own top-level children
26
- * (`registry.ts`'s `AnsiChildren.parts`: a `:::cell` directive grouping
27
- * several blocks, or any other block standing for itself, per docs/format.md's
28
- * "a row counts its direct block children as its cells"), rendered through
29
- * that part's own `render({ width })` at the ACTUAL column width decided
30
- * below, rather than rendered once at the row's full width and then
31
- * re-wrapped into a narrower column. A cell containing a self-drawing box
32
- * (a nested `card`/`table`) therefore draws that box at the real column
33
- * width in the first place, instead of having an already-drawn frame
34
- * mangled by a later re-wrap.
22
+ * An absent/invalid `cols` auto-fits: every cell becomes one column, all in
23
+ * a single grid row. A `cols` value smaller than the cell count wraps into
24
+ * further grid rows, `cols` cells at a time. Below `ROW_COLUMN_THRESHOLD`
25
+ * columns, or with only one cell, cells stack vertically instead.
35
26
  */
36
- export const Row = (attributes, children, ctx) => {
27
+ export function resolveRowLayout(attributes, cellCount, width) {
37
28
  const rawTextAlign = attributes.text;
38
29
  const align = rawTextAlign && isTextAlign(rawTextAlign) ? rawTextAlign : 'left';
39
- const cellParts = children.parts;
40
- if (cellParts.length === 0)
41
- return '';
42
30
  const rawCols = attributes.cols ?? '';
43
- const requestedCols = isColsValue(rawCols)
44
- ? Number(rawCols)
45
- : cellParts.length;
46
- const columnCount = Math.max(1, Math.min(requestedCols, cellParts.length));
47
- const place = (text, width) => align === 'left'
48
- ? text
49
- : text
50
- .split('\n')
51
- .map((line) => ctx.pad(line, width, align))
52
- .join('\n');
53
- if (ctx.width < ROW_COLUMN_THRESHOLD || columnCount <= 1) {
54
- return cellParts
55
- .map((part) => place(part.render({ width: ctx.width }), ctx.width))
56
- .join('\n\n');
57
- }
58
- const colWidth = Math.max(1, Math.floor((ctx.width - GUTTER * (columnCount - 1)) / columnCount));
59
- const placedCells = cellParts.map((part) => place(part.render({ width: colWidth }), colWidth));
60
- const gridRows = [];
61
- for (let index = 0; index < placedCells.length; index += columnCount) {
62
- const rowCells = placedCells.slice(index, index + columnCount);
63
- const widths = rowCells.map(() => colWidth);
64
- gridRows.push(columns(rowCells, widths, GUTTER));
65
- }
66
- return gridRows.join('\n\n');
67
- };
31
+ const requestedCols = isColsValue(rawCols) ? Number(rawCols) : cellCount;
32
+ const columnCount = Math.max(1, Math.min(requestedCols, Math.max(1, cellCount)));
33
+ const gutter = 1;
34
+ const stacked = width < ROW_COLUMN_THRESHOLD || columnCount <= 1;
35
+ const colWidth = stacked
36
+ ? width
37
+ : Math.max(1, Math.floor((width - gutter * (columnCount - 1)) / columnCount));
38
+ return { align, columnCount, colWidth, stacked };
39
+ }
40
+ /**
41
+ * `row`'s registered component: a passthrough. `render.tsx`'s walk builds
42
+ * the row's REAL flex-`Box` grid (or, for a `row` reached through STRING
43
+ * mode nested inside a self-drawing container, a flattened string) directly
44
+ * and hands it down as `children`, so the registered component's own job is
45
+ * just to return what it was given. Kept as a real registration (rather
46
+ * than omitted) so alias resolution, the contract-drift coverage test, and
47
+ * an unexpected direct invocation all still see a real `row` component.
48
+ */
49
+ export const Row = ({ children }) => children;
@@ -1,13 +1,8 @@
1
1
  import type { AnsiComponent } from '../registry.js';
2
2
  /**
3
3
  * `::stat{value=42 label="stars" trend=up}` — a big value plus label. Data
4
- * binding (§8) mirrors `@markii/html`'s `Stat` exactly: a bound number/string
5
- * supplies `value`; a bound object may supply `value`/`label`/`delta`/`trend`
6
- * — an explicit attribute always wins. Missing value renders `—` rather than
7
- * a blank line. Terminal form: a muted label line, then a bold value line
8
- * (with `delta` appended, colored by `trend` when recognized). A failed or
9
- * stale binding appends `failure-presentation.ts`'s quiet suffix to the
10
- * value line — the terminal has no tooltip channel, so the reason has to
11
- * reach the text itself (AGENTS.md's "clean is not silent").
4
+ * binding (§8): a bound number/string supplies `value`; a bound object may
5
+ * supply `value`/`label`/`delta`/`trend` — an explicit attribute always
6
+ * wins. Missing value renders `—` rather than a blank line.
12
7
  */
13
8
  export declare const Stat: AnsiComponent;
@@ -35,16 +35,11 @@ function pick(attribute, fromData) {
35
35
  }
36
36
  /**
37
37
  * `::stat{value=42 label="stars" trend=up}` — a big value plus label. Data
38
- * binding (§8) mirrors `@markii/html`'s `Stat` exactly: a bound number/string
39
- * supplies `value`; a bound object may supply `value`/`label`/`delta`/`trend`
40
- * — an explicit attribute always wins. Missing value renders `—` rather than
41
- * a blank line. Terminal form: a muted label line, then a bold value line
42
- * (with `delta` appended, colored by `trend` when recognized). A failed or
43
- * stale binding appends `failure-presentation.ts`'s quiet suffix to the
44
- * value line — the terminal has no tooltip channel, so the reason has to
45
- * reach the text itself (AGENTS.md's "clean is not silent").
38
+ * binding (§8): a bound number/string supplies `value`; a bound object may
39
+ * supply `value`/`label`/`delta`/`trend` — an explicit attribute always
40
+ * wins. Missing value renders `—` rather than a blank line.
46
41
  */
47
- export const Stat = (attributes, _children, ctx) => {
42
+ export const Stat = ({ attributes, ctx }) => {
48
43
  const { data, dataStatus, dataFailureKind } = ctx;
49
44
  const bound = safeRead(() => dataStatus === 'missing' || dataStatus === 'error'
50
45
  ? {}
@@ -1,13 +1,14 @@
1
- import type { AnsiComponent } from '../registry.js';
1
+ import { type AnsiComponent } from '../registry.js';
2
2
  export declare const DEFAULT_TAB_LABEL = "Tab";
3
3
  /**
4
4
  * `:::tab{label="..."} ... :::` — one panel of a `tabs` component
5
- * (`tabs.ts`). Unlike `@markii/html`'s `Tab` (which never reads `label` at
6
- * all — see that module's `tabs.ts` for why a string-based engine usually
7
- * can't reach a child directive's own attributes from its parent), THIS
8
- * engine's `tab` reads its OWN `label` directly, since it is rendered as
9
- * its OWN directive, not inspected by `tabs` from the outside: the label
10
- * becomes a bold heading line above the panel body. Rendered standalone
11
- * (outside a `tabs` parent) it shows exactly the same heading and panel.
5
+ * (`tabs.tsx`). Rendered STANDALONE (outside a `tabs` parent, or when
6
+ * reached through `render.tsx`'s STRING-mode fallback), it reads its own
7
+ * `label` attribute directly and shows a bold heading above the panel body.
8
+ * When nested under a real `tabs` directive on the ELEMENT-mode path,
9
+ * `render.tsx`'s `extractTabPanelsElement` reads `label` itself and this
10
+ * component is never invoked for that child — `InteractiveTabs` draws the
11
+ * heading instead, since it needs to draw ALL headings together (the tab
12
+ * strip / stacked list), not one at a time.
12
13
  */
13
14
  export declare const Tab: AnsiComponent;
@@ -1,17 +1,19 @@
1
+ import { childrenText } from '../registry.js';
1
2
  export const DEFAULT_TAB_LABEL = 'Tab';
2
3
  /**
3
4
  * `:::tab{label="..."} ... :::` — one panel of a `tabs` component
4
- * (`tabs.ts`). Unlike `@markii/html`'s `Tab` (which never reads `label` at
5
- * all — see that module's `tabs.ts` for why a string-based engine usually
6
- * can't reach a child directive's own attributes from its parent), THIS
7
- * engine's `tab` reads its OWN `label` directly, since it is rendered as
8
- * its OWN directive, not inspected by `tabs` from the outside: the label
9
- * becomes a bold heading line above the panel body. Rendered standalone
10
- * (outside a `tabs` parent) it shows exactly the same heading and panel.
5
+ * (`tabs.tsx`). Rendered STANDALONE (outside a `tabs` parent, or when
6
+ * reached through `render.tsx`'s STRING-mode fallback), it reads its own
7
+ * `label` attribute directly and shows a bold heading above the panel body.
8
+ * When nested under a real `tabs` directive on the ELEMENT-mode path,
9
+ * `render.tsx`'s `extractTabPanelsElement` reads `label` itself and this
10
+ * component is never invoked for that child — `InteractiveTabs` draws the
11
+ * heading instead, since it needs to draw ALL headings together (the tab
12
+ * strip / stacked list), not one at a time.
11
13
  */
12
- export const Tab = (attributes, children, ctx) => {
14
+ export const Tab = ({ attributes, children, ctx }) => {
13
15
  const label = attributes.label ?? DEFAULT_TAB_LABEL;
14
16
  const heading = ctx.bold(ctx.text(label));
15
- const childrenText = children();
16
- return childrenText ? `${heading}\n${childrenText}` : heading;
17
+ const body = childrenText(children);
18
+ return body ? `${heading}\n${body}` : heading;
17
19
  };
@@ -1,5 +1,4 @@
1
- import { pad, wrap } from '../box.js';
2
- import { measure } from '../measure.js';
1
+ import { padText as pad, wrapText as wrap, measureWidth as measure, } from '../text-grid.js';
3
2
  /**
4
3
  * The ONE box-drawing/width-negotiation routine every table this engine
5
4
  * draws goes through: the data-bound `::table` component (`./table.ts`) and
@@ -1,16 +1,11 @@
1
1
  import type { AnsiComponent } from '../registry.js';
2
2
  /**
3
3
  * `::table{data=users columns="name,role" limit=10}` — a data-bound table.
4
- * Data binding (§8) mirrors `@markii/html`'s `Table`: `@markii/stdlib`'s
5
- * `deriveTableShape` decides the layout from the bound value's own shape.
6
- * `format`/`decimals` apply to numeric cells only; `text` aligns every
7
- * cell's content within its column; `caption`, when given, is a bold line
8
- * above the grid. A missing/stale/failed binding degrades to the same quiet
9
- * "no data" line `chart`/`progress` use, with the failure suffix appended.
4
+ * Data binding (§8): `@markii/stdlib`'s `deriveTableShape` decides the
5
+ * layout from the bound value's own shape.
10
6
  *
11
- * Registered `selfLayout` (see `card.ts`'s doc comment): draws real
12
- * box-drawing glyphs (`./table-grid.ts`), so it sizes its own grid off
13
- * `ctx.layout` instead of letting a generic post-render narrow/pad corrupt
14
- * the borders.
7
+ * Registered `selfLayout`: draws real box-drawing glyphs
8
+ * (`./table-grid.ts`), so it sizes its own grid off `ctx.layout` instead of
9
+ * letting a generic post-render narrow/pad corrupt the borders.
15
10
  */
16
11
  export declare const Table: AnsiComponent;
@@ -24,7 +24,7 @@ function parseLimit(raw) {
24
24
  function limitRows(rows, limit) {
25
25
  return limit === undefined ? rows : rows.slice(0, limit);
26
26
  }
27
- /** Renders one cell's display text: `format`/`decimals` apply only when the cell's raw value is itself numeric-like, matching `@markii/html`'s `Table` exactly. */
27
+ /** Renders one cell's display text: `format`/`decimals` apply only when the cell's raw value is itself numeric-like. */
28
28
  function renderCell(value, format, decimals) {
29
29
  if (format && isNumericLike(value))
30
30
  return formatValue(value, format, decimals);
@@ -36,19 +36,14 @@ function isTextAlign(value) {
36
36
  }
37
37
  /**
38
38
  * `::table{data=users columns="name,role" limit=10}` — a data-bound table.
39
- * Data binding (§8) mirrors `@markii/html`'s `Table`: `@markii/stdlib`'s
40
- * `deriveTableShape` decides the layout from the bound value's own shape.
41
- * `format`/`decimals` apply to numeric cells only; `text` aligns every
42
- * cell's content within its column; `caption`, when given, is a bold line
43
- * above the grid. A missing/stale/failed binding degrades to the same quiet
44
- * "no data" line `chart`/`progress` use, with the failure suffix appended.
39
+ * Data binding (§8): `@markii/stdlib`'s `deriveTableShape` decides the
40
+ * layout from the bound value's own shape.
45
41
  *
46
- * Registered `selfLayout` (see `card.ts`'s doc comment): draws real
47
- * box-drawing glyphs (`./table-grid.ts`), so it sizes its own grid off
48
- * `ctx.layout` instead of letting a generic post-render narrow/pad corrupt
49
- * the borders.
42
+ * Registered `selfLayout`: draws real box-drawing glyphs
43
+ * (`./table-grid.ts`), so it sizes its own grid off `ctx.layout` instead of
44
+ * letting a generic post-render narrow/pad corrupt the borders.
50
45
  */
51
- export const Table = (attributes, _children, ctx) => {
46
+ export const Table = ({ attributes, ctx }) => {
52
47
  const { data, dataStatus, dataFailureKind } = ctx;
53
48
  const columnsOverride = parseColumns(attributes.columns);
54
49
  const limit = parseLimit(attributes.limit);
@@ -0,0 +1,17 @@
1
+ import { type AnsiComponent } from '../registry.js';
2
+ /**
3
+ * The STRING-mode `tabs` registration — the fallback rendering used only
4
+ * when a `tabs` directive is reached through `render.tsx`'s STRING mode
5
+ * (nested inside a self-drawing container) AND, unlike `row`, not
6
+ * reachable through the panel-extraction path there either (both of
7
+ * `render.tsx`'s STRING and ELEMENT dispatchers for `tabs` build panels
8
+ * directly from the hast tree via `extractTabPanels`/`extractTabPanelsElement`
9
+ * rather than calling this component, since only the hast tree — not an
10
+ * already-flattened string — can tell each `tab` child's own `label` apart
11
+ * from its neighbor without a fragile heuristic). This registration exists
12
+ * so the registry always has a real `tabs` component (alias resolution,
13
+ * the contract-drift coverage test, and a caller invoking it directly all
14
+ * see one), using the same blank-line-boundary heuristic the pre-Ink engine
15
+ * used when it has nothing but a flattened string to work with.
16
+ */
17
+ export declare const Tabs: AnsiComponent;
@@ -0,0 +1,30 @@
1
+ import { childrenText } from '../registry.js';
2
+ /** The visible marker appended to whichever tab is treated as "active". */
3
+ const ACTIVE_MARKER = ' (active)';
4
+ /**
5
+ * The STRING-mode `tabs` registration — the fallback rendering used only
6
+ * when a `tabs` directive is reached through `render.tsx`'s STRING mode
7
+ * (nested inside a self-drawing container) AND, unlike `row`, not
8
+ * reachable through the panel-extraction path there either (both of
9
+ * `render.tsx`'s STRING and ELEMENT dispatchers for `tabs` build panels
10
+ * directly from the hast tree via `extractTabPanels`/`extractTabPanelsElement`
11
+ * rather than calling this component, since only the hast tree — not an
12
+ * already-flattened string — can tell each `tab` child's own `label` apart
13
+ * from its neighbor without a fragile heuristic). This registration exists
14
+ * so the registry always has a real `tabs` component (alias resolution,
15
+ * the contract-drift coverage test, and a caller invoking it directly all
16
+ * see one), using the same blank-line-boundary heuristic the pre-Ink engine
17
+ * used when it has nothing but a flattened string to work with.
18
+ */
19
+ export const Tabs = ({ children }) => {
20
+ const text = childrenText(children);
21
+ if (!text.trim())
22
+ return '';
23
+ const blocks = text.split('\n\n');
24
+ const [first, ...rest] = blocks;
25
+ if (first === undefined)
26
+ return text;
27
+ const lines = first.split('\n');
28
+ lines[0] = `${lines[0] ?? ''}${ACTIVE_MARKER}`;
29
+ return [lines.join('\n'), ...rest].join('\n\n');
30
+ };
@@ -1,18 +1,33 @@
1
- import type { AnsiComponent } from '../registry.js';
1
+ import type { ReactNode } from 'react';
2
+ import type { ColorLevel } from '../ansi.js';
3
+ import type { ElementTabPanel } from '../render.js';
2
4
  /**
3
5
  * `::::tabs :::tab{label="..."} ... ::: :::tab{label="..."} ... ::: ::::` —
4
- * a tabbed panel switcher. A terminal has no click-driven tab switching (see
5
- * `render.ts`'s top comment on interactive components), so every panel is
6
- * shown, stacked, in document order — matching `@markii/html`'s `Tabs`
7
- * faithfulness limitation of showing every panel rather than picking one.
6
+ * a tabbed panel switcher, built as a real Ink element (`render.tsx`'s
7
+ * `renderTabsElement` calls this directly rather than going through the
8
+ * ordinary registry-component invocation, so it can hand it real per-panel
9
+ * subtrees — see `registry.ts`'s and `render.tsx`'s doc comments).
8
10
  *
9
- * "The active tab marked": since every panel is always visible here, this
10
- * engine treats the FIRST tab (document order) as the one a live host would
11
- * show before any interaction, and marks only its heading line — every
12
- * `tab` child's block is joined into `childrenText` with a blank line
13
- * between them (`render.ts`'s `renderBlocks`), the same separator this
14
- * engine uses between any two sibling blocks, so splitting on the first
15
- * blank line reliably finds the boundary between the first tab's block and
16
- * the rest without needing any information `tabs` does not have.
11
+ * Non-interactive (the default, and the render-once path): every panel is
12
+ * shown, stacked, in document order, the first one's heading marked
13
+ * `(active)` — matching the pre-Ink engine's faithfulness limitation of
14
+ * showing every panel rather than picking one, since a static render has no
15
+ * way to ask which panel a reader wants.
16
+ *
17
+ * Interactive: only the active panel's body is shown; the header row lists
18
+ * every label, the active one bracketed; left/right or Tab switches it when
19
+ * this `tabs` instance is focused (`useIsFocused`). When this `tabs`
20
+ * instance itself is the FOCUSED component (as opposed to merely being
21
+ * interactive), the whole header strip is drawn in the accent color and
22
+ * prefixed with a `› ` glyph (`useFocusMarker`), so focus is visible even
23
+ * on a theme with no perceptible accent color; an unfocused header strip
24
+ * gets two leading spaces instead, so nothing shifts horizontally when
25
+ * focus moves onto or off of this block.
17
26
  */
18
- export declare const Tabs: AnsiComponent;
27
+ export interface InteractiveTabsProps {
28
+ panels: readonly ElementTabPanel[];
29
+ color: ColorLevel;
30
+ interactive: boolean;
31
+ focusId: number | undefined;
32
+ }
33
+ export declare function InteractiveTabs({ panels, color, interactive, focusId, }: InteractiveTabsProps): ReactNode;
@@ -1,30 +1,37 @@
1
- /** The visible marker appended to whichever tab is treated as "active" (see this module's doc comment for how that is decided). */
2
- const ACTIVE_MARKER = ' (active)';
3
- /**
4
- * `::::tabs :::tab{label="..."} ... ::: :::tab{label="..."} ... ::: ::::` —
5
- * a tabbed panel switcher. A terminal has no click-driven tab switching (see
6
- * `render.ts`'s top comment on interactive components), so every panel is
7
- * shown, stacked, in document order — matching `@markii/html`'s `Tabs`
8
- * faithfulness limitation of showing every panel rather than picking one.
9
- *
10
- * "The active tab marked": since every panel is always visible here, this
11
- * engine treats the FIRST tab (document order) as the one a live host would
12
- * show before any interaction, and marks only its heading line — every
13
- * `tab` child's block is joined into `childrenText` with a blank line
14
- * between them (`render.ts`'s `renderBlocks`), the same separator this
15
- * engine uses between any two sibling blocks, so splitting on the first
16
- * blank line reliably finds the boundary between the first tab's block and
17
- * the rest without needing any information `tabs` does not have.
18
- */
19
- export const Tabs = (_attributes, children) => {
20
- const childrenText = children();
21
- if (!childrenText.trim())
22
- return '';
23
- const blocks = childrenText.split('\n\n');
24
- const [first, ...rest] = blocks;
25
- if (first === undefined)
26
- return childrenText;
27
- const lines = first.split('\n');
28
- lines[0] = `${lines[0] ?? ''}${ACTIVE_MARKER}`;
29
- return [lines.join('\n'), ...rest].join('\n\n');
30
- };
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { bold, dim } from '../ansi.js';
5
+ import { useFocusMarker, useIsFocused } from '../interactive.js';
6
+ export function InteractiveTabs({ panels, color, interactive, focusId, }) {
7
+ const [active, setActive] = useState(0);
8
+ const focused = useIsFocused(focusId);
9
+ useInput((_input, key) => {
10
+ if (key.leftArrow) {
11
+ setActive((current) => (current - 1 + panels.length) % panels.length);
12
+ }
13
+ else if (key.rightArrow || key.tab) {
14
+ setActive((current) => (current + 1) % panels.length);
15
+ }
16
+ }, { isActive: interactive && focused });
17
+ // Every hook this component calls runs BEFORE the non-interactive early
18
+ // return below, so the hook order is identical in both modes. The header
19
+ // strip is cheap to build and unused in the non-interactive branch; that
20
+ // waste is the price of never making a hook call conditional.
21
+ const activeIndex = Math.min(active, Math.max(0, panels.length - 1));
22
+ const activePanel = panels[activeIndex];
23
+ const strip = panels
24
+ .map((panel, index) => {
25
+ const rendered = index === activeIndex
26
+ ? bold(`[${panel.label}]`, color)
27
+ : dim(panel.label, color);
28
+ return index > 0 ? ` ${rendered}` : rendered;
29
+ })
30
+ .join('');
31
+ const focusGlyph = focused ? '› ' : ' ';
32
+ const headingLine = useFocusMarker(`${focusGlyph}${strip}`, focusId);
33
+ if (!interactive) {
34
+ return (_jsx(Box, { flexDirection: "column", children: panels.map((panel, index) => (_jsxs(Box, { marginTop: index > 0 ? 1 : 0, flexDirection: "column", children: [_jsx(Text, { children: bold(`${panel.label}${index === 0 ? ' (active)' : ''}`, color) }), panel.body] }, index))) }));
35
+ }
36
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: headingLine }), activePanel?.body] }));
37
+ }
package/dist/index.d.ts CHANGED
@@ -1,14 +1,12 @@
1
- export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, type RenderMarkOptions, } from './render.js';
1
+ export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, buildMarkElement, type RenderMarkOptions, } from './render.js';
2
2
  export { fg, colorize, bold, dim, italic, underline, inverse, hyperlink, detectColorLevel, resolveColorOption, type ColorLevel, type ColorOption, type AnsiColor, } from './ansi.js';
3
3
  export { defaultAnsiTheme, type AnsiTheme, type Tier1Token } from './theme.js';
4
4
  export { style } from './style.js';
5
- export { createAnsiRegistry, mergeAnsiRegistries, registryAliases, readRegistryComponent, resolveDirectiveAlias, REGISTRY_ALIASES, type DirectiveAttributes, type AnsiRegistry, type AnsiRegistryEntry, type AnsiComponent, type AnsiChildren, type AnsiChildPart, type AnsiChildrenOptions, type AnsiRenderContext, type RegistryAlias, type RegistryAliases, type ResolvedDirective, type ValueResolution, } from './registry.js';
5
+ export { createAnsiRegistry, mergeAnsiRegistries, registryAliases, readRegistryComponent, resolveDirectiveAlias, childrenText, REGISTRY_ALIASES, type DirectiveAttributes, type AnsiRegistry, type AnsiRegistryEntry, type AnsiComponent, type AnsiComponentProps, type AnsiRenderContext, type RegistryAlias, type RegistryAliases, type ResolvedDirective, type ValueResolution, } from './registry.js';
6
6
  export { resolveStorePath, resolveScopedPath, VAULT_NAME_PREFIX, type StorePathResolution, type ValueScope, } from './resolve.js';
7
7
  export { failurePhrase, failureTitle, failureToken, dataStateSuffix, emptyInlineTitle, invalidAttributeValueLabel, invalidAttributeValueTitle, unsafeImageSrcLabel, unsafeImageSrcTitle, } from './failure-presentation.js';
8
8
  export { stringifyStoredValue } from './value-format.js';
9
- export { measure, stripAnsi } from './measure.js';
10
9
  export { stripControlCharacters, sanitizeBlockText, sanitizeUrlText, BLOCK_TAB_WIDTH, } from './sanitize.js';
11
- export { wrap, pad, indentBlock, columns, frame, rule, type FrameOptions, } from './box.js';
12
10
  export { resolveLayoutAttributes, applyLayout, LAYOUT_ATTRIBUTE_KEYS, type ResolvedLayoutAttributes, type ResolvedLayoutPresets, type WidthPreset, type AlignPreset, } from './layout.js';
13
11
  export { type ResolveImageSrc } from './image-resolve.js';
14
12
  export { type ResolveHref } from './href-resolve.js';
package/dist/index.js CHANGED
@@ -1,17 +1,16 @@
1
- // @markii/ansi: a framework-free terminal renderer for Markii documents. It
1
+ // @markii/ansi: a terminal renderer for Markii documents, built on Ink. It
2
2
  // consumes @markii/core's sanitized hast and emits a plain string (optionally
3
3
  // carrying ANSI SGR/OSC 8 escapes) for a terminal, a pipe, or any host that
4
- // wants text with no HTML and no React runtime. It is a third platform
5
- // renderer alongside @markii/html and @markii/react.
6
- export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, } from './render.js';
4
+ // wants text with no HTML runtime — plus, for a live viewer, the Ink element
5
+ // tree itself (`buildMarkElement`). It is a third platform renderer alongside
6
+ // @markii/html and @markii/react.
7
+ export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, buildMarkElement, } from './render.js';
7
8
  export { fg, colorize, bold, dim, italic, underline, inverse, hyperlink, detectColorLevel, resolveColorOption, } from './ansi.js';
8
9
  export { defaultAnsiTheme } from './theme.js';
9
10
  export { style } from './style.js';
10
- export { createAnsiRegistry, mergeAnsiRegistries, registryAliases, readRegistryComponent, resolveDirectiveAlias, REGISTRY_ALIASES, } from './registry.js';
11
+ export { createAnsiRegistry, mergeAnsiRegistries, registryAliases, readRegistryComponent, resolveDirectiveAlias, childrenText, REGISTRY_ALIASES, } from './registry.js';
11
12
  export { resolveStorePath, resolveScopedPath, VAULT_NAME_PREFIX, } from './resolve.js';
12
13
  export { failurePhrase, failureTitle, failureToken, dataStateSuffix, emptyInlineTitle, invalidAttributeValueLabel, invalidAttributeValueTitle, unsafeImageSrcLabel, unsafeImageSrcTitle, } from './failure-presentation.js';
13
14
  export { stringifyStoredValue } from './value-format.js';
14
- export { measure, stripAnsi } from './measure.js';
15
15
  export { stripControlCharacters, sanitizeBlockText, sanitizeUrlText, BLOCK_TAB_WIDTH, } from './sanitize.js';
16
- export { wrap, pad, indentBlock, columns, frame, rule, } from './box.js';
17
16
  export { resolveLayoutAttributes, applyLayout, LAYOUT_ATTRIBUTE_KEYS, } from './layout.js';
@@ -0,0 +1,12 @@
1
+ import type { ReactElement } from 'react';
2
+ /**
3
+ * Mounts `element` with Ink at `columns` wide, captures the first (and only)
4
+ * frame as a plain string, and unmounts. `ink` is imported dynamically
5
+ * because `yoga-layout` (Ink's layout engine) resolves its WASM module via a
6
+ * top-level `await` at ES-module-evaluation time (`SPIKE-10-findings.md`'s
7
+ * "Item 3"): the first call to this function pays that one-time cost, and
8
+ * every exported entry point that reaches here is therefore `async`. A
9
+ * later call in the same process reuses the already-evaluated module graph,
10
+ * the ordinary ESM module-cache guarantee.
11
+ */
12
+ export declare function renderInkToString(element: ReactElement, columns: number): Promise<string>;
@@ -0,0 +1,41 @@
1
+ import { Writable } from 'node:stream';
2
+ function makeCollectingStream(columns) {
3
+ const chunks = [];
4
+ const stream = new Writable({
5
+ write(chunk, _encoding, callback) {
6
+ chunks.push(chunk.toString('utf8'));
7
+ callback();
8
+ },
9
+ });
10
+ stream.columns = columns;
11
+ stream.rows = 40;
12
+ // Ink's static/measured layout paths expect a TTY-shaped stream.
13
+ stream.isTTY = true;
14
+ stream.getColorDepth = () => 8;
15
+ stream.hasColors = () => true;
16
+ return { stream, getOutput: () => chunks.join('') };
17
+ }
18
+ /**
19
+ * Mounts `element` with Ink at `columns` wide, captures the first (and only)
20
+ * frame as a plain string, and unmounts. `ink` is imported dynamically
21
+ * because `yoga-layout` (Ink's layout engine) resolves its WASM module via a
22
+ * top-level `await` at ES-module-evaluation time (`SPIKE-10-findings.md`'s
23
+ * "Item 3"): the first call to this function pays that one-time cost, and
24
+ * every exported entry point that reaches here is therefore `async`. A
25
+ * later call in the same process reuses the already-evaluated module graph,
26
+ * the ordinary ESM module-cache guarantee.
27
+ */
28
+ export async function renderInkToString(element, columns) {
29
+ const { render } = await import('ink');
30
+ const { stream, getOutput } = makeCollectingStream(columns);
31
+ const instance = render(element, {
32
+ stdout: stream,
33
+ stdin: process.stdin,
34
+ exitOnCtrlC: false,
35
+ patchConsole: false,
36
+ debug: false,
37
+ interactive: false,
38
+ });
39
+ instance.unmount();
40
+ return getOutput();
41
+ }