Charsm is a port of the gorgeous Lipgloss library from Charm CLI, part of their impressive suite of CLI tools. Definitely check out Charm’s collection of tools; they’re fantastic.
I’m a huge fan of CLI tools and have been building a lot of them lately. Naturally, I want my CLIs to look amazing, which is exactly what Charm CLI tools achieve. Not wanting to Go without that same polish in JavaScript, I created Charsm! For details on how I ported Lipgloss using WebAssembly, see the porting lipgloss with wasm section below.
If you’re looking to build beautiful TUIs, this library is for you!
- Charsm
Install from npm with your favorite package manager:
pnpm add charsmadded huh forms
Tested on Node.js v20.15.0 and v18.20.4 both linux and windows
original charmcli huh repo
import {initLip, Lipgloss} from "charsm"
(async function() {
const isInit = await initLip(); // returns false if WASM fails to load, otherwise true
if (!isInit) return; // handle failure case
})();Once WASM is loaded, you can create a Lipgloss instance:
(async function() {
const lip = new Lipgloss();
})();At its core, Charsm lets you define styles similar to CSS, which can then be applied to text.
(async function() {
// Define a style
lip.createStyle({
id: "primary",
canvasColor: { color: "#7D56F4" },
border: { type: "rounded", background: "#0056b3", sides: [true] },
padding: [6, 8, 6, 8],
margin: [0, 0, 8, 0],
bold: true,
width: 10,
height: 12,
});
// Apply the style
const result = lip.apply({ value: "🔥🦾🍕" });
console.log(result); // Output styled result
// Apply a specific style by ID
const custom = lip.apply({ value: "🔥🦾🍕", id: "primary" });
console.log(custom);
})();Here’s an overview of the options available for creating styles:
type LipglossPos = "bottom" | "top" | "left" | "right" | "center";
type BorderType = "rounded" | "block" | "thick" | "double";
interface Style {
id: string;
canvasColor?: { color?: string, background?: string };
border?: { type: BorderType, foreground?: string, background?: string, sides: Array<boolean> };
padding?: Array<number>;
margin: Array<number>;
bold?: boolean;
alignV?: LipglossPos;
alignH?: LipglossPos; // buggy don't work
width?: number;
height?: number;
maxWidth?: number;
maxHeight?: number;
}alignV works!
Note: For horizontal alignment(alignH), use padding and margins.
- One value applies to all sides:
[1] - Two values apply to vertical and horizontal sides:
[1, 2] - Four values apply to top, right, bottom, and left:
[1, 2, 3, 4]
lip.createStyle({
id: "primary",
canvasColor: { color: "#7D56F4" },
border: { type: "rounded", background: "#0056b3", sides: [true] },
padding: [6, 8, 6, 8],
margin: [0, 2, 8, 2],
bold: true,
align: 'center',
width: 10,
height: 12,
});;
lip.createStyle({
id: "secondary",
canvasColor: {color: "#7D56F4" },
border: { type: "rounded", background: "#0056b3", sides: [true, false] },
padding: [6, 8, 6, 8],
margin: [0, 0, 8, 1],
bold: true,
// alignH: "right",
alignV: "bottom",
width: 10,
height: 12,
});
const a = lip.apply({ value: "Charsmmm", id: "secondary" });
const b = lip.apply({ value: "🔥🦾🍕", id: "primary" });
const c = lip.apply({ value: 'Charsmmm', id: "secondary" });- completeAdaptiveColor
lip.createStyle({
id: "primary",
canvasColor: {color: "#7D56F4", background:{completeAdaptiveColor: { Light:{TrueColor: "#d7ffae", ANSI256: "193", ANSI: "11"}, Dark: {TrueColor: "#d75fee", ANSI256: "163", ANSI: "5"}}}},
});- Adaptive Color
const highlight = { Light: "#874BFD", Dark: "#7D56F4" }
canvasColor: { color:{ adaptiveColor: highlight } , background: "#FAFAFA" },- completColor
canvasColor: {color: {completeColor: {TrueColor: "#d7ffae", ANSI256: "193", ANSI: "11"}}}Charsm currently supports horizontal and vertical layouts.
const res = lip.join({ direction: "horizontal", elements: [a, b, c], position: "left" });
console.log(res);For details on
lipgloss.JoinVerticalandlipgloss.JoinHorizontal, refer to Charm’s lipgloss repo.
Charsm can create tables easily. Here’s an example:
const rows = [
["Chinese", "您好", "你好"],
["Japanese", "こんにちは", "やあ"],
["Arabic", "أهلين", "أهلا"],
["Russian", "Здравствуйте", "Привет"],
["Spanish", "Hola", "¿Qué tal?"]
];
const tableData = { headers: ["LANGUAGE", "FORMAL", "INFORMAL"], rows: rows };
const t = lip.newTable({
data: tableData,
table: { border: "rounded", color: "99", width: 100 },
header: { color: "212", bold: true },
rows: { even: { color: "246" } }
});
console.log(t);use's glamour from charm CLI underneath
const content = `
# Today’s Menu
## Appetizers
| Name | Price | Notes |
| --- | --- | --- |
| Tsukemono | $2 | Just an appetizer |
| Tomato Soup | $4 | Made with San Marzano tomatoes |
| Okonomiyaki | $4 | Takes a few minutes to make |
| Curry | $3 | We can add squash if you’d like |
## Seasonal Dishes
| Name | Price | Notes |
| --- | --- | --- |
| Steamed bitter melon | $2 | Not so bitter |
| Takoyaki | $3 | Fun to eat |
| Winter squash | $3 | Today it's pumpkin |
## Desserts
| Name | Price | Notes |
| --- | --- | --- |
| Dorayaki | $4 | Looks good on rabbits |
| Banana Split | $5 | A classic |
| Cream Puff | $3 | Pretty creamy! |
All our dishes are made in-house by Karen, our chef. Most of our ingredients
are from our garden or the fish market down the street.
Some famous people that have eaten here lately:
* [x] René Redzepi
* [x] David Chang
* [ ] Jiro Ono (maybe some day)
Bon appétit!
`
// technically not part of lip(lipgloss)
console.log(lip.RenderMD(content, "tokyo-night"))
The implementation here is a straightforward 1-to-1 port! In other words, for example createStyle is built up from a bunch of lipgloss functions with conditional checks. It’s verbose, kind of repetitive, and maybe even a bit annoying.
The reason for this verbosity is to avoid using reflect for dynamic calls to lipgloss functions, reflect in Go is a form of metaprogramming that's super expensive.
Here's an example of Join:
func (l *lipWrapper) Join(this js.Value, args []js.Value) interface{} {
direction := args[0].Get("direction").String()
var elements []string
e := args[0].Get("elements")
for i := 0; i < e.Length(); i++ {
elements = append(elements, e.Index(i).String())
}
if CheckTruthy(args, "pc") {
if direction == "vertical" {
return lipgloss.JoinVertical(lipgloss.Position(args[0].Get("pc").Int()), elements...)
} else {
return lipgloss.JoinHorizontal(lipgloss.Position(args[0].Get("pc").Int()), elements...)
}
}
if CheckTruthy(args, "position") {
pos := args[0].Get("position").String()
var apos lipgloss.Position
if pos == "bottom" {
apos = lipgloss.Bottom
} else if pos == "top" {
apos = lipgloss.Top
} else if pos == "right" {
apos = lipgloss.Right
} else {
apos = lipgloss.Left
}
if direction == "vertical" {
return lipgloss.JoinVertical(apos, elements...)
} else {
return lipgloss.JoinHorizontal(apos, elements...)
}
}
return ""
}That's why some features like adaptive colors aren’t implemented just yet—those will come later!
Next up, I’m planning to port Bubble Tea for interactive components!
This project came up while I was building a CLI tool in JavaScript to monitor websites. I wanted it to look nice, and since I’ve been using lipgloss a lot in Go, I figured I'd port it.
Meaning, yes, the Go code is all over the place! Here’s a look at main for context:
func main() {
lip := &lipWrapper{}
lip.styles = make(map[string]string)
lip.styles2o = make(map[string]lipgloss.Style)
// Export the `add` function to JavaScript
// js.Global().Set("add", js.FuncOf(add))
// js.Global().Set("greet", js.FuncOf(greet))
// js.Global().Set("multiply", js.FuncOf(multiply))
// js.Global().Set("processUser", js.FuncOf(processUser))
// js.Global().Set("asyncAdd", js.FuncOf(asyncAdd))
// js.Global().Set("lipprint", js.FuncOf(printWithGloss))
// js.Global().Set("lipgloss", js.Func(lipgloss.NewStyle))
js.Global().Set("createStyle", js.FuncOf(lip.createStyle))
js.Global().Set("apply", js.FuncOf(lip.apply))
// js.Global().Set("canvasColor", js.FuncOf(lip.canvasColor))
// js.Global().Set("padding", js.FuncOf(lip.canvasColor))
// js.Global().Set("render", js.FuncOf(lip.render))
// js.Global().Set("margin", js.FuncOf(lip.margin))
// js.Global().Set("place", js.FuncOf(lip.place))
// js.Global().Set("size", js.FuncOf(lip.size))
// js.Global().Set("JoinHorizontal", js.FuncOf(lip.JoinHorizontal))
// js.Global().Set("JoinVertical", js.FuncOf(lip.JoinVertical))
// js.Global().Set("border", js.FuncOf(lip.border))
// js.Global().Set("width", js.FuncOf(lip.width))
// js.Global().Set("height", js.FuncOf(lip.height))
js.Global().Set("newTable", js.FuncOf(lip.newTable))
// js.Global().Set("tableStyle", js.FuncOf(lip.tableStyle))
js.Global().Set("join", js.FuncOf(lip.Join))
// Example user input
// input := "lipgloss.NewStyle().Foreground(lipgloss.Color(fg)).Background(lipgloss.Color(bg))"
// // Assuming user provides these values
// fg := "#FF0000" // red
// bg := "#00FF00" // green
// // style := buildStyleFromInput(input, fg, bg)
// // Print styled text to see the result
// styledText := style.Render("Hello, Styled World!")
// fmt.Println(styledText)
// // Keep the program running (WebAssembly runs until manually stopped)
select {} // loop
}yeah really bad and that's just main.
I’ve got files everywhere, so I’ll need to clean it up once I find the time then I'll post the Golang code.
To turn your Node application into an executable, make sure your build tool copies and bundles the WASM file in charsm’s dist folder.
Since it’s read with fs (not imported), your bundler needs to know about this file:
const wasmPath = path.resolve(dir, './lip.wasm');
const wasmfile = fs.readFileSync(wasmPath);Disclaimer pkg to create an exe.
This guide covers how to bundle a Node.js application that uses the charsm library and its lip.wasm file into a standalone executable. We'll review setup for common tools like pkg, nexe, and electron-builder.
To bundle, you’ll need a dynamic reference to lip.wasm since its path will change in the executable.
- Development Path: Typically,
node_modules/charsm/dist/lip.wasm. - Bundled Path: Dynamically reference the WASM file at runtime.
To include lip.wasm:
-
Update
package.json:{ "pkg": { "assets": [ "node_modules/charsm/dist/lip.wasm" ] } } -
Bundle the Application:
pkg . --assets node_modules/charsm/dist/lip.wasm
-
Run
nexewith Resource Flag:nexe -i index.js -o myApp.exe --resource node_modules/charsm/dist/lip.wasm
-
Update Code for
process.cwd():const wasmPath = path.join(process.cwd(), 'node_modules/charsm/dist/lip.wasm');
-
Modify
electron-builderConfiguration:{ "files": [ "dist/**/*", "node_modules/charsm/dist/lip.wasm" ] } -
Reference with
__dirname:const wasmPath = path.join(__dirname, 'node_modules/charsm/dist/lip.wasm');
Each bundling tool has a different configuration to include the lip.wasm file. Following these steps will ensure charsm’s WASM file is properly included in your executable.