Skip to content

Commit 9552c35

Browse files
chore(release): publish scoped ink shim packages (#208)
* chore(release): add scoped ink shim packages * chore(lint): allow scoped shim package paths
1 parent 30647c1 commit 9552c35

File tree

9 files changed

+236
-1
lines changed

9 files changed

+236
-1
lines changed

.github/workflows/release.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,16 @@ jobs:
223223
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
224224
run: npm publish ./packages/ink-spinner-shim --access public
225225

226+
- name: Publish @rezi-ui/ink-gradient-shim
227+
env:
228+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
229+
run: npm publish ./packages/rezi-ink-gradient-shim --access public
230+
231+
- name: Publish @rezi-ui/ink-spinner-shim
232+
env:
233+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
234+
run: npm publish ./packages/rezi-ink-spinner-shim --access public
235+
226236
- name: Publish create-rezi
227237
env:
228238
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

biome.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
"include": [
1818
"packages/ink-compat/**",
1919
"packages/ink-gradient-shim/**",
20-
"packages/ink-spinner-shim/**"
20+
"packages/ink-spinner-shim/**",
21+
"packages/rezi-ink-gradient-shim/**",
22+
"packages/rezi-ink-spinner-shim/**"
2123
],
2224
"linter": {
2325
"rules": {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type React from "react";
2+
3+
export interface GradientProps {
4+
colors?: string[];
5+
children?: React.ReactNode;
6+
}
7+
8+
declare const Gradient: React.FC<GradientProps>;
9+
export default Gradient;
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* @rezi-ui/ink-gradient-shim.
3+
* Scoped alias package with the same behavior as ink-gradient-shim.
4+
*/
5+
import React from "react";
6+
7+
const NAMED_COLORS = {
8+
black: [0, 0, 0],
9+
red: [255, 0, 0],
10+
green: [0, 255, 0],
11+
yellow: [255, 255, 0],
12+
blue: [0, 0, 255],
13+
magenta: [255, 0, 255],
14+
cyan: [0, 255, 255],
15+
white: [255, 255, 255],
16+
gray: [127, 127, 127],
17+
grey: [127, 127, 127],
18+
};
19+
20+
const GRADIENT_TRACE_ENABLED = process.env.INK_GRADIENT_TRACE === "1";
21+
const SHIM_PATH =
22+
typeof __filename === "string"
23+
? __filename
24+
: typeof import.meta !== "undefined"
25+
? import.meta.url
26+
: "unknown";
27+
28+
const traceGradient = (message) => {
29+
if (!GRADIENT_TRACE_ENABLED) return;
30+
try {
31+
process.stderr.write(`[ink-gradient-shim trace] ${message}\n`);
32+
} catch {
33+
// Best-effort tracing only.
34+
}
35+
};
36+
37+
const clampByte = (value) => Math.max(0, Math.min(255, Math.round(value)));
38+
const ANSI_ESCAPE_REGEX = /\u001b\[[0-9:;]*[ -/]*[@-~]|\u009b[0-9:;]*[ -/]*[@-~]/g;
39+
40+
const parseColor = (color) => {
41+
if (!color || typeof color !== "string") return undefined;
42+
const trimmed = color.trim();
43+
if (!trimmed) return undefined;
44+
45+
const lower = trimmed.toLowerCase();
46+
const named = NAMED_COLORS[lower];
47+
if (named) return { r: named[0], g: named[1], b: named[2] };
48+
49+
if (trimmed.startsWith("#")) {
50+
const hex = trimmed.slice(1);
51+
if (/^[\da-fA-F]{6}$/.test(hex)) {
52+
return {
53+
r: Number.parseInt(hex.slice(0, 2), 16),
54+
g: Number.parseInt(hex.slice(2, 4), 16),
55+
b: Number.parseInt(hex.slice(4, 6), 16),
56+
};
57+
}
58+
if (/^[\da-fA-F]{3}$/.test(hex)) {
59+
return {
60+
r: Number.parseInt(hex[0] + hex[0], 16),
61+
g: Number.parseInt(hex[1] + hex[1], 16),
62+
b: Number.parseInt(hex[2] + hex[2], 16),
63+
};
64+
}
65+
return undefined;
66+
}
67+
68+
const rgbMatch = trimmed.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
69+
if (!rgbMatch) return undefined;
70+
71+
return {
72+
r: clampByte(Number(rgbMatch[1])),
73+
g: clampByte(Number(rgbMatch[2])),
74+
b: clampByte(Number(rgbMatch[3])),
75+
};
76+
};
77+
78+
const mixChannel = (start, end, t) => clampByte(start + (end - start) * t);
79+
80+
const interpolateStops = (stops, t) => {
81+
if (stops.length === 0) return { r: 255, g: 255, b: 255 };
82+
if (stops.length === 1) return stops[0];
83+
84+
const clamped = Math.max(0, Math.min(1, t));
85+
const scaled = clamped * (stops.length - 1);
86+
const leftIndex = Math.floor(scaled);
87+
const rightIndex = Math.min(stops.length - 1, leftIndex + 1);
88+
const localT = scaled - leftIndex;
89+
90+
const left = stops[leftIndex];
91+
const right = stops[rightIndex];
92+
return {
93+
r: mixChannel(left.r, right.r, localT),
94+
g: mixChannel(left.g, right.g, localT),
95+
b: mixChannel(left.b, right.b, localT),
96+
};
97+
};
98+
99+
const stripAnsi = (value) => value.replace(ANSI_ESCAPE_REGEX, "");
100+
101+
const extractPlainText = (value) => {
102+
if (value == null || typeof value === "boolean") return "";
103+
if (typeof value === "string" || typeof value === "number") return String(value);
104+
if (Array.isArray(value)) return value.map(extractPlainText).join("");
105+
if (React.isValidElement(value)) return extractPlainText(value.props?.children);
106+
return "";
107+
};
108+
109+
const applyGradient = (text, stops) => {
110+
if (stops.length < 2) return stripAnsi(text);
111+
112+
const lines = text.split("\n");
113+
114+
const renderedLines = lines.map((line) => {
115+
const chars = Array.from(stripAnsi(line));
116+
if (chars.length === 0) return "";
117+
const denominator = Math.max(1, chars.length - 1);
118+
const sampled = Array.from({ length: chars.length }, (_, index) =>
119+
interpolateStops(stops, index / denominator),
120+
);
121+
let out = "";
122+
for (let index = 0; index < chars.length; index += 1) {
123+
const color = sampled[index];
124+
out += `\u001b[38;2;${color.r};${color.g};${color.b}m${chars[index]}`;
125+
}
126+
return `${out}\u001b[0m`;
127+
});
128+
129+
return renderedLines.join("\n");
130+
};
131+
132+
const Gradient = ({ colors, children }) => {
133+
const traceCountRef = React.useRef(0);
134+
const parsedStops = (Array.isArray(colors) ? colors : [])
135+
.map((entry) => parseColor(entry))
136+
.filter(Boolean);
137+
const colorsLength = Array.isArray(colors) ? colors.length : 0;
138+
const plainText = extractPlainText(children);
139+
const gradientText = applyGradient(plainText, parsedStops);
140+
React.useEffect(() => {
141+
if (!GRADIENT_TRACE_ENABLED || traceCountRef.current >= 20) return;
142+
if (traceCountRef.current === 0) {
143+
traceGradient(`module=${SHIM_PATH}`);
144+
}
145+
traceCountRef.current += 1;
146+
traceGradient(
147+
`render#${traceCountRef.current} colors=${colorsLength} parsedStops=${parsedStops.length} textChars=${Array.from(plainText).length} emittedAnsi=${gradientText.includes("\u001b[38;2;")}`,
148+
);
149+
}, [colorsLength, parsedStops.length, plainText, gradientText]);
150+
return React.createElement("ink-text", null, gradientText);
151+
};
152+
153+
export default Gradient;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@rezi-ui/ink-gradient-shim",
3+
"version": "1.0.0",
4+
"description": "Scoped ink-gradient shim alias for Rezi Ink compatibility.",
5+
"type": "module",
6+
"main": "index.js",
7+
"types": "index.d.ts",
8+
"peerDependencies": {
9+
"ink": "*",
10+
"react": "^18.0.0 || ^19.0.0"
11+
}
12+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type React from "react";
2+
3+
export interface SpinnerProps {
4+
type?: string;
5+
}
6+
7+
declare const Spinner: React.FC<SpinnerProps>;
8+
export default Spinner;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* @rezi-ui/ink-spinner-shim.
3+
* Scoped alias package with the same behavior as ink-spinner-shim.
4+
*/
5+
import React, { useEffect, useState } from "react";
6+
7+
const SPINNER_FRAMES = {
8+
dots: { frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], interval: 80 },
9+
line: { frames: ["-", "\\", "|", "/"], interval: 130 },
10+
arrow: { frames: ["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"], interval: 120 },
11+
};
12+
13+
const Spinner = ({ type = "dots" }) => {
14+
const [frame, setFrame] = useState(0);
15+
const spinner = SPINNER_FRAMES[type] ?? SPINNER_FRAMES.dots;
16+
17+
useEffect(() => {
18+
const timer = setInterval(() => {
19+
setFrame((f) => (f + 1) % spinner.frames.length);
20+
}, spinner.interval);
21+
return () => clearInterval(timer);
22+
}, [spinner]);
23+
24+
return React.createElement("ink-text", { color: "green" }, spinner.frames[frame]);
25+
};
26+
27+
export default Spinner;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@rezi-ui/ink-spinner-shim",
3+
"version": "1.0.0",
4+
"description": "Scoped ink-spinner shim alias for Rezi Ink compatibility.",
5+
"type": "module",
6+
"main": "index.js",
7+
"types": "index.d.ts",
8+
"peerDependencies": {
9+
"ink": "*",
10+
"react": "^18.0.0 || ^19.0.0"
11+
}
12+
}

scripts/release-set-version.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const EXTRA_RELEASE_PACKAGE_DIRS = [
88
"packages/ink-compat",
99
"packages/ink-gradient-shim",
1010
"packages/ink-spinner-shim",
11+
"packages/rezi-ink-gradient-shim",
12+
"packages/rezi-ink-spinner-shim",
1113
];
1214

1315
function die(msg) {

0 commit comments

Comments
 (0)