#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); const SOURCES_PATH = join(__dirname, "sources.json"); const OUTPUT_PATH = join(ROOT, "lua", "github_theme", "generated", "themes.lua"); const VARIANTS = [ { slug: "github_light_default", file: "light-default.json", theme: "light", isDark: false, }, { slug: "github_light_high_contrast", file: "light-high-contrast.json", theme: "light_high_contrast", isDark: false, }, { slug: "github_light_colorblind", file: "light-colorblind.json", theme: "light_colorblind", isDark: false, }, { slug: "github_dark_default", file: "dark-default.json", theme: "dark", isDark: true, }, { slug: "github_dark_high_contrast", file: "dark-high-contrast.json", theme: "dark_high_contrast", isDark: true, }, { slug: "github_dark_colorblind", file: "dark-colorblind.json", theme: "dark_colorblind", isDark: true, }, { slug: "github_dark_dimmed", file: "dark-dimmed.json", theme: "dark_dimmed", isDark: true, }, ]; const LUA_BACKSLASH = "\\"; const LUA_ESCAPED_BACKSLASH = String.raw`\\`; const LUA_ESCAPED_SINGLE_QUOTE = String.raw`\'`; function parseArgs(argv) { const args = { check: false, keepTemp: false, }; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (value === "--check") { args.check = true; continue; } if (value === "--keep-temp") { args.keepTemp = true; continue; } if (value === "--github-vscode-theme") { index += 1; args.githubVscodeTheme = argv[index]; continue; } if (value === "--primer") { index += 1; args.primerPrimitives = argv[index]; continue; } throw new Error(`Unknown argument: ${value}`); } return args; } function loadPinnedSources() { return JSON.parse(readFileSync(SOURCES_PATH, "utf8")); } function run(command, args, options = {}) { return execFileSync(command, args, { cwd: options.cwd ?? ROOT, encoding: "utf8", stdio: options.stdio ?? "pipe", }); } function packPackage(tempRoot, spec) { const packed = run("npm", ["pack", "--silent", spec], { cwd: tempRoot }).trim(); return join(tempRoot, packed.split("\n").at(-1)); } function extractPackage(tarballPath, destination) { mkdirSync(destination, { recursive: true }); run("tar", ["-xzf", tarballPath, "-C", destination]); return join(destination, "package"); } function ensureLink(target, linkPath) { const parent = dirname(linkPath); mkdirSync(parent, { recursive: true }); if (existsSync(linkPath)) { rmSync(linkPath, { recursive: true, force: true }); } symlinkSync(target, linkPath, "dir"); } function parseHex(value) { if (typeof value !== "string" || value.length === 0) { return null; } return value.toUpperCase(); } function scopeList(token) { if (Array.isArray(token.scope)) { return token.scope; } if (typeof token.scope === "string") { return [token.scope]; } return []; } function findTokenSettings(theme, scopes) { for (const token of theme.tokenColors ?? []) { const tokenScopes = scopeList(token); if (scopes.some((scope) => tokenScopes.includes(scope))) { return token.settings ?? {}; } } return {}; } function normalizeTheme(variant, theme) { const colors = theme.colors ?? {}; const comment = findTokenSettings(theme, ["comment"]); const constant = findTokenSettings(theme, ["constant"]); const character = findTokenSettings(theme, ["constant.character"]); const string = findTokenSettings(theme, ["string"]); const regexp = findTokenSettings(theme, ["source.regexp", "string.regexp"]); const func = findTokenSettings(theme, ["entity.name.function"]); const keyword = findTokenSettings(theme, ["keyword"]); const type = findTokenSettings(theme, ["entity.name"]); const variable = findTokenSettings(theme, ["variable"]); const variableOther = findTokenSettings(theme, ["variable.other"]); const property = findTokenSettings(theme, ["meta.property-name"]); const support = findTokenSettings(theme, ["support"]); const tag = findTokenSettings(theme, ["entity.name.tag"]); const link = findTokenSettings(theme, ["constant.other.reference.link", "string.other.link"]); const markupHeading = findTokenSettings(theme, ["markup.heading"]); const markupQuote = findTokenSettings(theme, ["markup.quote"]); const markupRaw = findTokenSettings(theme, ["markup.inline.raw"]); const punctuationSpecial = findTokenSettings(theme, ["punctuation.section.embedded"]); const diffDeleted = findTokenSettings(theme, ["markup.deleted"]); const diffInserted = findTokenSettings(theme, ["markup.inserted"]); const diffChanged = findTokenSettings(theme, ["markup.changed"]); return { name: theme.name, is_dark: variant.isDark, upstream_theme: variant.theme, palette: { bg: parseHex(colors["editor.background"]), fg: parseHex(colors["editor.foreground"]), fg_muted: parseHex(colors.descriptionForeground), fg_subtle: parseHex(colors["editorLineNumber.foreground"]), bg_float: parseHex(colors["editorWidget.background"]), bg_popup: parseHex(colors["quickInput.background"] ?? colors["dropdown.background"]), bg_sidebar: parseHex(colors["sideBar.background"]), bg_statusline: parseHex(colors["statusBar.background"]), bg_visual: parseHex( colors["editor.selectionBackground"] ?? colors["editor.inactiveSelectionBackground"] ?? colors["editor.linkedEditingBackground"] ), selection_fg: parseHex(colors["editor.selectionForeground"]), bg_search: parseHex(colors["editor.findMatchBackground"]), bg_incsearch: parseHex( colors["editor.findMatchHighlightBackground"] ?? colors["editor.wordHighlightBackground"] ), bg_cursorline: parseHex(colors["editor.lineHighlightBackground"]), bg_menu: parseHex(colors["dropdown.background"]), bg_menu_sel: parseHex(colors["list.focusBackground"]), border: parseHex(colors["panel.border"] ?? colors["editorGroup.border"]), border_float: parseHex(colors["dropdown.border"]), accent: parseHex(colors["textLink.foreground"]), cursor: parseHex(colors["editorCursor.foreground"]), line_nr: parseHex(colors["editorLineNumber.foreground"]), line_nr_active: parseHex(colors["editorLineNumber.activeForeground"]), whitespace: parseHex(colors["editorWhitespace.foreground"]), match_paren_bg: parseHex(colors["editorBracketMatch.background"]), error: parseHex(colors.errorForeground ?? colors["editorError.foreground"]), warning: parseHex(colors["editorWarning.foreground"] ?? colors["notificationsWarningIcon.foreground"]), info: parseHex(colors["notificationsInfoIcon.foreground"] ?? colors["textLink.foreground"]), hint: parseHex(colors["editorInlayHint.foreground"] ?? colors.descriptionForeground), git_add: parseHex(colors["gitDecoration.addedResourceForeground"]), git_change: parseHex(colors["gitDecoration.modifiedResourceForeground"]), git_delete: parseHex(colors["gitDecoration.deletedResourceForeground"]), diff_add: parseHex(diffInserted.foreground ?? colors["gitDecoration.addedResourceForeground"]), diff_add_bg: parseHex( diffInserted.background ?? colors["diffEditor.insertedTextBackground"] ?? colors["diffEditor.insertedLineBackground"] ), diff_delete: parseHex(diffDeleted.foreground ?? colors["gitDecoration.deletedResourceForeground"]), diff_delete_bg: parseHex( diffDeleted.background ?? colors["diffEditor.removedTextBackground"] ?? colors["diffEditor.removedLineBackground"] ), diff_change: parseHex(diffChanged.foreground ?? colors["gitDecoration.modifiedResourceForeground"]), diff_change_bg: parseHex(diffChanged.background), comment: parseHex(comment.foreground), constant: parseHex(constant.foreground), character: parseHex(character.foreground ?? string.foreground), string: parseHex(string.foreground), regexp: parseHex(regexp.foreground ?? string.foreground), func: parseHex(func.foreground), keyword: parseHex(keyword.foreground), type: parseHex(type.foreground), variable: parseHex(variable.foreground), variable_other: parseHex(variableOther.foreground ?? colors["editor.foreground"]), property: parseHex(property.foreground), support: parseHex(support.foreground), tag: parseHex(tag.foreground), link: parseHex(link.foreground ?? colors["textLink.foreground"]), heading: parseHex(markupHeading.foreground ?? colors["textLink.foreground"]), quote: parseHex(markupQuote.foreground ?? comment.foreground), inline_raw: parseHex(markupRaw.foreground ?? string.foreground), punctuation_special: parseHex(punctuationSpecial.foreground ?? keyword.foreground), ansi_black: parseHex(colors["terminal.ansiBlack"]), ansi_red: parseHex(colors["terminal.ansiRed"]), ansi_green: parseHex(colors["terminal.ansiGreen"]), ansi_yellow: parseHex(colors["terminal.ansiYellow"]), ansi_blue: parseHex(colors["terminal.ansiBlue"]), ansi_magenta: parseHex(colors["terminal.ansiMagenta"]), ansi_cyan: parseHex(colors["terminal.ansiCyan"]), ansi_white: parseHex(colors["terminal.ansiWhite"]), ansi_bright_black: parseHex(colors["terminal.ansiBrightBlack"]), ansi_bright_red: parseHex(colors["terminal.ansiBrightRed"]), ansi_bright_green: parseHex(colors["terminal.ansiBrightGreen"]), ansi_bright_yellow: parseHex(colors["terminal.ansiBrightYellow"]), ansi_bright_blue: parseHex(colors["terminal.ansiBrightBlue"]), ansi_bright_magenta: parseHex(colors["terminal.ansiBrightMagenta"]), ansi_bright_cyan: parseHex(colors["terminal.ansiBrightCyan"]), ansi_bright_white: parseHex(colors["terminal.ansiBrightWhite"]), }, }; } function generateModernThemes(themeDir, tempRoot) { const generatorPath = join(tempRoot, "generate-modern-themes.cjs"); const themesDir = join(themeDir, "themes"); const runner = ` const fs = require("fs"); const path = require("path"); const getTheme = require(${JSON.stringify(join(themeDir, "src", "theme.js"))}); const variants = ${JSON.stringify( VARIANTS.map((variant) => ({ file: variant.file, theme: variant.theme, })) )}; const names = { light: "GitHub Light Default", light_high_contrast: "GitHub Light High Contrast", light_colorblind: "GitHub Light Colorblind", dark: "GitHub Dark Default", dark_high_contrast: "GitHub Dark High Contrast", dark_colorblind: "GitHub Dark Colorblind", dark_dimmed: "GitHub Dark Dimmed", }; fs.mkdirSync(${JSON.stringify(themesDir)}, { recursive: true }); for (const variant of variants) { const theme = getTheme({ theme: variant.theme, name: names[variant.theme], }); fs.writeFileSync( path.join(${JSON.stringify(themesDir)}, variant.file), JSON.stringify(theme, null, 2) ); } `; writeFileSync(generatorPath, runner, "utf8"); run("node", [generatorPath], { cwd: themeDir }); } function luaString(value) { return `'${String(value) .replaceAll(LUA_BACKSLASH, LUA_ESCAPED_BACKSLASH) .replaceAll("'", LUA_ESCAPED_SINGLE_QUOTE)}'`; } function toLua(value, indent = 0) { const pad = " ".repeat(indent); const nextPad = " ".repeat(indent + 1); if (value === null || value === undefined) { return "nil"; } if (typeof value === "string") { return luaString(value); } if (typeof value === "boolean" || typeof value === "number") { return String(value); } if (Array.isArray(value)) { if (value.length === 0) { return "{}"; } const items = value.map((entry) => `${nextPad}${toLua(entry, indent + 1)}`); return `{\n${items.join(",\n")}\n${pad}}`; } const entries = Object.entries(value); if (entries.length === 0) { return "{}"; } const rendered = entries.map(([key, entry]) => `${nextPad}${key} = ${toLua(entry, indent + 1)}`); return `{\n${rendered.join(",\n")}\n${pad}}`; } function renderLua(meta, variants) { const payload = { meta, variants, }; return `return ${toLua(payload)}\n`; } function main() { const pinned = loadPinnedSources(); const args = parseArgs(process.argv.slice(2)); const githubVscodeTheme = args.githubVscodeTheme ?? pinned.githubVscodeTheme; const primerPrimitives = args.primerPrimitives ?? pinned.primerPrimitives; const tempRoot = mkdtempSync(join(tmpdir(), "github-theme-sync-")); try { const themeTarball = packPackage(tempRoot, `github-vscode-theme@${githubVscodeTheme}`); const primerTarball = packPackage(tempRoot, `@primer/primitives@${primerPrimitives}`); const themeDir = extractPackage(themeTarball, join(tempRoot, "theme")); const primerDir = extractPackage(primerTarball, join(tempRoot, "primer")); const themePackage = JSON.parse(readFileSync(join(themeDir, "package.json"), "utf8")); const chromaSpec = pinned.chromaJs; if (!chromaSpec) { throw new Error("Missing chromaJs pin in scripts/sources.json"); } if (!themePackage.devDependencies["chroma-js"]) { throw new Error("github-vscode-theme no longer declares chroma-js in devDependencies"); } const chromaTarball = packPackage(tempRoot, `chroma-js@${chromaSpec}`); const chromaDir = extractPackage(chromaTarball, join(tempRoot, "chroma")); ensureLink(primerDir, join(themeDir, "node_modules", "@primer", "primitives")); ensureLink(chromaDir, join(themeDir, "node_modules", "chroma-js")); generateModernThemes(themeDir, tempRoot); const normalizedVariants = {}; for (const variant of VARIANTS) { const theme = JSON.parse(readFileSync(join(themeDir, "themes", variant.file), "utf8")); normalizedVariants[variant.slug] = normalizeTheme(variant, theme); } const rendered = renderLua( { github_vscode_theme: githubVscodeTheme, primer_primitives: primerPrimitives, }, normalizedVariants ); if (args.check) { const current = readFileSync(OUTPUT_PATH, "utf8"); if (current !== rendered) { throw new Error("Generated theme data is out of date. Run: node scripts/sync_intent.mjs"); } process.stdout.write("Generated theme data is in sync.\n"); return; } writeFileSync(OUTPUT_PATH, rendered, "utf8"); process.stdout.write(`Updated ${OUTPUT_PATH}\n`); } finally { if (args.keepTemp) { process.stdout.write(`Kept temp workspace: ${tempRoot}\n`); } else { rmSync(tempRoot, { recursive: true, force: true }); } } } main();