astro-terminal-themes
Version:
Astro integration to convert terminal themes (Ghostty, Warp, etc.) to Tailwind CSS themes
338 lines (337 loc) • 14.2 kB
JavaScript
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import chokidar from "chokidar";
// Ghostty theme adapter
export class GhosttyAdapter {
name = "ghostty";
extensions = [".conf", ".config"];
parse(content) {
const lines = content.split("\n");
const config = {};
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const [key, ...valueParts] = trimmed.split("=");
if (key && valueParts.length > 0) {
const value = valueParts.join("=").trim();
// Remove quotes and backticks
config[key.trim()] = value.replace(/^[`"']|[`"']$/g, "");
}
}
}
// Parse palette colors (0-15)
const paletteColors = new Array(16);
for (let i = 0; i < 16; i++) {
// Look for "palette = ${i}=#color" format
const paletteKey = Object.keys(config).find((key) => key === "palette" && config[key].startsWith(`${i}=`));
if (paletteKey) {
const colorValue = config[paletteKey].split("=")[1];
if (colorValue) {
paletteColors[i] = this.normalizeColor(colorValue);
}
}
}
// Ensure we have all 16 colors with fallbacks
const defaultPalette = [
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
];
for (let i = 0; i < 16; i++) {
if (!paletteColors[i]) {
paletteColors[i] = defaultPalette[i];
}
}
return {
background: this.normalizeColor(config.background || "#000000"),
foreground: this.normalizeColor(config.foreground || "#ffffff"),
cursor: config["cursor-color"]
? this.normalizeColor(config["cursor-color"])
: undefined,
selection: {
background: this.normalizeColor(config["selection-background"] || paletteColors[8]),
foreground: config["selection-foreground"]
? this.normalizeColor(config["selection-foreground"])
: undefined,
},
colors: {
black: paletteColors[0],
red: paletteColors[1],
green: paletteColors[2],
yellow: paletteColors[3],
blue: paletteColors[4],
magenta: paletteColors[5],
cyan: paletteColors[6],
white: paletteColors[7],
brightBlack: paletteColors[8],
brightRed: paletteColors[9],
brightGreen: paletteColors[10],
brightYellow: paletteColors[11],
brightBlue: paletteColors[12],
brightMagenta: paletteColors[13],
brightCyan: paletteColors[14],
brightWhite: paletteColors[15],
},
};
}
normalizeColor(color) {
// Remove # if present, add it back
return color.startsWith("#") ? color : `#${color}`;
}
}
// Warp theme adapter
export class WarpAdapter {
name = "warp";
extensions = [".yaml", ".yml"];
parse(content) {
// Simple YAML parser for Warp themes
const lines = content.split("\n");
const config = {};
let currentSection = config;
let currentKey = "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#"))
continue;
const indent = line.length - line.trimStart().length;
if (trimmed.includes(":")) {
const [key, ...valueParts] = trimmed.split(":");
const value = valueParts.join(":").trim();
if (value) {
// Key-value pair
currentSection[key.trim()] = value.replace(/^['"]|['"]$/g, "");
}
else {
// Section header
currentKey = key.trim();
if (indent === 0) {
config[currentKey] = {};
currentSection = config[currentKey];
}
else {
currentSection[currentKey] = {};
currentSection = currentSection[currentKey];
}
}
}
}
const terminal = config.terminal_colors || {};
const normal = terminal.normal || {};
const bright = terminal.bright || {};
return {
background: this.normalizeColor(config.background || "#000000"),
foreground: this.normalizeColor(config.foreground || "#ffffff"),
cursor: config.cursor ? this.normalizeColor(config.cursor) : undefined,
selection: {
background: this.normalizeColor(config.accent || normal.blue || "#0000ff"),
foreground: this.normalizeColor(config.foreground || "#ffffff"),
},
colors: {
black: this.normalizeColor(normal.black || "#000000"),
red: this.normalizeColor(normal.red || "#800000"),
green: this.normalizeColor(normal.green || "#008000"),
yellow: this.normalizeColor(normal.yellow || "#808000"),
blue: this.normalizeColor(normal.blue || "#000080"),
magenta: this.normalizeColor(normal.magenta || "#800080"),
cyan: this.normalizeColor(normal.cyan || "#008080"),
white: this.normalizeColor(normal.white || "#c0c0c0"),
brightBlack: this.normalizeColor(bright.black || "#808080"),
brightRed: this.normalizeColor(bright.red || "#ff0000"),
brightGreen: this.normalizeColor(bright.green || "#00ff00"),
brightYellow: this.normalizeColor(bright.yellow || "#ffff00"),
brightBlue: this.normalizeColor(bright.blue || "#0000ff"),
brightMagenta: this.normalizeColor(bright.magenta || "#ff00ff"),
brightCyan: this.normalizeColor(bright.cyan || "#00ffff"),
brightWhite: this.normalizeColor(bright.white || "#ffffff"),
},
};
}
normalizeColor(color) {
return color.startsWith("#") ? color : `#${color}`;
}
}
// Theme processor
export class ThemeProcessor {
adapters = new Map();
constructor() {
this.registerAdapter(new GhosttyAdapter());
this.registerAdapter(new WarpAdapter());
}
registerAdapter(adapter) {
this.adapters.set(adapter.name, adapter);
}
async discoverThemes(themesDir) {
const themes = [];
try {
const files = await fs.readdir(themesDir);
for (const file of files) {
const filePath = path.join(themesDir, file);
const stat = await fs.stat(filePath);
if (stat.isFile()) {
const ext = path.extname(file);
for (const adapter of this.adapters.values()) {
if (adapter.extensions.includes(ext)) {
themes.push({
name: path.basename(file, ext),
path: filePath,
adapter,
});
break;
}
}
}
}
}
catch (error) {
console.warn(`Could not read themes directory: ${themesDir}`);
}
return themes;
}
async loadTheme(themePath, adapter) {
const content = await fs.readFile(themePath, "utf-8");
return adapter.parse(content);
}
generateTailwindTheme(colors, semanticMapping = false) {
if (semanticMapping) {
return this.generateSemanticTheme(colors);
}
else {
return this.generateDirectTheme(colors);
}
}
generateSemanticTheme(colors) {
return `@theme {
--color-background: ${colors.background};
--color-foreground: ${colors.foreground};
--color-primary: ${colors.colors.blue};
--color-primary-foreground: ${colors.background};
--color-secondary: ${colors.colors.cyan};
--color-secondary-foreground: ${colors.background};
--color-accent: ${colors.colors.magenta};
--color-accent-foreground: ${colors.background};
--color-destructive: ${colors.colors.red};
--color-destructive-foreground: ${colors.colors.white};
--color-muted: ${colors.colors.brightBlack};
--color-muted-foreground: ${colors.colors.white};
--color-border: ${colors.colors.brightBlack};
--color-input: ${colors.colors.brightBlack};
--color-ring: ${colors.colors.blue};
--color-success: ${colors.colors.green};
--color-warning: ${colors.colors.yellow};
--color-info: ${colors.colors.cyan};
${colors.cursor ? `--color-cursor: ${colors.cursor};` : ""}
${colors.selection ? `--color-selection: ${colors.selection.background};` : ""}
${colors.selection?.foreground
? `--color-selection-foreground: ${colors.selection.foreground};`
: ""}
}`;
}
generateDirectTheme(colors) {
return `@theme {
--color-background: ${colors.background};
--color-foreground: ${colors.foreground};
--color-black: ${colors.colors.black};
--color-red: ${colors.colors.red};
--color-green: ${colors.colors.green};
--color-yellow: ${colors.colors.yellow};
--color-blue: ${colors.colors.blue};
--color-magenta: ${colors.colors.magenta};
--color-cyan: ${colors.colors.cyan};
--color-white: ${colors.colors.white};
--color-bright-black: ${colors.colors.brightBlack};
--color-bright-red: ${colors.colors.brightRed};
--color-bright-green: ${colors.colors.brightGreen};
--color-bright-yellow: ${colors.colors.brightYellow};
--color-bright-blue: ${colors.colors.brightBlue};
--color-bright-magenta: ${colors.colors.brightMagenta};
--color-bright-cyan: ${colors.colors.brightCyan};
--color-bright-white: ${colors.colors.brightWhite};
${colors.cursor ? `--color-cursor: ${colors.cursor};` : ""}
${colors.selection ? `--color-selection: ${colors.selection.background};` : ""}
${colors.selection?.foreground
? `--color-selection-foreground: ${colors.selection.foreground};`
: ""}
}`;
}
}
// Main Astro integration
export default function terminalThemeIntegration(options = {}) {
const { themesDir = "./themes", outputFile = "./src/styles/theme.css", defaultTheme, semanticMapping = false, } = options;
let processor;
let watcher = null;
return {
name: "terminal-theme-integration",
hooks: {
"astro:config:setup": async ({ config, logger }) => {
processor = new ThemeProcessor();
const resolvedThemesDir = path.resolve(fileURLToPath(config.root), themesDir);
const resolvedOutputFile = path.resolve(fileURLToPath(config.root), outputFile);
async function generateThemeFile() {
try {
const themes = await processor.discoverThemes(resolvedThemesDir);
if (themes.length === 0) {
logger.warn(`No themes found in ${resolvedThemesDir}`);
return;
}
// Determine which theme to use
const selectedThemeName = process.env.THEME || defaultTheme || themes[0].name;
const selectedTheme = themes.find((t) => t.name === selectedThemeName) || themes[0];
logger.info(`Using theme: ${selectedTheme.name} (${selectedTheme.adapter.name})`);
const colors = await processor.loadTheme(selectedTheme.path, selectedTheme.adapter);
const themeCSS = processor.generateTailwindTheme(colors, semanticMapping);
// Ensure output directory exists
await fs.mkdir(path.dirname(resolvedOutputFile), {
recursive: true,
});
await fs.writeFile(resolvedOutputFile, themeCSS);
logger.info(`Generated theme CSS: ${outputFile}`);
}
catch (error) {
logger.error(`Failed to generate theme: ${error}`);
}
}
// Generate initial theme
await generateThemeFile();
// Setup file watcher for hot reloading
if (process.env.NODE_ENV === "development") {
watcher = chokidar.watch(resolvedThemesDir, {
ignored: /(^|[\/\\])\../,
persistent: true,
});
watcher.on("change", async (filepath) => {
logger.info(`Theme file changed: ${filepath}`);
await generateThemeFile();
});
watcher.on("add", async (filepath) => {
logger.info(`Theme file added: ${filepath}`);
await generateThemeFile();
});
watcher.on("unlink", async (filepath) => {
logger.info(`Theme file removed: ${filepath}`);
await generateThemeFile();
});
}
},
"astro:config:done": () => {
// Clean up watcher on config done
if (watcher) {
watcher.close();
}
},
},
};
}