refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,364 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChildProcess, spawn} from 'node:child_process';
import type {Dirent} from 'node:fs';
import {mkdir, readdir, readFile, rm, stat, writeFile} from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDir, '..');
const metadataFile = path.join(projectRoot, '.devserver-cache.json');
const binDir = path.join(projectRoot, 'node_modules', '.bin');
const rspackBin = path.join(binDir, 'rspack');
const tcmBin = path.join(binDir, 'tcm');
const DEFAULT_SKIP_DIRS = new Set(['.git', 'node_modules', '.turbo', 'dist', 'target', 'pkg', 'pkgs']);
let metadataCache: Metadata | null = null;
interface StepMetadata {
lastRun: number;
inputs: Record<string, number>;
}
interface Metadata {
[key: string]: StepMetadata;
}
type StepKey = 'wasm' | 'colors' | 'masks' | 'cssTypes' | 'lingui';
async function loadMetadata(): Promise<void> {
if (metadataCache !== null) {
return;
}
try {
const raw = await readFile(metadataFile, 'utf8');
const parsed = JSON.parse(raw);
metadataCache = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Metadata;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
metadataCache = {};
return;
}
console.warn('Failed to read dev server metadata cache, falling back to full rebuild:', error);
metadataCache = {};
}
}
async function saveMetadata(): Promise<void> {
if (!metadataCache) {
return;
}
await mkdir(path.dirname(metadataFile), {recursive: true});
await writeFile(metadataFile, JSON.stringify(metadataCache, null, 2), 'utf8');
}
function haveInputsChanged(prev: Record<string, number>, next: Record<string, number>): boolean {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) {
return true;
}
for (const key of nextKeys) {
if (!Object.hasOwn(prev, key) || prev[key] !== next[key]) {
return true;
}
}
return false;
}
function shouldRunStep(stepName: StepKey, inputs: Record<string, number>): boolean {
if (!metadataCache) {
return true;
}
const entry = metadataCache[stepName];
if (!entry) {
return true;
}
return haveInputsChanged(entry.inputs, inputs);
}
async function collectFileStats(paths: ReadonlyArray<string>): Promise<Record<string, number>> {
const result: Record<string, number> = {};
for (const relPath of paths) {
const absolutePath = path.join(projectRoot, relPath);
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) {
throw new Error(`Expected ${relPath} to be a file when collecting dev server cache inputs.`);
}
result[relPath] = fileStat.mtimeMs;
}
return result;
}
async function collectDirectoryStats(
rootRel: string,
predicate: (relPath: string) => boolean,
): Promise<Record<string, number>> {
const accumulator: Record<string, number> = {};
async function walk(relPath: string): Promise<void> {
const absoluteDir = path.join(projectRoot, relPath);
let entries: Array<Dirent>;
try {
entries = await readdir(absoluteDir, {withFileTypes: true});
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return;
}
throw error;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (DEFAULT_SKIP_DIRS.has(entry.name)) {
continue;
}
await walk(path.join(relPath, entry.name));
continue;
}
if (!entry.isFile()) {
continue;
}
const fileRel = path.join(relPath, entry.name);
if (!predicate(fileRel)) {
continue;
}
const fileStat = await stat(path.join(projectRoot, fileRel));
accumulator[fileRel] = fileStat.mtimeMs;
}
}
await walk(rootRel);
return accumulator;
}
async function runCachedStep(
stepName: StepKey,
gatherInputs: () => Promise<Record<string, number>>,
command: string,
args: ReadonlyArray<string>,
): Promise<void> {
const inputs = await gatherInputs();
if (!shouldRunStep(stepName, inputs)) {
console.log(`Skipping ${command} ${args.join(' ')} (no changes detected)`);
return;
}
await runCommand(command, args);
metadataCache ??= {};
metadataCache[stepName] = {lastRun: Date.now(), inputs};
await saveMetadata();
}
async function gatherWasmInputs(): Promise<Record<string, number>> {
return collectDirectoryStats(path.join('crates', 'libfluxcore'), () => true);
}
async function gatherColorInputs(): Promise<Record<string, number>> {
return collectFileStats(['scripts/GenerateColorSystem.tsx']);
}
async function gatherMaskInputs(): Promise<Record<string, number>> {
return collectFileStats(['scripts/GenerateAvatarMasks.tsx', 'src/components/uikit/TypingConstants.tsx']);
}
async function gatherCssModuleInputs(): Promise<Record<string, number>> {
return collectDirectoryStats('src', (relPath) => relPath.endsWith('.module.css'));
}
async function gatherLinguiInputs(): Promise<Record<string, number>> {
return collectDirectoryStats(path.join('src', 'locales'), (relPath) => relPath.endsWith('.po'));
}
let currentChild: ChildProcess | null = null;
let cssTypeWatcher: ChildProcess | null = null;
let shuttingDown = false;
const shutdownSignals: ReadonlyArray<NodeJS.Signals> = ['SIGINT', 'SIGTERM'];
function handleShutdown(signal: NodeJS.Signals): void {
if (shuttingDown) {
return;
}
shuttingDown = true;
console.log(`\nReceived ${signal}, shutting down fluxer app dev server...`);
currentChild?.kill('SIGTERM');
cssTypeWatcher?.kill('SIGTERM');
}
shutdownSignals.forEach((signal) => {
process.on(signal, () => handleShutdown(signal));
});
function runCommand(command: string, args: ReadonlyArray<string>): Promise<void> {
return new Promise((resolve, reject) => {
if (shuttingDown) {
resolve();
return;
}
const child = spawn(command, args, {
cwd: projectRoot,
stdio: 'inherit',
});
currentChild = child;
child.once('error', (error) => {
currentChild = null;
reject(error);
});
child.once('exit', (code, signal) => {
currentChild = null;
if (shuttingDown) {
resolve();
return;
}
if (signal) {
reject(new Error(`${command} ${args.join(' ')} terminated by signal ${signal}`));
return;
}
if (code && code !== 0) {
reject(new Error(`${command} ${args.join(' ')} exited with status ${code}`));
return;
}
resolve();
});
});
}
async function cleanDist(): Promise<void> {
if (shuttingDown) {
return;
}
const distPath = path.join(projectRoot, 'dist');
await rm(distPath, {recursive: true, force: true});
}
function startCssTypeWatcher(): void {
if (shuttingDown) {
return;
}
const child = spawn(tcmBin, ['src', '--pattern', '**/*.module.css', '--watch', '--silent'], {
cwd: projectRoot,
stdio: 'inherit',
});
cssTypeWatcher = child;
child.once('error', (error) => {
if (!shuttingDown) {
console.error('CSS type watcher error:', error);
}
cssTypeWatcher = null;
});
child.once('exit', (code, signal) => {
cssTypeWatcher = null;
if (!shuttingDown && code !== 0) {
console.error(`CSS type watcher exited unexpectedly (code: ${code}, signal: ${signal})`);
}
});
}
function runRspack(): Promise<number> {
return new Promise((resolve, reject) => {
if (shuttingDown) {
resolve(0);
return;
}
const child = spawn(rspackBin, ['serve', '--mode', 'development'], {
cwd: projectRoot,
stdio: 'inherit',
});
currentChild = child;
child.once('error', (error) => {
currentChild = null;
reject(error);
});
child.once('exit', (code, signal) => {
currentChild = null;
if (shuttingDown) {
resolve(0);
return;
}
if (signal) {
reject(new Error(`rspack serve terminated by signal ${signal}`));
return;
}
resolve(code ?? 0);
});
});
}
async function main(): Promise<void> {
await loadMetadata();
try {
await runCachedStep('wasm', gatherWasmInputs, 'pnpm', ['wasm:codegen']);
await runCachedStep('colors', gatherColorInputs, 'pnpm', ['generate:colors']);
await runCachedStep('masks', gatherMaskInputs, 'pnpm', ['generate:masks']);
await runCachedStep('cssTypes', gatherCssModuleInputs, 'pnpm', ['generate:css-types']);
await runCachedStep('lingui', gatherLinguiInputs, 'pnpm', ['lingui:compile']);
await cleanDist();
startCssTypeWatcher();
const rspackExitCode = await runRspack();
if (!shuttingDown && rspackExitCode !== 0) {
process.exit(rspackExitCode);
}
} catch (error) {
if (shuttingDown) {
process.exit(0);
}
console.error(error);
process.exit(1);
}
}
void main();

View File

@@ -20,11 +20,12 @@
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO, TYPING_WIDTH_MULTIPLIER} from '@app/components/uikit/TypingConstants';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
type AvatarSize = 16 | 20 | 24 | 32 | 36 | 40 | 48 | 56 | 80 | 120;
type AvatarSize = 16 | 20 | 24 | 32 | 36 | 40 | 44 | 48 | 56 | 80 | 120;
interface StatusConfig {
statusSize: number;
@@ -39,6 +40,7 @@ const STATUS_CONFIG: Record<number, StatusConfig> = {
32: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 27},
36: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 30},
40: {statusSize: 12, cutoutRadius: 9, cutoutCenter: 34},
44: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 38},
48: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 42},
56: {statusSize: 16, cutoutRadius: 11, cutoutCenter: 49},
80: {statusSize: 16, cutoutRadius: 14, cutoutCenter: 68},
@@ -54,7 +56,6 @@ const DESIGN_RULES = {
mobileWheelRadius: 0.13,
mobileWheelY: 0.83,
typingWidthMultiplier: 1.8,
mobilePhoneExtraHeight: 2,
mobileDisplayExtraHeight: 2,
mobileDisplayExtraWidthPerSide: 2,
@@ -173,11 +174,14 @@ function generateAvatarMaskStatusTyping(size: number): string {
const r = size / 2;
const status = calculateStatusGeometry(size);
const typingWidth = Math.round(status.size * DESIGN_RULES.typingWidthMultiplier);
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
const typingHeight = status.size;
const typingRx = status.outerRadius;
const x = status.cx - typingWidth / 2;
const typingExtension = Math.max(0, typingWidth - status.size);
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
const x = status.cx - typingWidth / 2 + typingBridgeShift;
const y = status.cy - typingHeight / 2;
return `(
@@ -285,16 +289,18 @@ function generateStatusOffline(size: number): string {
function generateStatusTyping(size: number): string {
const status = calculateStatusGeometry(size);
const typingWidth = Math.round(status.size * DESIGN_RULES.typingWidthMultiplier);
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
const typingHeight = status.size;
const rx = status.outerRadius;
const x = status.cx - typingWidth / 2;
const typingExtension = Math.max(0, typingWidth - status.size);
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
const x = status.cx - typingWidth / 2 + typingBridgeShift;
const y = status.cy - typingHeight / 2;
return `<rect fill="white" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${rx}" ry="${rx}" />`;
}
const SIZES: Array<AvatarSize> = [16, 20, 24, 32, 36, 40, 48, 56, 80, 120];
const SIZES: Array<AvatarSize> = [16, 20, 24, 32, 36, 40, 44, 48, 56, 80, 120];
let output = `// @generated - DO NOT EDIT MANUALLY
// Run: pnpm generate:masks
@@ -413,9 +419,12 @@ for (const size of SIZES) {
const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size;
const typingWidth = Math.round(status.size * DESIGN_RULES.typingWidthMultiplier) / size;
const typingWidthPx = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
const typingExtensionPx = Math.max(0, typingWidthPx - status.size);
const typingBridgeShift = (typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
const typingWidth = typingWidthPx / size;
const typingHeight = status.size / size;
const typingX = cx - typingWidth / 2;
const typingX = cx - typingWidth / 2 + typingBridgeShift;
const typingY = cy - typingHeight / 2;
const typingRx = status.outerRadius / size;

View File

@@ -0,0 +1,680 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {mkdirSync, writeFileSync} from 'node:fs';
import {dirname, join, relative} from 'node:path';
interface ColorFamily {
hue: number;
saturation: number;
useSaturationFactor: boolean;
}
interface ScaleStop {
name: string;
position?: number;
}
interface Scale {
family: string;
range: [number, number];
curve: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
stops: Array<ScaleStop>;
}
interface TokenDef {
name?: string;
scale?: string;
value?: string;
family?: string;
hue?: number;
saturation?: number;
lightness?: number;
alpha?: number;
useSaturationFactor?: boolean;
}
interface Config {
families: Record<string, ColorFamily>;
scales: Record<string, Scale>;
tokens: {
root: Array<TokenDef>;
light: Array<TokenDef>;
coal: Array<TokenDef>;
};
}
const CONFIG: Config = {
families: {
neutralDark: {hue: 220, saturation: 13, useSaturationFactor: true},
neutralLight: {hue: 220, saturation: 10, useSaturationFactor: true},
brand: {hue: 242, saturation: 70, useSaturationFactor: true},
link: {hue: 210, saturation: 100, useSaturationFactor: true},
accentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
statusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
statusIdle: {hue: 45, saturation: 93, useSaturationFactor: true},
statusDnd: {hue: 0, saturation: 84, useSaturationFactor: true},
statusOffline: {hue: 218, saturation: 11, useSaturationFactor: true},
statusDanger: {hue: 1, saturation: 77, useSaturationFactor: true},
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
},
scales: {
darkSurface: {
family: 'neutralDark',
range: [5, 26],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-lighter', position: 0.22},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.68},
{name: '--background-header-primary-hover', position: 0.85},
],
},
coalSurface: {
family: 'neutralDark',
range: [1, 12],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
{name: '--background-secondary', position: 0.16},
{name: '--background-secondary-alt', position: 0.28},
{name: '--background-tertiary', position: 0.4},
{name: '--background-channel-header', position: 0.34},
{name: '--guild-list-foreground', position: 0.38},
{name: '--background-header-secondary', position: 0.5},
{name: '--background-header-primary', position: 0.5},
{name: '--background-textarea', position: 0.68},
{name: '--background-header-primary-hover', position: 0.85},
],
},
darkText: {
family: 'neutralDark',
range: [52, 96],
curve: 'easeInOut',
stops: [
{name: '--text-tertiary-secondary', position: 0},
{name: '--text-tertiary-muted', position: 0.2},
{name: '--text-tertiary', position: 0.38},
{name: '--text-primary-muted', position: 0.55},
{name: '--text-chat-muted', position: 0.55},
{name: '--text-secondary', position: 0.72},
{name: '--text-chat', position: 0.82},
{name: '--text-primary', position: 1},
],
},
lightSurface: {
family: 'neutralLight',
range: [86, 98.5],
curve: 'easeIn',
stops: [
{name: '--background-header-primary-hover', position: 0},
{name: '--background-header-primary', position: 0.12},
{name: '--background-header-secondary', position: 0.2},
{name: '--guild-list-foreground', position: 0.35},
{name: '--background-tertiary', position: 0.42},
{name: '--background-channel-header', position: 0.5},
{name: '--background-secondary-alt', position: 0.63},
{name: '--background-secondary', position: 0.74},
{name: '--background-secondary-lighter', position: 0.83},
{name: '--background-textarea', position: 0.88},
{name: '--background-primary', position: 1},
],
},
lightText: {
family: 'neutralLight',
range: [15, 60],
curve: 'easeOut',
stops: [
{name: '--text-primary', position: 0},
{name: '--text-chat', position: 0.08},
{name: '--text-secondary', position: 0.28},
{name: '--text-chat-muted', position: 0.45},
{name: '--text-primary-muted', position: 0.45},
{name: '--text-tertiary', position: 0.6},
{name: '--text-tertiary-secondary', position: 0.75},
{name: '--text-tertiary-muted', position: 0.85},
],
},
},
tokens: {
root: [
{scale: 'darkSurface'},
{scale: 'darkText'},
{
name: '--panel-control-bg',
value: `color-mix(
in srgb,
var(--background-secondary-alt) 80%,
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
)`,
},
{name: '--panel-control-border', family: 'neutralDark', saturation: 30, lightness: 65, alpha: 0.45},
{name: '--panel-control-divider', family: 'neutralDark', saturation: 30, lightness: 55, alpha: 0.35},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.04)'},
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.05},
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.1},
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.15},
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.22},
{name: '--control-button-normal-bg', value: 'transparent'},
{name: '--control-button-normal-text', value: 'var(--text-primary-muted)'},
{name: '--control-button-hover-bg', family: 'neutralDark', lightness: 22},
{name: '--control-button-hover-text', value: 'var(--text-primary)'},
{name: '--control-button-active-bg', family: 'neutralDark', lightness: 24},
{name: '--control-button-active-text', value: 'var(--text-primary)'},
{name: '--control-button-danger-text', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 60},
{name: '--control-button-danger-hover-bg', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 20},
{name: '--brand-primary', family: 'brand', lightness: 55},
{name: '--brand-secondary', family: 'brand', saturation: 60, lightness: 49},
{name: '--brand-primary-light', family: 'brand', saturation: 100, lightness: 84},
{name: '--brand-primary-fill', hue: 0, saturation: 0, lightness: 100},
{name: '--status-online', family: 'statusOnline', lightness: 40},
{name: '--status-idle', family: 'statusIdle', lightness: 50},
{name: '--status-dnd', family: 'statusDnd', lightness: 60},
{name: '--status-offline', family: 'statusOffline', lightness: 65},
{name: '--status-danger', family: 'statusDanger', lightness: 55},
{name: '--status-warning', value: 'var(--status-idle)'},
{name: '--text-warning', family: 'statusIdle', lightness: 55},
{name: '--plutonium', value: 'var(--brand-primary)'},
{name: '--plutonium-hover', value: 'var(--brand-secondary)'},
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
{name: '--plutonium-icon', family: 'brandIcon', lightness: 50},
{name: '--invite-verified-icon-color', value: 'var(--text-on-brand-primary)'},
{name: '--text-link', family: 'link', lightness: 70},
{name: '--text-on-brand-primary', hue: 0, saturation: 0, lightness: 98},
{name: '--text-code', family: 'textCode', lightness: 90},
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35},
{name: '--markup-mention-text', value: 'var(--text-link)'},
{name: '--markup-mention-fill', value: 'color-mix(in srgb, var(--text-link) 20%, transparent)'},
{name: '--markup-mention-border', family: 'link', lightness: 70, alpha: 0.3},
{name: '--markup-jump-link-text', value: 'var(--text-link)'},
{name: '--markup-jump-link-fill', value: 'color-mix(in srgb, var(--text-link) 12%, transparent)'},
{name: '--markup-jump-link-hover-fill', value: 'color-mix(in srgb, var(--text-link) 20%, transparent)'},
{name: '--markup-everyone-text', hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75},
{
name: '--markup-everyone-fill',
value: 'color-mix(in srgb, hsl(250, calc(80% * var(--saturation-factor)), 75%) 18%, transparent)',
},
{
name: '--markup-everyone-border',
hue: 250,
saturation: 80,
useSaturationFactor: true,
lightness: 75,
alpha: 0.3,
},
{name: '--markup-here-text', hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70},
{
name: '--markup-here-fill',
value: 'color-mix(in srgb, hsl(45, calc(90% * var(--saturation-factor)), 70%) 18%, transparent)',
},
{name: '--markup-here-border', hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.3},
{name: '--markup-interactive-hover-text', value: 'var(--text-link)'},
{name: '--markup-interactive-hover-fill', value: 'color-mix(in srgb, var(--text-link) 30%, transparent)'},
{
name: '--interactive-muted',
value: `color-mix(
in oklab,
hsl(228, calc(10% * var(--saturation-factor)), 35%) 100%,
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
)`,
},
{
name: '--interactive-active',
value: `color-mix(
in oklab,
hsl(0, calc(0% * var(--saturation-factor)), 100%) 100%,
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
)`,
},
{name: '--button-primary-fill', hue: 139, saturation: 55, useSaturationFactor: true, lightness: 44},
{name: '--button-primary-active-fill', hue: 136, saturation: 60, useSaturationFactor: true, lightness: 38},
{name: '--button-primary-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-secondary-fill', hue: 0, saturation: 0, lightness: 100, alpha: 0.1, useSaturationFactor: false},
{
name: '--button-secondary-active-fill',
hue: 0,
saturation: 0,
lightness: 100,
alpha: 0.15,
useSaturationFactor: false,
},
{name: '--button-secondary-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-secondary-active-text', value: 'var(--button-secondary-text)'},
{name: '--button-danger-fill', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 54},
{name: '--button-danger-active-fill', hue: 359, saturation: 65, useSaturationFactor: true, lightness: 45},
{name: '--button-danger-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-danger-outline-border', value: '1px solid hsl(359, calc(70% * var(--saturation-factor)), 54%)'},
{name: '--button-danger-outline-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-danger-outline-active-fill', hue: 359, saturation: 65, useSaturationFactor: true, lightness: 48},
{name: '--button-danger-outline-active-border', value: 'transparent'},
{name: '--button-ghost-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-inverted-fill', hue: 0, saturation: 0, lightness: 100},
{name: '--button-inverted-text', hue: 0, saturation: 0, lightness: 0},
{name: '--button-outline-border', value: '1px solid hsla(0, 0%, 100%, 0.3)'},
{name: '--button-outline-text', hue: 0, saturation: 0, lightness: 100},
{name: '--button-outline-active-fill', value: 'hsla(0, 0%, 100%, 0.15)'},
{name: '--button-outline-active-border', value: '1px solid hsla(0, 0%, 100%, 0.4)'},
{name: '--theme-border', value: 'transparent'},
{name: '--theme-border-width', value: '0px'},
{name: '--bg-primary', value: 'var(--background-primary)'},
{name: '--bg-secondary', value: 'var(--background-secondary)'},
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
{name: '--bg-code', family: 'neutralDark', lightness: 15, alpha: 0.8},
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
{name: '--border-color', family: 'neutralDark', lightness: 50, alpha: 0.2},
{name: '--border-color-hover', family: 'neutralDark', lightness: 50, alpha: 0.3},
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45},
{name: '--accent-primary', value: 'var(--brand-primary)'},
{name: '--accent-success', value: 'var(--status-online)'},
{name: '--accent-warning', value: 'var(--status-idle)'},
{name: '--accent-danger', value: 'var(--status-dnd)'},
{name: '--accent-info', value: 'var(--text-link)'},
{name: '--accent-purple', family: 'accentPurple', lightness: 65},
{name: '--alert-note-color', family: 'link', lightness: 70},
{name: '--alert-tip-color', family: 'statusOnline', lightness: 45},
{name: '--alert-important-color', family: 'accentPurple', lightness: 65},
{name: '--alert-warning-color', family: 'statusIdle', lightness: 55},
{name: '--alert-caution-color', hue: 359, saturation: 75, useSaturationFactor: true, lightness: 60},
{name: '--shadow-sm', value: '0 1px 2px rgba(0, 0, 0, 0.1)'},
{name: '--shadow-md', value: '0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1)'},
{name: '--shadow-lg', value: '0 4px 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1)'},
{name: '--shadow-xl', value: '0 10px 20px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1)'},
{name: '--transition-fast', value: '100ms ease'},
{name: '--transition-normal', value: '200ms ease'},
{name: '--transition-slow', value: '300ms ease'},
{name: '--spoiler-overlay-color', value: 'rgba(0, 0, 0, 0.2)'},
{name: '--spoiler-overlay-hover-color', value: 'rgba(0, 0, 0, 0.3)'},
{name: '--scrollbar-thumb-bg', value: 'rgba(121, 122, 124, 0.4)'},
{name: '--scrollbar-thumb-bg-hover', value: 'rgba(121, 122, 124, 0.7)'},
{name: '--scrollbar-track-bg', value: 'transparent'},
{
name: '--user-area-divider-color',
value: 'color-mix(in srgb, var(--background-modifier-hover) 70%, transparent)',
},
],
light: [
{scale: 'lightSurface'},
{scale: 'lightText'},
{name: '--panel-control-bg', value: 'color-mix(in srgb, var(--background-secondary) 65%, hsl(0, 0%, 100%) 35%)'},
{name: '--panel-control-border', family: 'neutralLight', saturation: 25, lightness: 45, alpha: 0.25},
{name: '--panel-control-divider', family: 'neutralLight', saturation: 30, lightness: 35, alpha: 0.2},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.65)'},
{name: '--background-modifier-hover', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.05},
{name: '--background-modifier-selected', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
{name: '--background-modifier-accent', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.22},
{name: '--background-modifier-accent-focus', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.32},
{name: '--control-button-normal-bg', value: 'transparent'},
{name: '--control-button-normal-text', family: 'neutralLight', lightness: 50},
{name: '--control-button-hover-bg', family: 'neutralLight', lightness: 88},
{name: '--control-button-hover-text', family: 'neutralLight', lightness: 20},
{name: '--control-button-active-bg', family: 'neutralLight', lightness: 85},
{name: '--control-button-active-text', family: 'neutralLight', lightness: 15},
{name: '--control-button-danger-text', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
{name: '--control-button-danger-hover-bg', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 95},
{name: '--text-link', family: 'link', lightness: 45},
{name: '--text-code', family: 'textCode', lightness: 45},
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.2},
{name: '--markup-mention-border', family: 'link', lightness: 45, alpha: 0.4},
{name: '--markup-jump-link-fill', value: 'color-mix(in srgb, var(--text-link) 8%, transparent)'},
{name: '--markup-everyone-text', hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45},
{
name: '--markup-everyone-fill',
value: 'color-mix(in srgb, hsl(250, calc(70% * var(--saturation-factor)), 45%) 12%, transparent)',
},
{
name: '--markup-everyone-border',
hue: 250,
saturation: 70,
useSaturationFactor: true,
lightness: 45,
alpha: 0.4,
},
{name: '--markup-here-text', hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40},
{
name: '--markup-here-fill',
value: 'color-mix(in srgb, hsl(40, calc(85% * var(--saturation-factor)), 40%) 12%, transparent)',
},
{name: '--markup-here-border', hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40, alpha: 0.4},
{name: '--status-online', family: 'statusOnline', saturation: 70, lightness: 40},
{name: '--status-idle', family: 'statusIdle', saturation: 90, lightness: 45},
{name: '--status-dnd', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
{name: '--status-offline', family: 'statusOffline', hue: 210, saturation: 10, lightness: 55},
{name: '--plutonium', value: 'var(--brand-primary)'},
{name: '--plutonium-hover', value: 'var(--brand-secondary)'},
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
{name: '--plutonium-icon', family: 'brandIcon', lightness: 45},
{name: '--invite-verified-icon-color', value: 'var(--brand-primary)'},
{name: '--border-color', family: 'neutralLight', lightness: 40, alpha: 0.15},
{name: '--border-color-hover', family: 'neutralLight', lightness: 40, alpha: 0.25},
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4},
{name: '--bg-primary', value: 'var(--background-primary)'},
{name: '--bg-secondary', value: 'var(--background-secondary)'},
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
{name: '--bg-code', family: 'neutralLight', saturation: 22, lightness: 90, alpha: 0.9},
{name: '--bg-code-block', value: 'var(--background-primary)'},
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
{name: '--alert-note-color', family: 'link', lightness: 45},
{name: '--alert-tip-color', hue: 150, saturation: 80, useSaturationFactor: true, lightness: 35},
{name: '--alert-important-color', family: 'accentPurple', lightness: 50},
{name: '--alert-warning-color', family: 'statusIdle', saturation: 90, lightness: 45},
{name: '--alert-caution-color', hue: 358, saturation: 80, useSaturationFactor: true, lightness: 50},
{name: '--spoiler-overlay-color', value: 'rgba(0, 0, 0, 0.1)'},
{name: '--spoiler-overlay-hover-color', value: 'rgba(0, 0, 0, 0.15)'},
{name: '--button-secondary-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
{name: '--button-secondary-active-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.15},
{name: '--button-secondary-text', family: 'neutralLight', lightness: 15},
{name: '--button-secondary-active-text', family: 'neutralLight', lightness: 10},
{name: '--button-ghost-text', family: 'neutralLight', lightness: 20},
{name: '--button-inverted-fill', hue: 0, saturation: 0, lightness: 100},
{name: '--button-inverted-text', hue: 0, saturation: 0, lightness: 10},
{name: '--button-outline-border', value: '1px solid hsla(220, calc(10% * var(--saturation-factor)), 40%, 0.3)'},
{name: '--button-outline-text', family: 'neutralLight', lightness: 20},
{name: '--button-outline-active-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
{
name: '--button-outline-active-border',
value: '1px solid hsla(220, calc(10% * var(--saturation-factor)), 40%, 0.5)',
},
{name: '--button-danger-outline-border', value: '1px solid hsl(359, calc(70% * var(--saturation-factor)), 50%)'},
{name: '--button-danger-outline-text', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 45},
{name: '--button-danger-outline-active-fill', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
{name: '--user-area-divider-color', family: 'neutralLight', lightness: 40, alpha: 0.2},
],
coal: [
{scale: 'coalSurface'},
{name: '--background-secondary', value: 'var(--background-primary)'},
{name: '--background-secondary-lighter', value: 'var(--background-primary)'},
{
name: '--panel-control-bg',
value: `color-mix(
in srgb,
var(--background-primary) 90%,
hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
)`,
},
{name: '--panel-control-border', family: 'neutralDark', saturation: 20, lightness: 30, alpha: 0.35},
{name: '--panel-control-divider', family: 'neutralDark', saturation: 20, lightness: 25, alpha: 0.28},
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.06)'},
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.04},
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.08},
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 10, lightness: 65, alpha: 0.18},
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 10, lightness: 70, alpha: 0.26},
{name: '--control-button-normal-bg', value: 'transparent'},
{name: '--control-button-normal-text', value: 'var(--text-primary-muted)'},
{name: '--control-button-hover-bg', family: 'neutralDark', lightness: 12},
{name: '--control-button-hover-text', value: 'var(--text-primary)'},
{name: '--control-button-active-bg', family: 'neutralDark', lightness: 14},
{name: '--control-button-active-text', value: 'var(--text-primary)'},
{name: '--scrollbar-thumb-bg', value: 'rgba(160, 160, 160, 0.35)'},
{name: '--scrollbar-thumb-bg-hover', value: 'rgba(200, 200, 200, 0.55)'},
{name: '--scrollbar-track-bg', value: 'rgba(0, 0, 0, 0.45)'},
{name: '--bg-primary', value: 'var(--background-primary)'},
{name: '--bg-secondary', value: 'var(--background-secondary)'},
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
{name: '--bg-code', value: 'hsl(220, calc(13% * var(--saturation-factor)), 8%)'},
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
{name: '--bg-blockquote', value: 'var(--background-secondary)'},
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
{name: '--button-secondary-fill', value: 'hsla(0, 0%, 100%, 0.04)'},
{name: '--button-secondary-active-fill', value: 'hsla(0, 0%, 100%, 0.07)'},
{name: '--button-secondary-text', value: 'var(--text-primary)'},
{name: '--button-secondary-active-text', value: 'var(--text-primary)'},
{name: '--button-outline-border', value: '1px solid hsla(0, 0%, 100%, 0.08)'},
{name: '--button-outline-active-fill', value: 'hsla(0, 0%, 100%, 0.12)'},
{name: '--button-outline-active-border', value: '1px solid hsla(0, 0%, 100%, 0.16)'},
{
name: '--user-area-divider-color',
value: 'color-mix(in srgb, var(--background-modifier-hover) 80%, transparent)',
},
],
},
};
interface OutputToken {
type: 'tone' | 'literal';
name: string;
family?: string;
hue?: number;
saturation?: number;
lightness?: number;
alpha?: number;
useSaturationFactor?: boolean;
value?: string;
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function applyCurve(curve: Scale['curve'], t: number): number {
switch (curve) {
case 'easeIn':
return t * t;
case 'easeOut':
return 1 - (1 - t) * (1 - t);
case 'easeInOut':
if (t < 0.5) {
return 2 * t * t;
}
return 1 - 2 * (1 - t) * (1 - t);
default:
return t;
}
}
function buildScaleTokens(scale: Scale): Array<OutputToken> {
const lastIndex = Math.max(scale.stops.length - 1, 1);
const tokens: Array<OutputToken> = [];
for (let i = 0; i < scale.stops.length; i++) {
const stop = scale.stops[i];
let pos: number;
if (stop.position !== undefined) {
pos = clamp01(stop.position);
} else {
pos = i / lastIndex;
}
const eased = applyCurve(scale.curve, pos);
let lightness = scale.range[0] + (scale.range[1] - scale.range[0]) * eased;
lightness = Math.round(lightness * 1000) / 1000;
tokens.push({
type: 'tone',
name: stop.name,
family: scale.family,
lightness,
});
}
return tokens;
}
function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Array<OutputToken> {
const tokens: Array<OutputToken> = [];
for (const def of defs) {
if (def.scale) {
const scale = scales[def.scale];
if (!scale) {
console.warn(`Warning: unknown scale "${def.scale}"`);
continue;
}
tokens.push(...buildScaleTokens(scale));
continue;
}
if (def.value !== undefined) {
tokens.push({
type: 'literal',
name: def.name!,
value: def.value.trim(),
});
} else {
tokens.push({
type: 'tone',
name: def.name!,
family: def.family,
hue: def.hue,
saturation: def.saturation,
lightness: def.lightness,
alpha: def.alpha,
useSaturationFactor: def.useSaturationFactor,
});
}
}
return tokens;
}
function formatNumber(value: number): string {
if (value === Math.floor(value)) {
return String(Math.floor(value));
}
let s = value.toFixed(2);
s = s.replace(/\.?0+$/, '');
return s;
}
function formatTone(token: OutputToken, families: Record<string, ColorFamily>): string {
const family = token.family ? families[token.family] : undefined;
let hue = 0;
let saturation = 0;
let lightness = 0;
let useFactor = false;
if (token.hue !== undefined) {
hue = token.hue;
} else if (family) {
hue = family.hue;
}
if (token.saturation !== undefined) {
saturation = token.saturation;
} else if (family) {
saturation = family.saturation;
}
if (token.lightness !== undefined) {
lightness = token.lightness;
}
if (token.useSaturationFactor !== undefined) {
useFactor = token.useSaturationFactor;
} else if (family) {
useFactor = family.useSaturationFactor;
}
let satStr: string;
if (useFactor) {
satStr = `calc(${formatNumber(saturation)}% * var(--saturation-factor))`;
} else {
satStr = `${formatNumber(saturation)}%`;
}
if (token.alpha === undefined) {
return `hsl(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%)`;
}
return `hsla(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%, ${formatNumber(token.alpha)})`;
}
function formatValue(token: OutputToken, families: Record<string, ColorFamily>): string {
if (token.type === 'tone') {
return formatTone(token, families);
}
return token.value!.trim();
}
function renderBlock(selector: string, tokens: Array<OutputToken>, families: Record<string, ColorFamily>): string {
const lines: Array<string> = [];
for (const token of tokens) {
lines.push(`\t${token.name}: ${formatValue(token, families)};`);
}
return `${selector} {\n${lines.join('\n')}\n}`;
}
function generateCSS(
cfg: Config,
rootTokens: Array<OutputToken>,
lightTokens: Array<OutputToken>,
coalTokens: Array<OutputToken>,
): string {
const header = `/*
* This file is auto-generated by scripts/GenerateColorSystem.ts.
* Do not edit directly — update the config in generate-color-system.ts instead.
*/`;
const blocks = [
renderBlock(':root', rootTokens, cfg.families),
renderBlock('.theme-light', lightTokens, cfg.families),
renderBlock('.theme-coal', coalTokens, cfg.families),
];
return `${header}\n\n${blocks.join('\n\n')}\n`;
}
function main() {
const scriptDir = import.meta.dirname;
const appDir = join(scriptDir, '..');
const rootTokens = expandTokens(CONFIG.tokens.root, CONFIG.scales);
const lightTokens = expandTokens(CONFIG.tokens.light, CONFIG.scales);
const coalTokens = expandTokens(CONFIG.tokens.coal, CONFIG.scales);
const cssPath = join(appDir, 'src', 'styles', 'generated', 'color-system.css');
mkdirSync(dirname(cssPath), {recursive: true});
const css = generateCSS(CONFIG, rootTokens, lightTokens, coalTokens);
writeFileSync(cssPath, css);
const relCSS = relative(appDir, cssPath);
console.log(`Wrote ${relCSS}`);
}
main();

View File

@@ -0,0 +1,315 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {mkdirSync, readFileSync, writeFileSync} from 'node:fs';
import {join} from 'node:path';
import {convertToCodePoints} from '@app/utils/EmojiCodepointUtils';
import sharp from 'sharp';
const EMOJI_SPRITES = {
nonDiversityPerRow: 42,
diversityPerRow: 10,
pickerPerRow: 11,
pickerCount: 50,
} as const;
const EMOJI_SIZE = 32;
const TWEMOJI_CDN = 'https://fluxerstatic.com/emoji';
const SPRITE_SCALES = [1, 2] as const;
interface EmojiObject {
surrogates: string;
skins?: Array<{surrogates: string}>;
}
interface EmojiEntry {
surrogates: string;
}
const svgCache = new Map<string, string | null>();
async function fetchTwemojiSVG(codepoint: string): Promise<string | null> {
if (svgCache.has(codepoint)) {
return svgCache.get(codepoint) ?? null;
}
const url = `${TWEMOJI_CDN}/${codepoint}.svg`;
try {
const response = await fetch(url);
if (!response.ok) {
console.error(`Twemoji ${codepoint} returned ${response.status}`);
svgCache.set(codepoint, null);
return null;
}
const body = await response.text();
svgCache.set(codepoint, body);
return body;
} catch (err) {
console.error(`Failed to fetch Twemoji ${codepoint}:`, err);
svgCache.set(codepoint, null);
return null;
}
}
function fixSVGSize(svg: string, size: number): string {
return svg.replace(/<svg([^>]*)>/i, `<svg$1 width="${size}" height="${size}">`);
}
async function renderSVGToBuffer(svgContent: string, size: number): Promise<Buffer> {
const fixed = fixSVGSize(svgContent, size);
return sharp(Buffer.from(fixed)).resize(size, size).png().toBuffer();
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
h = ((h % 360) + 360) % 360;
h /= 360;
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hueToRgb = (p: number, q: number, t: number): number => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
r = hueToRgb(p, q, h + 1 / 3);
g = hueToRgb(p, q, h);
b = hueToRgb(p, q, h - 1 / 3);
}
return [
Math.round(Math.min(1, Math.max(0, r)) * 255),
Math.round(Math.min(1, Math.max(0, g)) * 255),
Math.round(Math.min(1, Math.max(0, b)) * 255),
];
}
async function createPlaceholder(size: number): Promise<Buffer> {
const h = Math.random() * 360;
const [r, g, b] = hslToRgb(h, 0.7, 0.6);
const radius = Math.floor(size * 0.4);
const cx = Math.floor(size / 2);
const cy = Math.floor(size / 2);
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${cx}" cy="${cy}" r="${radius}" fill="rgb(${r},${g},${b})"/>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer> {
const codepoint = convertToCodePoints(surrogate);
const svg = await fetchTwemojiSVG(codepoint);
if (svg) {
try {
return await renderSVGToBuffer(svg, size);
} catch (error) {
console.error(`Failed to render SVG for ${codepoint}:`, error);
}
}
if (codepoint.includes('-200d-')) {
const basePart = codepoint.split('-200d-')[0];
const baseSvg = await fetchTwemojiSVG(basePart);
if (baseSvg) {
try {
return await renderSVGToBuffer(baseSvg, size);
} catch (error) {
console.error(`Failed to render base SVG for ${basePart}:`, error);
}
}
}
console.error(`Missing SVG for ${codepoint} (${surrogate}), using placeholder`);
return createPlaceholder(size);
}
async function renderSpriteSheet(
emojiEntries: Array<EmojiEntry>,
perRow: number,
fileNameBase: string,
outputDir: string,
): Promise<void> {
if (perRow <= 0) {
throw new Error('perRow must be > 0');
}
const rows = Math.ceil(emojiEntries.length / perRow);
for (const scale of SPRITE_SCALES) {
const size = EMOJI_SIZE * scale;
const dstW = perRow * size;
const dstH = rows * size;
const compositeOps: Array<sharp.OverlayOptions> = [];
for (let i = 0; i < emojiEntries.length; i++) {
const item = emojiEntries[i];
const emojiBuffer = await loadEmojiImage(item.surrogates, size);
const row = Math.floor(i / perRow);
const col = i % perRow;
const x = col * size;
const y = row * size;
compositeOps.push({
input: emojiBuffer,
left: x,
top: y,
});
}
const sheet = await sharp({
create: {
width: dstW,
height: dstH,
channels: 4,
background: {r: 0, g: 0, b: 0, alpha: 0},
},
})
.composite(compositeOps)
.png()
.toBuffer();
const suffix = scale !== 1 ? `@${scale}x` : '';
const outPath = join(outputDir, `${fileNameBase}${suffix}.png`);
writeFileSync(outPath, sheet);
console.log(`Wrote ${outPath}`);
}
}
async function generateMainSpriteSheet(
emojiData: Record<string, Array<EmojiObject>>,
outputDir: string,
): Promise<void> {
const base: Array<EmojiEntry> = [];
for (const objs of Object.values(emojiData)) {
for (const obj of objs) {
base.push({surrogates: obj.surrogates});
}
}
await renderSpriteSheet(base, EMOJI_SPRITES.nonDiversityPerRow, 'spritesheet-emoji', outputDir);
}
async function generateDiversitySpriteSheets(
emojiData: Record<string, Array<EmojiObject>>,
outputDir: string,
): Promise<void> {
const skinTones = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
for (let skinIndex = 0; skinIndex < skinTones.length; skinIndex++) {
const skinTone = skinTones[skinIndex];
const skinCodepoint = convertToCodePoints(skinTone);
const skinEntries: Array<EmojiEntry> = [];
for (const objs of Object.values(emojiData)) {
for (const obj of objs) {
if (obj.skins && obj.skins.length > skinIndex && obj.skins[skinIndex].surrogates) {
skinEntries.push({surrogates: obj.skins[skinIndex].surrogates});
}
}
}
if (skinEntries.length === 0) {
continue;
}
await renderSpriteSheet(skinEntries, EMOJI_SPRITES.diversityPerRow, `spritesheet-${skinCodepoint}`, outputDir);
}
}
async function generatePickerSpriteSheet(outputDir: string): Promise<void> {
const basicEmojis = [
'\u{1F600}',
'\u{1F603}',
'\u{1F604}',
'\u{1F601}',
'\u{1F606}',
'\u{1F605}',
'\u{1F602}',
'\u{1F923}',
'\u{1F60A}',
'\u{1F607}',
'\u{1F642}',
'\u{1F609}',
'\u{1F60C}',
'\u{1F60D}',
'\u{1F970}',
'\u{1F618}',
'\u{1F617}',
'\u{1F619}',
'\u{1F61A}',
'\u{1F60B}',
'\u{1F61B}',
'\u{1F61D}',
'\u{1F61C}',
'\u{1F92A}',
'\u{1F928}',
'\u{1F9D0}',
'\u{1F913}',
'\u{1F60E}',
'\u{1F973}',
'\u{1F60F}',
];
const entries: Array<EmojiEntry> = basicEmojis.map((e) => ({surrogates: e}));
await renderSpriteSheet(entries, EMOJI_SPRITES.pickerPerRow, 'spritesheet-picker', outputDir);
}
async function main(): Promise<void> {
const scriptDir = import.meta.dirname;
const appDir = join(scriptDir, '..');
const outputDir = join(appDir, 'src', 'assets', 'emoji-sprites');
mkdirSync(outputDir, {recursive: true});
const emojiDataPath = join(appDir, 'src', 'data', 'emojis.json');
const emojiData: Record<string, Array<EmojiObject>> = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
console.log('Generating main sprite sheet...');
await generateMainSpriteSheet(emojiData, outputDir);
console.log('Generating diversity sprite sheets...');
await generateDiversitySpriteSheets(emojiData, outputDir);
console.log('Generating picker sprite sheet...');
await generatePickerSpriteSheet(outputDir);
console.log('Emoji sprites generated successfully.');
}
main().catch((err) => {
console.error('Error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {spawnSync} from 'node:child_process';
import {readFileSync} from 'node:fs';
import {homedir} from 'node:os';
import {join} from 'node:path';
const envOverrides = loadEnvFromFiles(['FLUXER_AUTO_I18N', 'OPENROUTER_API_KEY']);
const FLUXER_AUTO_I18N = process.env.FLUXER_AUTO_I18N ?? envOverrides.FLUXER_AUTO_I18N ?? '';
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? envOverrides.OPENROUTER_API_KEY ?? '';
const shouldRun = FLUXER_AUTO_I18N === '1' && Boolean(OPENROUTER_API_KEY);
if (!shouldRun) {
process.exit(0);
}
const childEnv = {...process.env, FLUXER_AUTO_I18N, OPENROUTER_API_KEY};
const scriptPath = new URL('./translate-i18n.mjs', import.meta.url).pathname;
const result = spawnSync(process.execPath, [scriptPath], {stdio: 'inherit', env: childEnv});
process.exit(result.status ?? 1);
function loadEnvFromFiles(keys) {
const homeDir = homedir();
const targetKeys = new Set(keys);
const env = Object.create(null);
const candidates = ['.bash_profile', '.bashrc', '.profile'];
for (const candidate of candidates) {
const filePath = join(homeDir, candidate);
try {
const content = readFileSync(filePath, 'utf8');
for (const line of content.split(/\r?\n/)) {
const parsed = parseExportLine(line);
if (!parsed || !targetKeys.has(parsed.key) || env[parsed.key]) {
continue;
}
env[parsed.key] = parsed.value;
}
} catch {}
}
return env;
}
function parseExportLine(line) {
const trimmed = line.trim();
if (!trimmed.startsWith('export ')) {
return null;
}
const match = trimmed.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) {
return null;
}
return {key: match[1], value: stripQuotes(match[2])};
}
function stripQuotes(value) {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}

View File

@@ -1,52 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {execSync} from 'node:child_process';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import * as esbuild from 'esbuild';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDir, '..');
const allowedChannels = new Set(['stable', 'canary']);
const rawChannel = process.env.BUILD_CHANNEL?.toLowerCase() ?? '';
const channel = allowedChannels.has(rawChannel) ? rawChannel : 'stable';
console.log(`Building Electron with channel: ${channel}`);
execSync('node scripts/set-build-channel.mjs', {cwd: projectRoot, stdio: 'inherit'});
execSync('npx tsc -p tsconfig.electron.json', {cwd: projectRoot, stdio: 'inherit'});
await esbuild.build({
entryPoints: [path.join(projectRoot, 'src-electron/preload/index.ts')],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: path.join(projectRoot, 'src-electron/dist/preload/index.js'),
external: ['electron'],
define: {
'process.env.BUILD_CHANNEL': JSON.stringify(channel),
},
sourcemap: true,
});
console.log('Electron build complete');

View File

@@ -17,7 +17,7 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {buildServiceWorker} from './build/utils/service-worker.js';
import {buildServiceWorker} from './build/utils/ServiceWorker';
const isProduction = process.env.NODE_ENV === 'production';

View File

@@ -19,19 +19,19 @@
import {sources} from '@rspack/core';
function normalizeEndpoint(cdnEndpoint) {
if (!cdnEndpoint) return '';
return cdnEndpoint.endsWith('/') ? cdnEndpoint.slice(0, -1) : cdnEndpoint;
function normalizeEndpoint(staticCdnEndpoint) {
if (!staticCdnEndpoint) return '';
return staticCdnEndpoint.endsWith('/') ? staticCdnEndpoint.slice(0, -1) : staticCdnEndpoint;
}
function generateManifest(cdnEndpointRaw) {
const cdnEndpoint = normalizeEndpoint(cdnEndpointRaw);
function generateManifest(staticCdnEndpointRaw) {
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
const manifest = {
name: 'Fluxer',
short_name: 'Fluxer',
description:
'Fluxer is an open-source, independent instant messaging and VoIP platform. Built for friends, groups, and communities.',
'Fluxer is a free and open source instant messaging and VoIP platform built for friends, groups, and communities.',
start_url: '/',
display: 'standalone',
orientation: 'portrait-primary',
@@ -42,29 +42,29 @@ function generateManifest(cdnEndpointRaw) {
scope: '/',
icons: [
{
src: `${cdnEndpoint}/web/android-chrome-192x192.png`,
src: `${staticCdnEndpoint}/web/android-chrome-192x192.png`,
sizes: '192x192',
type: 'image/png',
purpose: 'maskable any',
},
{
src: `${cdnEndpoint}/web/android-chrome-512x512.png`,
src: `${staticCdnEndpoint}/web/android-chrome-512x512.png`,
sizes: '512x512',
type: 'image/png',
purpose: 'maskable any',
},
{
src: `${cdnEndpoint}/web/apple-touch-icon.png`,
src: `${staticCdnEndpoint}/web/apple-touch-icon.png`,
sizes: '180x180',
type: 'image/png',
},
{
src: `${cdnEndpoint}/web/favicon-32x32.png`,
src: `${staticCdnEndpoint}/web/favicon-32x32.png`,
sizes: '32x32',
type: 'image/png',
},
{
src: `${cdnEndpoint}/web/favicon-16x16.png`,
src: `${staticCdnEndpoint}/web/favicon-16x16.png`,
sizes: '16x16',
type: 'image/png',
},
@@ -74,14 +74,14 @@ function generateManifest(cdnEndpointRaw) {
return JSON.stringify(manifest, null, 2);
}
function generateBrowserConfig(cdnEndpointRaw) {
const cdnEndpoint = normalizeEndpoint(cdnEndpointRaw);
function generateBrowserConfig(staticCdnEndpointRaw) {
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
return `<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="${cdnEndpoint}/web/mstile-150x150.png"/>
<square150x150logo src="${staticCdnEndpoint}/web/mstile-150x150.png"/>
<TileColor>#4641D9</TileColor>
</tile>
</msapplication>
@@ -94,7 +94,7 @@ function generateRobotsTxt() {
export class StaticFilesPlugin {
constructor(options) {
this.cdnEndpoint = options?.cdnEndpoint ?? '';
this.staticCdnEndpoint = options?.staticCdnEndpoint ?? '';
}
apply(compiler) {
@@ -105,8 +105,11 @@ export class StaticFilesPlugin {
stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
compilation.emitAsset('manifest.json', new sources.RawSource(generateManifest(this.cdnEndpoint)));
compilation.emitAsset('browserconfig.xml', new sources.RawSource(generateBrowserConfig(this.cdnEndpoint)));
compilation.emitAsset('manifest.json', new sources.RawSource(generateManifest(this.staticCdnEndpoint)));
compilation.emitAsset(
'browserconfig.xml',
new sources.RawSource(generateBrowserConfig(this.staticCdnEndpoint)),
);
compilation.emitAsset('robots.txt', new sources.RawSource(generateRobotsTxt()));
},
);

View File

@@ -10,11 +10,15 @@
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"jsx": "preserve",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"paths": {
"@app_scripts/*": ["./../*"]
}
},
"include": ["./**/*.ts"]
"include": ["./**/*.ts", "./**/*.tsx"]
}

View File

@@ -19,7 +19,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {ASSETS_DIR, DIST_DIR, PKGS_DIR, PUBLIC_DIR} from '../config';
import {ASSETS_DIR, DIST_DIR, PKGS_DIR, PUBLIC_DIR} from '@app_scripts/build/Config';
export async function copyPublicAssets(): Promise<void> {
if (!fs.existsSync(PUBLIC_DIR)) {

View File

@@ -19,9 +19,9 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {PKGS_DIR, SRC_DIR} from '@app_scripts/build/Config';
import postcss from 'postcss';
import postcssModules from 'postcss-modules';
import {PKGS_DIR, SRC_DIR} from '../config';
const RESERVED_KEYWORDS = new Set([
'break',

View File

@@ -19,7 +19,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {ASSETS_DIR, CDN_ENDPOINT, ROOT_DIR} from '../config';
import {ASSETS_DIR, CDN_ENDPOINT, ROOT_DIR} from '@app_scripts/build/Config';
interface BuildOutput {
mainScript: string | null;

View File

@@ -19,7 +19,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {RESOLVE_EXTENSIONS} from '../config';
import {RESOLVE_EXTENSIONS} from '@app_scripts/build/Config';
export function tryResolveWithExtensions(basePath: string): string | null {
if (fs.existsSync(basePath)) {

View File

@@ -18,12 +18,12 @@
*/
import * as path from 'node:path';
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
import * as esbuild from 'esbuild';
import {DIST_DIR, SRC_DIR} from '../config';
export async function buildServiceWorker(production: boolean): Promise<void> {
await esbuild.build({
entryPoints: [path.join(SRC_DIR, 'sw', 'worker.ts')],
entryPoints: [path.join(SRC_DIR, 'service_worker', 'Worker.tsx')],
bundle: true,
format: 'iife',
outfile: path.join(DIST_DIR, 'sw.js'),

View File

@@ -1,25 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export {copyPublicAssets, copyWasmFiles, removeUnusedCssAssets} from './assets';
export {generateAllCssDts, generateCssDtsForFile} from './css-dts';
export {generateHtml} from './html';
export {tryResolveWithExtensions} from './resolve';
export {buildServiceWorker} from './service-worker';
export {cleanEmptySourceMaps} from './sourcemaps';

View File

@@ -1,290 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type ColorFamily struct {
Hue int `yaml:"hue" json:"hue"`
Saturation int `yaml:"saturation" json:"saturation"`
UseSaturationFactor bool `yaml:"useSaturationFactor" json:"useSaturationFactor"`
}
type ScaleStop struct {
Name string `yaml:"name"`
Position *float64 `yaml:"position"`
}
type Scale struct {
Family string `yaml:"family"`
Range [2]float64 `yaml:"range"`
Curve string `yaml:"curve"`
Stops []ScaleStop `yaml:"stops"`
}
type TokenDef struct {
Name string `yaml:"name,omitempty"`
Scale string `yaml:"scale,omitempty"`
Value string `yaml:"value,omitempty"`
Family string `yaml:"family,omitempty"`
Hue *int `yaml:"hue,omitempty"`
Saturation *int `yaml:"saturation,omitempty"`
Lightness *float64 `yaml:"lightness,omitempty"`
Alpha *float64 `yaml:"alpha,omitempty"`
UseSaturationFactor *bool `yaml:"useSaturationFactor,omitempty"`
}
type Config struct {
Families map[string]ColorFamily `yaml:"families"`
Scales map[string]Scale `yaml:"scales"`
Tokens struct {
Root []TokenDef `yaml:"root"`
Light []TokenDef `yaml:"light"`
Coal []TokenDef `yaml:"coal"`
} `yaml:"tokens"`
}
type OutputToken struct {
Type string `json:"type"`
Name string `json:"name"`
Family string `json:"family,omitempty"`
Hue *int `json:"hue,omitempty"`
Saturation *int `json:"saturation,omitempty"`
Lightness *float64 `json:"lightness,omitempty"`
Alpha *float64 `json:"alpha,omitempty"`
UseSaturationFactor *bool `json:"useSaturationFactor,omitempty"`
Value string `json:"value,omitempty"`
}
func clamp01(value float64) float64 {
return math.Min(1, math.Max(0, value))
}
func applyCurve(curve string, t float64) float64 {
switch curve {
case "easeIn":
return t * t
case "easeOut":
return 1 - (1-t)*(1-t)
case "easeInOut":
if t < 0.5 {
return 2 * t * t
}
return 1 - 2*(1-t)*(1-t)
default:
return t
}
}
func buildScaleTokens(scale Scale) []OutputToken {
lastIndex := float64(max(len(scale.Stops)-1, 1))
tokens := make([]OutputToken, 0, len(scale.Stops))
for i, stop := range scale.Stops {
pos := 0.0
if stop.Position != nil {
pos = clamp01(*stop.Position)
} else {
pos = float64(i) / lastIndex
}
eased := applyCurve(scale.Curve, pos)
lightness := scale.Range[0] + (scale.Range[1]-scale.Range[0])*eased
lightness = math.Round(lightness*1000) / 1000
tokens = append(tokens, OutputToken{
Type: "tone",
Name: stop.Name,
Family: scale.Family,
Lightness: &lightness,
})
}
return tokens
}
func expandTokens(defs []TokenDef, scales map[string]Scale) []OutputToken {
var tokens []OutputToken
for _, def := range defs {
if def.Scale != "" {
scale, ok := scales[def.Scale]
if !ok {
fmt.Fprintf(os.Stderr, "Warning: unknown scale %q\n", def.Scale)
continue
}
tokens = append(tokens, buildScaleTokens(scale)...)
continue
}
if def.Value != "" {
tokens = append(tokens, OutputToken{
Type: "literal",
Name: def.Name,
Value: strings.TrimSpace(def.Value),
})
} else {
tokens = append(tokens, OutputToken{
Type: "tone",
Name: def.Name,
Family: def.Family,
Hue: def.Hue,
Saturation: def.Saturation,
Lightness: def.Lightness,
Alpha: def.Alpha,
UseSaturationFactor: def.UseSaturationFactor,
})
}
}
return tokens
}
func formatNumber(value float64) string {
if value == float64(int(value)) {
return fmt.Sprintf("%d", int(value))
}
s := fmt.Sprintf("%.2f", value)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
return s
}
func formatTone(token OutputToken, families map[string]ColorFamily) string {
var family *ColorFamily
if token.Family != "" {
if f, ok := families[token.Family]; ok {
family = &f
}
}
var hue, saturation int
var lightness float64
var useFactor bool
if token.Hue != nil {
hue = *token.Hue
} else if family != nil {
hue = family.Hue
}
if token.Saturation != nil {
saturation = *token.Saturation
} else if family != nil {
saturation = family.Saturation
}
if token.Lightness != nil {
lightness = *token.Lightness
}
if token.UseSaturationFactor != nil {
useFactor = *token.UseSaturationFactor
} else if family != nil {
useFactor = family.UseSaturationFactor
}
var satStr string
if useFactor {
satStr = fmt.Sprintf("calc(%s%% * var(--saturation-factor))", formatNumber(float64(saturation)))
} else {
satStr = fmt.Sprintf("%s%%", formatNumber(float64(saturation)))
}
if token.Alpha == nil {
return fmt.Sprintf("hsl(%s, %s, %s%%)", formatNumber(float64(hue)), satStr, formatNumber(lightness))
}
return fmt.Sprintf("hsla(%s, %s, %s%%, %s)", formatNumber(float64(hue)), satStr, formatNumber(lightness), formatNumber(*token.Alpha))
}
func formatValue(token OutputToken, families map[string]ColorFamily) string {
if token.Type == "tone" {
return formatTone(token, families)
}
return strings.TrimSpace(token.Value)
}
func renderBlock(selector string, tokens []OutputToken, families map[string]ColorFamily) string {
var lines []string
for _, token := range tokens {
lines = append(lines, fmt.Sprintf("\t%s: %s;", token.Name, formatValue(token, families)))
}
return fmt.Sprintf("%s {\n%s\n}", selector, strings.Join(lines, "\n"))
}
func generateCSS(cfg *Config, rootTokens, lightTokens, coalTokens []OutputToken) string {
header := `/*
* This file is auto-generated by scripts/cmd/generate-color-system.
* Do not edit directly — update color-system.yaml instead.
*/`
blocks := []string{
renderBlock(":root", rootTokens, cfg.Families),
renderBlock(".theme-light", lightTokens, cfg.Families),
renderBlock(".theme-coal", coalTokens, cfg.Families),
}
return header + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
}
func main() {
cwd, _ := os.Getwd()
configPath := filepath.Join(cwd, "color-system.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading config: %v\n", err)
os.Exit(1)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing config: %v\n", err)
os.Exit(1)
}
rootTokens := expandTokens(cfg.Tokens.Root, cfg.Scales)
lightTokens := expandTokens(cfg.Tokens.Light, cfg.Scales)
coalTokens := expandTokens(cfg.Tokens.Coal, cfg.Scales)
parentDir := filepath.Join(cwd, "..")
cssPath := filepath.Join(parentDir, "src", "styles", "generated", "color-system.css")
if err := os.MkdirAll(filepath.Dir(cssPath), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating CSS directory: %v\n", err)
os.Exit(1)
}
css := generateCSS(&cfg, rootTokens, lightTokens, coalTokens)
if err := os.WriteFile(cssPath, []byte(css), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing CSS file: %v\n", err)
os.Exit(1)
}
relCSS, _ := filepath.Rel(parentDir, cssPath)
fmt.Printf("Wrote %s\n", relCSS)
}

View File

@@ -1,437 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/png"
"io"
"math"
"math/rand"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
type EmojiSpritesConfig struct {
NonDiversityPerRow int
DiversityPerRow int
PickerPerRow int
PickerCount int
}
var EMOJI_SPRITES = EmojiSpritesConfig{
NonDiversityPerRow: 42,
DiversityPerRow: 10,
PickerPerRow: 11,
PickerCount: 50,
}
const (
EMOJI_SIZE = 32
TWEMOJI_CDN = "https://fluxerstatic.com/emoji"
)
var SPRITE_SCALES = []int{1, 2}
type EmojiObject struct {
Surrogates string `json:"surrogates"`
Skins []struct {
Surrogates string `json:"surrogates"`
} `json:"skins,omitempty"`
}
type EmojiEntry struct {
Surrogates string
}
type httpResp struct {
Status int
Body string
}
func main() {
rand.Seed(time.Now().UnixNano())
cwd, _ := os.Getwd()
appDir := filepath.Join(cwd, "..")
outputDir := filepath.Join(appDir, "src", "assets", "emoji-sprites")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "Failed to ensure output dir:", err)
os.Exit(1)
}
emojiData, err := loadEmojiData(filepath.Join(appDir, "src", "data", "emojis.json"))
if err != nil {
fmt.Fprintln(os.Stderr, "Error loading emoji data:", err)
os.Exit(1)
}
client := &http.Client{
Timeout: 30 * time.Second,
}
svgCache := newSVGCache()
if err := generateMainSpriteSheet(client, svgCache, emojiData, outputDir); err != nil {
fmt.Fprintln(os.Stderr, "Error generating main sprite sheet:", err)
os.Exit(1)
}
if err := generateDiversitySpriteSheets(client, svgCache, emojiData, outputDir); err != nil {
fmt.Fprintln(os.Stderr, "Error generating diversity sprite sheets:", err)
os.Exit(1)
}
if err := generatePickerSpriteSheet(client, svgCache, outputDir); err != nil {
fmt.Fprintln(os.Stderr, "Error generating picker sprite sheet:", err)
os.Exit(1)
}
fmt.Println("Emoji sprites generated successfully.")
}
func loadEmojiData(path string) (map[string][]EmojiObject, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var data map[string][]EmojiObject
if err := json.Unmarshal(b, &data); err != nil {
return nil, err
}
return data, nil
}
// --- SVG fetching + caching ---
type svgCache struct {
m map[string]*string
}
func newSVGCache() *svgCache {
return &svgCache{m: make(map[string]*string)}
}
func (c *svgCache) get(codepoint string) (*string, bool) {
v, ok := c.m[codepoint]
return v, ok
}
func (c *svgCache) set(codepoint string, v *string) {
c.m[codepoint] = v
}
func downloadSVG(client *http.Client, url string) (httpResp, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return httpResp{}, err
}
req.Header.Set("User-Agent", "fluxer-emoji-sprites/1.0")
resp, err := client.Do(req)
if err != nil {
return httpResp{}, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return httpResp{}, err
}
return httpResp{
Status: resp.StatusCode,
Body: string(bodyBytes),
}, nil
}
func fetchTwemojiSVG(client *http.Client, cache *svgCache, codepoint string) *string {
if v, ok := cache.get(codepoint); ok {
return v
}
url := fmt.Sprintf("%s/%s.svg", TWEMOJI_CDN, codepoint)
r, err := downloadSVG(client, url)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to fetch Twemoji %s: %v\n", codepoint, err)
cache.set(codepoint, nil)
return nil
}
if r.Status != 200 {
fmt.Fprintf(os.Stderr, "Twemoji %s returned %d\n", codepoint, r.Status)
cache.set(codepoint, nil)
return nil
}
body := r.Body
cache.set(codepoint, &body)
return &body
}
// --- Emoji -> codepoint ---
func emojiToCodepoint(s string) string {
parts := make([]string, 0, len(s))
for _, r := range s {
if r == 0xFE0F {
continue
}
parts = append(parts, strings.ToLower(strconv.FormatInt(int64(r), 16)))
}
return strings.Join(parts, "-")
}
// --- Rendering ---
var svgOpenTagRe = regexp.MustCompile(`(?i)<svg([^>]*)>`)
func fixSVGSize(svg string, size int) string {
return svgOpenTagRe.ReplaceAllString(svg, fmt.Sprintf(`<svg$1 width="%d" height="%d">`, size, size))
}
func renderSVGToImage(svgContent string, size int) (*image.RGBA, error) {
fixed := fixSVGSize(svgContent, size)
icon, err := oksvg.ReadIconStream(strings.NewReader(fixed))
if err != nil {
return nil, err
}
icon.SetTarget(0, 0, float64(size), float64(size))
dst := image.NewRGBA(image.Rect(0, 0, size, size))
scanner := rasterx.NewScannerGV(size, size, dst, dst.Bounds())
r := rasterx.NewDasher(size, size, scanner)
icon.Draw(r, 1.0)
return dst, nil
}
func createPlaceholder(size int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, size, size))
h := rand.Float64() * 360.0
r, g, b := hslToRGB(h, 0.70, 0.60)
cx := float64(size) / 2.0
cy := float64(size) / 2.0
radius := float64(size) * 0.4
r2 := radius * radius
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
dx := (float64(x) + 0.5) - cx
dy := (float64(y) + 0.5) - cy
if dx*dx+dy*dy <= r2 {
i := img.PixOffset(x, y)
img.Pix[i+0] = r
img.Pix[i+1] = g
img.Pix[i+2] = b
img.Pix[i+3] = 0xFF
}
}
}
return img
}
func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
h = math.Mod(h, 360.0) / 360.0
var r, g, b float64
if s == 0 {
r, g, b = l, l, l
} else {
var q float64
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
r = hueToRGB(p, q, h+1.0/3.0)
g = hueToRGB(p, q, h)
b = hueToRGB(p, q, h-1.0/3.0)
}
return uint8(clamp01(r) * 255), uint8(clamp01(g) * 255), uint8(clamp01(b) * 255)
}
func hueToRGB(p, q, t float64) float64 {
if t < 0 {
t += 1
}
if t > 1 {
t -= 1
}
if t < 1.0/6.0 {
return p + (q-p)*6*t
}
if t < 1.0/2.0 {
return q
}
if t < 2.0/3.0 {
return p + (q-p)*(2.0/3.0-t)*6
}
return p
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func loadEmojiImage(client *http.Client, cache *svgCache, surrogate string, size int) *image.RGBA {
codepoint := emojiToCodepoint(surrogate)
if svg := fetchTwemojiSVG(client, cache, codepoint); svg != nil {
if img, err := renderSVGToImage(*svg, size); err == nil {
return img
}
}
if strings.Contains(codepoint, "-200d-") {
basePart := strings.Split(codepoint, "-200d-")[0]
if svg := fetchTwemojiSVG(client, cache, basePart); svg != nil {
if img, err := renderSVGToImage(*svg, size); err == nil {
return img
}
}
}
fmt.Fprintf(os.Stderr, "Missing SVG for %s (%s), using placeholder\n", codepoint, surrogate)
return createPlaceholder(size)
}
func renderSpriteSheet(client *http.Client, cache *svgCache, emojiEntries []EmojiEntry, perRow int, fileNameBase string, outputDir string) error {
if perRow <= 0 {
return fmt.Errorf("perRow must be > 0")
}
rows := int(math.Ceil(float64(len(emojiEntries)) / float64(perRow)))
for _, scale := range SPRITE_SCALES {
size := EMOJI_SIZE * scale
dstW := perRow * size
dstH := rows * size
sheet := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for i, item := range emojiEntries {
emojiImg := loadEmojiImage(client, cache, item.Surrogates, size)
row := i / perRow
col := i % perRow
x := col * size
y := row * size
r := image.Rect(x, y, x+size, y+size)
draw.Draw(sheet, r, emojiImg, image.Point{}, draw.Over)
}
suffix := ""
if scale != 1 {
suffix = fmt.Sprintf("@%dx", scale)
}
outPath := filepath.Join(outputDir, fmt.Sprintf("%s%s.png", fileNameBase, suffix))
if err := writePNG(outPath, sheet); err != nil {
return err
}
fmt.Printf("Wrote %s\n", outPath)
}
return nil
}
func writePNG(path string, img image.Image) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}
// --- Generators ---
func generateMainSpriteSheet(client *http.Client, cache *svgCache, emojiData map[string][]EmojiObject, outputDir string) error {
base := make([]EmojiEntry, 0, 4096)
for _, objs := range emojiData {
for _, obj := range objs {
base = append(base, EmojiEntry{Surrogates: obj.Surrogates})
}
}
return renderSpriteSheet(client, cache, base, EMOJI_SPRITES.NonDiversityPerRow, "spritesheet-emoji", outputDir)
}
func generateDiversitySpriteSheets(client *http.Client, cache *svgCache, emojiData map[string][]EmojiObject, outputDir string) error {
skinTones := []string{"🏻", "🏼", "🏽", "🏾", "🏿"}
for skinIndex, skinTone := range skinTones {
skinCodepoint := emojiToCodepoint(skinTone)
skinEntries := make([]EmojiEntry, 0, 2048)
for _, objs := range emojiData {
for _, obj := range objs {
if len(obj.Skins) > skinIndex && obj.Skins[skinIndex].Surrogates != "" {
skinEntries = append(skinEntries, EmojiEntry{Surrogates: obj.Skins[skinIndex].Surrogates})
}
}
}
if len(skinEntries) == 0 {
continue
}
if err := renderSpriteSheet(client, cache, skinEntries, EMOJI_SPRITES.DiversityPerRow, "spritesheet-"+skinCodepoint, outputDir); err != nil {
return err
}
}
return nil
}
func generatePickerSpriteSheet(client *http.Client, cache *svgCache, outputDir string) error {
basicEmojis := []string{
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "😊", "😇",
"🙂", "😉", "😌", "😍", "🥰", "😘", "😗", "😙", "😚", "😋",
"😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓", "😎", "🥳", "😏",
}
entries := make([]EmojiEntry, 0, len(basicEmojis))
for _, e := range basicEmojis {
entries = append(entries, EmojiEntry{Surrogates: e})
}
return renderSpriteSheet(client, cache, entries, EMOJI_SPRITES.PickerPerRow, "spritesheet-picker", outputDir)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,279 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
type POFile struct {
HeaderLines []string
Entries []POEntry
}
type POEntry struct {
Comments []string
References []string
MsgID string
MsgStr string
}
func main() {
localesDir := flag.String("locales-dir", "../../../../src/locales", "Path to the locales directory")
singleLocale := flag.String("locale", "", "Reset only this locale (empty = all)")
dryRun := flag.Bool("dry-run", false, "Show what would be reset without making changes")
flag.Parse()
absLocalesDir, err := absPath(*localesDir)
if err != nil {
fmt.Printf("Failed to resolve locales directory: %v\n", err)
os.Exit(1)
}
locales, err := discoverLocales(absLocalesDir)
if err != nil {
fmt.Printf("Failed to discover locales: %v\n", err)
os.Exit(1)
}
var targetLocales []string
for _, locale := range locales {
if locale == "en-US" {
continue
}
if *singleLocale != "" && locale != *singleLocale {
continue
}
targetLocales = append(targetLocales, locale)
}
if len(targetLocales) == 0 {
fmt.Println("No target locales found")
os.Exit(1)
}
fmt.Printf("Resetting translations for %d locales...\n", len(targetLocales))
if *dryRun {
fmt.Println("(DRY RUN - no changes will be made)")
}
fmt.Println()
totalReset := 0
for _, locale := range targetLocales {
poPath := filepath.Join(absLocalesDir, locale, "messages.po")
poFile, err := parsePOFile(poPath)
if err != nil {
fmt.Printf(" ✗ %s: failed to parse: %v\n", locale, err)
continue
}
resetCount := 0
for i := range poFile.Entries {
if poFile.Entries[i].MsgStr != "" {
resetCount++
poFile.Entries[i].MsgStr = ""
}
}
if resetCount == 0 {
fmt.Printf(" - %s: already empty (0 strings)\n", locale)
continue
}
if !*dryRun {
if err := writePOFile(poPath, poFile); err != nil {
fmt.Printf(" ✗ %s: failed to write: %v\n", locale, err)
continue
}
}
fmt.Printf(" ✓ %s: reset %d strings\n", locale, resetCount)
totalReset += resetCount
}
fmt.Printf("\nTotal: reset %d translations across %d locales\n", totalReset, len(targetLocales))
if *dryRun {
fmt.Println("(DRY RUN - run without --dry-run to apply changes)")
}
}
func absPath(rel string) (string, error) {
if filepath.IsAbs(rel) {
return rel, nil
}
wd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Join(wd, rel), nil
}
func discoverLocales(localesDir string) ([]string, error) {
entries, err := os.ReadDir(localesDir)
if err != nil {
return nil, err
}
var locales []string
for _, entry := range entries {
if entry.IsDir() {
locales = append(locales, entry.Name())
}
}
sort.Strings(locales)
return locales, nil
}
func parsePOFile(path string) (POFile, error) {
file, err := os.Open(path)
if err != nil {
return POFile{}, err
}
defer file.Close()
var (
current []string
scanner = bufio.NewScanner(file)
trimmed string
headerSet bool
result POFile
)
for scanner.Scan() {
line := scanner.Text()
trimmed = strings.TrimSpace(line)
if trimmed == "" {
if len(current) > 0 {
entry := parseBlock(current)
if !headerSet && entry.MsgID == "" {
result.HeaderLines = append([]string{}, current...)
headerSet = true
} else {
result.Entries = append(result.Entries, entry)
}
current = nil
}
continue
}
current = append(current, line)
}
if len(current) > 0 {
entry := parseBlock(current)
if !headerSet && entry.MsgID == "" {
result.HeaderLines = append([]string{}, current...)
} else {
result.Entries = append(result.Entries, entry)
}
}
if err := scanner.Err(); err != nil {
return POFile{}, err
}
return result, nil
}
func parseBlock(lines []string) POEntry {
entry := POEntry{}
var (
inMsgID bool
inMsgStr bool
)
for _, raw := range lines {
line := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(line, "#."):
entry.Comments = append(entry.Comments, strings.TrimSpace(line[2:]))
case strings.HasPrefix(line, "#:"):
entry.References = append(entry.References, strings.TrimSpace(line[2:]))
case strings.HasPrefix(line, "msgid"):
entry.MsgID = parseQuoted(strings.TrimSpace(line[len("msgid"):]))
inMsgID = true
inMsgStr = false
case strings.HasPrefix(line, "msgstr"):
entry.MsgStr = parseQuoted(strings.TrimSpace(line[len("msgstr"):]))
inMsgStr = true
inMsgID = false
case strings.HasPrefix(line, "\""):
if inMsgID {
entry.MsgID += parseQuoted(line)
} else if inMsgStr {
entry.MsgStr += parseQuoted(line)
}
}
}
return entry
}
func parseQuoted(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if unquoted, err := strconv.Unquote(value); err == nil {
return unquoted
}
if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
return value[1 : len(value)-1]
}
return value
}
func writePOFile(path string, po POFile) error {
var lines []string
if len(po.HeaderLines) > 0 {
lines = append(lines, po.HeaderLines...)
lines = append(lines, "")
}
for idx, entry := range po.Entries {
lines = append(lines, renderEntry(entry))
if idx < len(po.Entries)-1 {
lines = append(lines, "")
}
}
lines = append(lines, "")
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
}
func renderEntry(entry POEntry) string {
var sb strings.Builder
for _, comment := range entry.Comments {
sb.WriteString("#. ")
sb.WriteString(comment)
sb.WriteString("\n")
}
for _, ref := range entry.References {
sb.WriteString("#: ")
sb.WriteString(ref)
sb.WriteString("\n")
}
sb.WriteString("msgid ")
sb.WriteString(strconv.Quote(entry.MsgID))
sb.WriteString("\nmsgstr ")
sb.WriteString(strconv.Quote(entry.MsgStr))
return sb.String()
}

View File

@@ -1,418 +0,0 @@
families:
neutralDark:
hue: 220
saturation: 13
useSaturationFactor: true
neutralLight:
hue: 210
saturation: 20
useSaturationFactor: true
brand:
hue: 242
saturation: 70
useSaturationFactor: true
link:
hue: 210
saturation: 100
useSaturationFactor: true
accentPurple:
hue: 270
saturation: 80
useSaturationFactor: true
statusOnline:
hue: 142
saturation: 76
useSaturationFactor: true
statusIdle:
hue: 45
saturation: 93
useSaturationFactor: true
statusDnd:
hue: 0
saturation: 84
useSaturationFactor: true
statusOffline:
hue: 218
saturation: 11
useSaturationFactor: true
statusDanger:
hue: 1
saturation: 77
useSaturationFactor: true
textCode:
hue: 340
saturation: 50
useSaturationFactor: true
brandIcon:
hue: 38
saturation: 92
useSaturationFactor: true
scales:
darkSurface:
family: neutralDark
range: [5, 26]
curve: easeOut
stops:
- { name: "--background-primary", position: 0 }
- { name: "--background-secondary", position: 0.16 }
- { name: "--background-secondary-lighter", position: 0.22 }
- { name: "--background-secondary-alt", position: 0.28 }
- { name: "--background-tertiary", position: 0.4 }
- { name: "--background-channel-header", position: 0.34 }
- { name: "--guild-list-foreground", position: 0.38 }
- { name: "--background-header-secondary", position: 0.5 }
- { name: "--background-header-primary", position: 0.5 }
- { name: "--background-textarea", position: 0.68 }
- { name: "--background-header-primary-hover", position: 0.85 }
coalSurface:
family: neutralDark
range: [1, 12]
curve: easeOut
stops:
- { name: "--background-primary", position: 0 }
- { name: "--background-secondary", position: 0.16 }
- { name: "--background-secondary-alt", position: 0.28 }
- { name: "--background-tertiary", position: 0.4 }
- { name: "--background-channel-header", position: 0.34 }
- { name: "--guild-list-foreground", position: 0.38 }
- { name: "--background-header-secondary", position: 0.5 }
- { name: "--background-header-primary", position: 0.5 }
- { name: "--background-textarea", position: 0.68 }
- { name: "--background-header-primary-hover", position: 0.85 }
darkText:
family: neutralDark
range: [52, 96]
curve: easeInOut
stops:
- { name: "--text-tertiary-secondary", position: 0 }
- { name: "--text-tertiary-muted", position: 0.2 }
- { name: "--text-tertiary", position: 0.38 }
- { name: "--text-primary-muted", position: 0.55 }
- { name: "--text-chat-muted", position: 0.55 }
- { name: "--text-secondary", position: 0.72 }
- { name: "--text-chat", position: 0.82 }
- { name: "--text-primary", position: 1 }
lightSurface:
family: neutralLight
range: [85, 99]
curve: easeIn
stops:
- { name: "--background-header-primary-hover", position: 0 }
- { name: "--background-header-primary", position: 0.12 }
- { name: "--background-header-secondary", position: 0.2 }
- { name: "--guild-list-foreground", position: 0.35 }
- { name: "--background-tertiary", position: 0.42 }
- { name: "--background-channel-header", position: 0.5 }
- { name: "--background-secondary-alt", position: 0.63 }
- { name: "--background-textarea", position: 0.88 }
- { name: "--background-primary", position: 1 }
lightText:
family: neutralLight
range: [15, 60]
curve: easeOut
stops:
- { name: "--text-primary", position: 0 }
- { name: "--text-chat", position: 0.08 }
- { name: "--text-secondary", position: 0.28 }
- { name: "--text-chat-muted", position: 0.45 }
- { name: "--text-primary-muted", position: 0.45 }
- { name: "--text-tertiary", position: 0.6 }
- { name: "--text-tertiary-secondary", position: 0.75 }
- { name: "--text-tertiary-muted", position: 0.85 }
tokens:
root:
- scale: darkSurface
- scale: darkText
- name: "--panel-control-bg"
value: |
color-mix(
in srgb,
var(--background-secondary-alt) 80%,
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
)
- { name: "--panel-control-border", family: neutralDark, saturation: 30, lightness: 65, alpha: 0.45 }
- { name: "--panel-control-divider", family: neutralDark, saturation: 30, lightness: 55, alpha: 0.35 }
- { name: "--panel-control-highlight", value: "hsla(0, 0%, 100%, 0.04)" }
- { name: "--background-modifier-hover", family: neutralDark, lightness: 100, alpha: 0.05 }
- { name: "--background-modifier-selected", family: neutralDark, lightness: 100, alpha: 0.1 }
- { name: "--background-modifier-accent", family: neutralDark, saturation: 13, lightness: 80, alpha: 0.15 }
- { name: "--background-modifier-accent-focus", family: neutralDark, saturation: 13, lightness: 80, alpha: 0.22 }
- { name: "--control-button-normal-bg", value: "transparent" }
- { name: "--control-button-normal-text", value: "var(--text-primary-muted)" }
- { name: "--control-button-hover-bg", family: neutralDark, lightness: 22 }
- { name: "--control-button-hover-text", value: "var(--text-primary)" }
- { name: "--control-button-active-bg", family: neutralDark, lightness: 24 }
- { name: "--control-button-active-text", value: "var(--text-primary)" }
- { name: "--control-button-danger-text", hue: 1, saturation: 77, useSaturationFactor: true, lightness: 60 }
- { name: "--control-button-danger-hover-bg", hue: 1, saturation: 77, useSaturationFactor: true, lightness: 20 }
- { name: "--brand-primary", family: brand, lightness: 55 }
- { name: "--brand-secondary", family: brand, saturation: 60, lightness: 49 }
- { name: "--brand-primary-light", family: brand, saturation: 100, lightness: 84 }
- { name: "--brand-primary-fill", hue: 0, saturation: 0, lightness: 100 }
- { name: "--status-online", family: statusOnline, lightness: 40 }
- { name: "--status-idle", family: statusIdle, lightness: 50 }
- { name: "--status-dnd", family: statusDnd, lightness: 60 }
- { name: "--status-offline", family: statusOffline, lightness: 65 }
- { name: "--status-danger", family: statusDanger, lightness: 55 }
- { name: "--status-warning", value: "var(--status-idle)" }
- { name: "--text-warning", family: statusIdle, lightness: 55 }
- { name: "--plutonium", value: "var(--brand-primary)" }
- { name: "--plutonium-hover", value: "var(--brand-secondary)" }
- { name: "--plutonium-text", value: "var(--text-on-brand-primary)" }
- { name: "--plutonium-icon", family: brandIcon, lightness: 50 }
- { name: "--invite-verified-icon-color", value: "var(--text-on-brand-primary)" }
- { name: "--text-link", family: link, lightness: 70 }
- { name: "--text-on-brand-primary", hue: 0, saturation: 0, lightness: 98 }
- { name: "--text-code", family: textCode, lightness: 90 }
- { name: "--text-selection", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35 }
- { name: "--markup-mention-text", value: "var(--text-link)" }
- name: "--markup-mention-fill"
value: "color-mix(in srgb, var(--text-link) 20%, transparent)"
- { name: "--markup-mention-border", family: link, lightness: 70, alpha: 0.3 }
- { name: "--markup-jump-link-text", value: "var(--text-link)" }
- name: "--markup-jump-link-fill"
value: "color-mix(in srgb, var(--text-link) 12%, transparent)"
- name: "--markup-jump-link-hover-fill"
value: "color-mix(in srgb, var(--text-link) 20%, transparent)"
- { name: "--markup-everyone-text", hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75 }
- name: "--markup-everyone-fill"
value: "color-mix(in srgb, hsl(250, calc(80% * var(--saturation-factor)), 75%) 18%, transparent)"
- { name: "--markup-everyone-border", hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75, alpha: 0.3 }
- { name: "--markup-here-text", hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70 }
- name: "--markup-here-fill"
value: "color-mix(in srgb, hsl(45, calc(90% * var(--saturation-factor)), 70%) 18%, transparent)"
- { name: "--markup-here-border", hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.3 }
- { name: "--markup-interactive-hover-text", value: "var(--text-link)" }
- name: "--markup-interactive-hover-fill"
value: "color-mix(in srgb, var(--text-link) 30%, transparent)"
- name: "--interactive-muted"
value: |
color-mix(
in oklab,
hsl(228, calc(10% * var(--saturation-factor)), 35%) 100%,
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
)
- name: "--interactive-active"
value: |
color-mix(
in oklab,
hsl(0, calc(0% * var(--saturation-factor)), 100%) 100%,
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
)
- { name: "--button-primary-fill", hue: 139, saturation: 55, useSaturationFactor: true, lightness: 44 }
- { name: "--button-primary-active-fill", hue: 136, saturation: 60, useSaturationFactor: true, lightness: 38 }
- { name: "--button-primary-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-secondary-fill", hue: 0, saturation: 0, lightness: 100, alpha: 0.1, useSaturationFactor: false }
- { name: "--button-secondary-active-fill", hue: 0, saturation: 0, lightness: 100, alpha: 0.15, useSaturationFactor: false }
- { name: "--button-secondary-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-secondary-active-text", value: "var(--button-secondary-text)" }
- { name: "--button-danger-fill", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 54 }
- { name: "--button-danger-active-fill", hue: 359, saturation: 65, useSaturationFactor: true, lightness: 45 }
- { name: "--button-danger-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-danger-outline-border", value: "1px solid hsl(359, calc(70% * var(--saturation-factor)), 54%)" }
- { name: "--button-danger-outline-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-danger-outline-active-fill", hue: 359, saturation: 65, useSaturationFactor: true, lightness: 48 }
- { name: "--button-danger-outline-active-border", value: "transparent" }
- { name: "--button-ghost-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-inverted-fill", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-inverted-text", hue: 0, saturation: 0, lightness: 0 }
- { name: "--button-outline-border", value: "1px solid hsla(0, 0%, 100%, 0.3)" }
- { name: "--button-outline-text", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-outline-active-fill", value: "hsla(0, 0%, 100%, 0.15)" }
- { name: "--button-outline-active-border", value: "1px solid hsla(0, 0%, 100%, 0.4)" }
- { name: "--theme-border", value: "transparent" }
- { name: "--theme-border-width", value: "0px" }
- { name: "--bg-primary", value: "var(--background-primary)" }
- { name: "--bg-secondary", value: "var(--background-secondary)" }
- { name: "--bg-tertiary", value: "var(--background-tertiary)" }
- { name: "--bg-hover", value: "var(--background-modifier-hover)" }
- { name: "--bg-active", value: "var(--background-modifier-selected)" }
- { name: "--bg-code", family: neutralDark, lightness: 15, alpha: 0.8 }
- { name: "--bg-code-block", value: "var(--background-secondary-alt)" }
- { name: "--bg-blockquote", value: "var(--background-secondary-alt)" }
- { name: "--bg-table-header", value: "var(--background-tertiary)" }
- { name: "--bg-table-row-odd", value: "var(--background-primary)" }
- { name: "--bg-table-row-even", value: "var(--background-secondary)" }
- { name: "--border-color", family: neutralDark, lightness: 50, alpha: 0.2 }
- { name: "--border-color-hover", family: neutralDark, lightness: 50, alpha: 0.3 }
- { name: "--border-color-focus", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45 }
- { name: "--accent-primary", value: "var(--brand-primary)" }
- { name: "--accent-success", value: "var(--status-online)" }
- { name: "--accent-warning", value: "var(--status-idle)" }
- { name: "--accent-danger", value: "var(--status-dnd)" }
- { name: "--accent-info", value: "var(--text-link)" }
- { name: "--accent-purple", family: accentPurple, lightness: 65 }
- { name: "--alert-note-color", family: link, lightness: 70 }
- { name: "--alert-tip-color", family: statusOnline, lightness: 45 }
- { name: "--alert-important-color", family: accentPurple, lightness: 65 }
- { name: "--alert-warning-color", family: statusIdle, lightness: 55 }
- { name: "--alert-caution-color", hue: 359, saturation: 75, useSaturationFactor: true, lightness: 60 }
- { name: "--shadow-sm", value: "0 1px 2px rgba(0, 0, 0, 0.1)" }
- { name: "--shadow-md", value: "0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1)" }
- { name: "--shadow-lg", value: "0 4px 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1)" }
- { name: "--shadow-xl", value: "0 10px 20px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1)" }
- { name: "--transition-fast", value: "100ms ease" }
- { name: "--transition-normal", value: "200ms ease" }
- { name: "--transition-slow", value: "300ms ease" }
- { name: "--spoiler-overlay-color", value: "rgba(0, 0, 0, 0.2)" }
- { name: "--spoiler-overlay-hover-color", value: "rgba(0, 0, 0, 0.3)" }
- { name: "--scrollbar-thumb-bg", value: "rgba(121, 122, 124, 0.4)" }
- { name: "--scrollbar-thumb-bg-hover", value: "rgba(121, 122, 124, 0.7)" }
- { name: "--scrollbar-track-bg", value: "transparent" }
- { name: "--user-area-divider-color", value: "color-mix(in srgb, var(--background-modifier-hover) 70%, transparent)" }
light:
- scale: lightSurface
- scale: lightText
- { name: "--background-secondary", value: "var(--background-primary)" }
- { name: "--background-secondary-lighter", value: "var(--background-secondary)" }
- { name: "--panel-control-bg", value: "color-mix(in srgb, var(--background-secondary) 65%, hsl(0, 0%, 100%) 35%)" }
- { name: "--panel-control-border", family: neutralLight, saturation: 25, lightness: 45, alpha: 0.25 }
- { name: "--panel-control-divider", family: neutralLight, saturation: 30, lightness: 35, alpha: 0.2 }
- { name: "--panel-control-highlight", value: "hsla(0, 0%, 100%, 0.65)" }
- { name: "--background-modifier-hover", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.04 }
- { name: "--background-modifier-selected", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.08 }
- { name: "--background-modifier-accent", family: neutralLight, saturation: 20, lightness: 40, alpha: 0.2 }
- { name: "--background-modifier-accent-focus", family: neutralLight, saturation: 20, lightness: 40, alpha: 0.3 }
- { name: "--control-button-normal-bg", value: "transparent" }
- { name: "--control-button-normal-text", family: neutralLight, lightness: 50 }
- { name: "--control-button-hover-bg", family: neutralLight, lightness: 88 }
- { name: "--control-button-hover-text", family: neutralLight, lightness: 20 }
- { name: "--control-button-active-bg", family: neutralLight, lightness: 85 }
- { name: "--control-button-active-text", family: neutralLight, lightness: 15 }
- { name: "--control-button-danger-text", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50 }
- { name: "--control-button-danger-hover-bg", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 95 }
- { name: "--text-link", family: link, lightness: 45 }
- { name: "--text-code", family: textCode, lightness: 45 }
- { name: "--text-selection", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.2 }
- { name: "--markup-mention-border", family: link, lightness: 45, alpha: 0.4 }
- name: "--markup-jump-link-fill"
value: "color-mix(in srgb, var(--text-link) 8%, transparent)"
- { name: "--markup-everyone-text", hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45 }
- name: "--markup-everyone-fill"
value: "color-mix(in srgb, hsl(250, calc(70% * var(--saturation-factor)), 45%) 12%, transparent)"
- { name: "--markup-everyone-border", hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45, alpha: 0.4 }
- { name: "--markup-here-text", hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40 }
- name: "--markup-here-fill"
value: "color-mix(in srgb, hsl(40, calc(85% * var(--saturation-factor)), 40%) 12%, transparent)"
- { name: "--markup-here-border", hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40, alpha: 0.4 }
- { name: "--status-online", family: statusOnline, saturation: 70, lightness: 40 }
- { name: "--status-idle", family: statusIdle, saturation: 90, lightness: 45 }
- { name: "--status-dnd", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50 }
- { name: "--status-offline", family: statusOffline, hue: 210, saturation: 10, lightness: 55 }
- { name: "--plutonium", value: "var(--brand-primary)" }
- { name: "--plutonium-hover", value: "var(--brand-secondary)" }
- { name: "--plutonium-text", value: "var(--text-on-brand-primary)" }
- { name: "--plutonium-icon", family: brandIcon, lightness: 45 }
- { name: "--invite-verified-icon-color", value: "var(--brand-primary)" }
- { name: "--border-color", family: neutralLight, lightness: 40, alpha: 0.15 }
- { name: "--border-color-hover", family: neutralLight, lightness: 40, alpha: 0.25 }
- { name: "--border-color-focus", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4 }
- { name: "--bg-primary", value: "var(--background-primary)" }
- { name: "--bg-secondary", value: "var(--background-secondary)" }
- { name: "--bg-tertiary", value: "var(--background-tertiary)" }
- { name: "--bg-hover", value: "var(--background-modifier-hover)" }
- { name: "--bg-active", value: "var(--background-modifier-selected)" }
- { name: "--bg-code", family: neutralLight, saturation: 22, lightness: 90, alpha: 0.9 }
- { name: "--bg-code-block", value: "var(--background-primary)" }
- { name: "--bg-blockquote", value: "var(--background-secondary-alt)" }
- { name: "--bg-table-header", value: "var(--background-tertiary)" }
- { name: "--bg-table-row-odd", value: "var(--background-primary)" }
- { name: "--bg-table-row-even", value: "var(--background-secondary)" }
- { name: "--alert-note-color", family: link, lightness: 45 }
- { name: "--alert-tip-color", hue: 150, saturation: 80, useSaturationFactor: true, lightness: 35 }
- { name: "--alert-important-color", family: accentPurple, lightness: 50 }
- { name: "--alert-warning-color", family: statusIdle, saturation: 90, lightness: 45 }
- { name: "--alert-caution-color", hue: 358, saturation: 80, useSaturationFactor: true, lightness: 50 }
- { name: "--spoiler-overlay-color", value: "rgba(0, 0, 0, 0.1)" }
- { name: "--spoiler-overlay-hover-color", value: "rgba(0, 0, 0, 0.15)" }
- { name: "--button-secondary-fill", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.1 }
- { name: "--button-secondary-active-fill", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.15 }
- { name: "--button-secondary-text", family: neutralLight, lightness: 15 }
- { name: "--button-secondary-active-text", family: neutralLight, lightness: 10 }
- { name: "--button-ghost-text", family: neutralLight, lightness: 20 }
- { name: "--button-inverted-fill", hue: 0, saturation: 0, lightness: 100 }
- { name: "--button-inverted-text", hue: 0, saturation: 0, lightness: 10 }
- { name: "--button-outline-border", value: "1px solid hsla(210, calc(20% * var(--saturation-factor)), 40%, 0.3)" }
- { name: "--button-outline-text", family: neutralLight, lightness: 20 }
- { name: "--button-outline-active-fill", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.1 }
- { name: "--button-outline-active-border", value: "1px solid hsla(210, calc(20% * var(--saturation-factor)), 40%, 0.5)" }
- { name: "--button-danger-outline-border", value: "1px solid hsl(359, calc(70% * var(--saturation-factor)), 50%)" }
- { name: "--button-danger-outline-text", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 45 }
- { name: "--button-danger-outline-active-fill", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50 }
- { name: "--user-area-divider-color", family: neutralLight, lightness: 40, alpha: 0.2 }
coal:
- scale: coalSurface
- { name: "--background-secondary", value: "var(--background-primary)" }
- { name: "--background-secondary-lighter", value: "var(--background-primary)" }
- name: "--panel-control-bg"
value: |
color-mix(
in srgb,
var(--background-primary) 90%,
hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
)
- { name: "--panel-control-border", family: neutralDark, saturation: 20, lightness: 30, alpha: 0.35 }
- { name: "--panel-control-divider", family: neutralDark, saturation: 20, lightness: 25, alpha: 0.28 }
- { name: "--panel-control-highlight", value: "hsla(0, 0%, 100%, 0.06)" }
- { name: "--background-modifier-hover", family: neutralDark, lightness: 100, alpha: 0.04 }
- { name: "--background-modifier-selected", family: neutralDark, lightness: 100, alpha: 0.08 }
- { name: "--background-modifier-accent", family: neutralDark, saturation: 10, lightness: 65, alpha: 0.18 }
- { name: "--background-modifier-accent-focus", family: neutralDark, saturation: 10, lightness: 70, alpha: 0.26 }
- { name: "--control-button-normal-bg", value: "transparent" }
- { name: "--control-button-normal-text", value: "var(--text-primary-muted)" }
- { name: "--control-button-hover-bg", family: neutralDark, lightness: 12 }
- { name: "--control-button-hover-text", value: "var(--text-primary)" }
- { name: "--control-button-active-bg", family: neutralDark, lightness: 14 }
- { name: "--control-button-active-text", value: "var(--text-primary)" }
- { name: "--scrollbar-thumb-bg", value: "rgba(160, 160, 160, 0.35)" }
- { name: "--scrollbar-thumb-bg-hover", value: "rgba(200, 200, 200, 0.55)" }
- { name: "--scrollbar-track-bg", value: "rgba(0, 0, 0, 0.45)" }
- { name: "--bg-primary", value: "var(--background-primary)" }
- { name: "--bg-secondary", value: "var(--background-secondary)" }
- { name: "--bg-tertiary", value: "var(--background-tertiary)" }
- { name: "--bg-hover", value: "var(--background-modifier-hover)" }
- { name: "--bg-active", value: "var(--background-modifier-selected)" }
- { name: "--bg-code", value: "hsl(220, calc(13% * var(--saturation-factor)), 8%)" }
- { name: "--bg-code-block", value: "var(--background-secondary-alt)" }
- { name: "--bg-blockquote", value: "var(--background-secondary)" }
- { name: "--bg-table-header", value: "var(--background-tertiary)" }
- { name: "--bg-table-row-odd", value: "var(--background-primary)" }
- { name: "--bg-table-row-even", value: "var(--background-secondary)" }
- { name: "--button-secondary-fill", value: "hsla(0, 0%, 100%, 0.04)" }
- { name: "--button-secondary-active-fill", value: "hsla(0, 0%, 100%, 0.07)" }
- { name: "--button-secondary-text", value: "var(--text-primary)" }
- { name: "--button-secondary-active-text", value: "var(--text-primary)" }
- { name: "--button-outline-border", value: "1px solid hsla(0, 0%, 100%, 0.08)" }
- { name: "--button-outline-active-fill", value: "hsla(0, 0%, 100%, 0.12)" }
- { name: "--button-outline-active-border", value: "1px solid hsla(0, 0%, 100%, 0.16)" }
- { name: "--user-area-divider-color", value: "color-mix(in srgb, var(--background-modifier-hover) 80%, transparent)" }

View File

@@ -1,42 +0,0 @@
module fluxer_app/scripts
go 1.25.5
require (
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.15
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2
github.com/google/uuid v1.6.0
github.com/schollz/progressbar/v3 v3.18.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
golang.org/x/sync v0.19.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.3.6 // indirect
)

View File

@@ -1,79 +0,0 @@
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.15 h1:Zn4SfxkULorRqLg/VhxQ5cg9bi8Qhq7Y8W9RUew15oI=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.15/go.mod h1:uFphWOp8hzgUQ6ORHAw2WUf2xeqOWHjhgCDSdAVxzp0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0=
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,47 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {writeFile} from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const allowedChannels = new Set(['stable', 'canary']);
const rawChannel = process.env.BUILD_CHANNEL?.toLowerCase() ?? '';
const channel = allowedChannels.has(rawChannel) ? rawChannel : 'stable';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const targetPath = path.resolve(scriptDir, '..', 'src-electron', 'common', 'build-channel.ts');
const fileContent = `/*
* This file is generated by scripts/set-build-channel.mjs.
* DO NOT EDIT MANUALLY.
*/
export type BuildChannel = 'stable' | 'canary';
const DEFAULT_BUILD_CHANNEL = '${channel}' as BuildChannel;
const envChannel = process.env.BUILD_CHANNEL?.toLowerCase();
export const BUILD_CHANNEL = (envChannel === 'canary' ? 'canary' : DEFAULT_BUILD_CHANNEL) as BuildChannel;
export const IS_CANARY = BUILD_CHANNEL === 'canary';
export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;
`;
await writeFile(targetPath, fileContent, 'utf8');
console.log(`Wrote ${targetPath} with channel '${channel}'`);

View File

@@ -0,0 +1,363 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {join} from 'node:path';
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
if (!OPENROUTER_API_KEY) {
console.error('Error: OPENROUTER_API_KEY environment variable is required');
process.exit(1);
}
const LOCALES_DIR = new URL('../src/locales', import.meta.url).pathname;
const SOURCE_LOCALE = 'en-US';
const BATCH_SIZE = 20;
const CONCURRENT_LOCALES = 10;
const CONCURRENT_BATCHES_PER_LOCALE = 3;
const LOCALE_NAMES = {
ar: 'Arabic',
bg: 'Bulgarian',
cs: 'Czech',
da: 'Danish',
de: 'German',
el: 'Greek',
'en-GB': 'British English',
'es-ES': 'Spanish (Spain)',
'es-419': 'Spanish (Latin America)',
fi: 'Finnish',
fr: 'French',
he: 'Hebrew',
hi: 'Hindi',
hr: 'Croatian',
hu: 'Hungarian',
id: 'Indonesian',
it: 'Italian',
ja: 'Japanese',
ko: 'Korean',
lt: 'Lithuanian',
nl: 'Dutch',
no: 'Norwegian',
pl: 'Polish',
'pt-BR': 'Portuguese (Brazil)',
ro: 'Romanian',
ru: 'Russian',
'sv-SE': 'Swedish',
th: 'Thai',
tr: 'Turkish',
uk: 'Ukrainian',
vi: 'Vietnamese',
'zh-CN': 'Chinese (Simplified)',
'zh-TW': 'Chinese (Traditional)',
};
function parsePo(content) {
const entries = [];
const lines = content.split('\n');
let currentEntry = null;
let currentField = null;
let isHeader = true;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('#. ')) {
if (!currentEntry) {
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
}
currentEntry.comments.push(line);
} else if (line.startsWith('#: ')) {
if (!currentEntry) {
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
}
currentEntry.references.push(line);
} else if (line.startsWith('msgid "')) {
if (!currentEntry) {
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
}
currentEntry.msgid = line.slice(7, -1);
currentField = 'msgid';
} else if (line.startsWith('msgstr "')) {
if (currentEntry) {
currentEntry.msgstr = line.slice(8, -1);
currentField = 'msgstr';
}
} else if (line.startsWith('"') && line.endsWith('"')) {
if (currentEntry && currentField) {
currentEntry[currentField] += line.slice(1, -1);
}
} else if (line === '' && currentEntry) {
if (isHeader && currentEntry.msgid === '') {
isHeader = false;
} else if (currentEntry.msgid !== '') {
entries.push(currentEntry);
}
currentEntry = null;
currentField = null;
}
}
if (currentEntry && currentEntry.msgid !== '') {
entries.push(currentEntry);
}
return entries;
}
function rebuildPo(content, translations) {
const translationMap = new Map(translations.map((t) => [t.msgid, t.msgstr]));
const normalized = content.replace(/\r\n/g, '\n');
const blocks = normalized.trimEnd().split(/\n{2,}/g);
const nextBlocks = blocks.map((block) => rebuildPoBlock(block, translationMap));
return `${nextBlocks.join('\n\n')}\n`;
}
function rebuildPoBlock(block, translationMap) {
const lines = block.split('\n');
const msgidRange = getFieldRange(lines, 'msgid');
const msgstrRange = getFieldRange(lines, 'msgstr');
if (!msgidRange || !msgstrRange) {
return block;
}
const hasReferences = lines.some((line) => line.startsWith('#: '));
const msgid = readFieldRawValue(lines, msgidRange);
if (!hasReferences && msgid === '') {
return block;
}
const currentMsgstr = readFieldRawValue(lines, msgstrRange);
if (currentMsgstr !== '') {
return block;
}
if (!translationMap.has(msgid)) {
return block;
}
const newMsgstr = translationMap.get(msgid);
const newMsgstrLine = `msgstr "${escapePo(newMsgstr)}"`;
return [...lines.slice(0, msgstrRange.startIndex), newMsgstrLine, ...lines.slice(msgstrRange.endIndex)].join('\n');
}
function getFieldRange(lines, field) {
const startIndex = lines.findIndex((line) => line.startsWith(`${field} `));
if (startIndex === -1) {
return null;
}
let endIndex = startIndex + 1;
while (endIndex < lines.length && lines[endIndex].startsWith('"') && lines[endIndex].endsWith('"')) {
endIndex++;
}
return {startIndex, endIndex};
}
function readFieldRawValue(lines, range) {
const firstLine = lines[range.startIndex];
const match = firstLine.match(/^[a-z]+\s+"(.*)"$/);
let value = match ? match[1] : '';
for (let i = range.startIndex + 1; i < range.endIndex; i++) {
value += lines[i].slice(1, -1);
}
return value;
}
function escapePo(str) {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t');
}
function unescapePo(str) {
return str.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
async function translateBatch(strings, targetLocale) {
const localeName = LOCALE_NAMES[targetLocale] || targetLocale;
const prompt = `You are a professional translator. Translate the following UI strings from English to ${localeName}.
CRITICAL RULES:
1. Preserve ALL placeholders exactly as they appear: {0}, {1}, {name}, {count}, etc.
2. Preserve ICU plural syntax exactly: {0, plural, one {...} other {...}}
3. Keep technical terms, brand names, and special characters intact
4. Match the tone and formality of a modern chat/messaging application
5. Return ONLY a JSON array of translated strings in the same order as input
6. Do NOT add any explanations or notes
Input strings (JSON array):
${JSON.stringify(strings, null, 2)}
Output (JSON array of translated strings only):`;
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://fluxer.dev',
'X-Title': 'Fluxer i18n Translation',
},
body: JSON.stringify({
model: 'openai/gpt-4o-mini',
messages: [{role: 'user', content: prompt}],
temperature: 0.3,
max_tokens: 4096,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenRouter API error: ${response.status} - ${error}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('Empty response from API');
}
const jsonMatch = content.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
throw new Error(`Failed to parse JSON from response: ${content}`);
}
const translations = JSON.parse(jsonMatch[0]);
if (translations.length !== strings.length) {
throw new Error(`Translation count mismatch: expected ${strings.length}, got ${translations.length}`);
}
return translations;
}
async function pMap(items, mapper, concurrency) {
const results = [];
const executing = new Set();
for (const [index, item] of items.entries()) {
const promise = Promise.resolve().then(() => mapper(item, index));
results.push(promise);
executing.add(promise);
const clean = () => executing.delete(promise);
promise.then(clean, clean);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
async function processLocale(locale) {
const poPath = join(LOCALES_DIR, locale, 'messages.po');
console.log(`[${locale}] Starting...`);
let content;
try {
content = readFileSync(poPath, 'utf-8');
} catch (error) {
console.error(`[${locale}] Error reading file: ${error.message}`);
return {locale, translated: 0, errors: 1};
}
const entries = parsePo(content);
const untranslated = entries.filter((e) => e.msgstr === '');
if (untranslated.length === 0) {
console.log(`[${locale}] No untranslated strings`);
return {locale, translated: 0, errors: 0};
}
console.log(`[${locale}] Found ${untranslated.length} untranslated strings`);
const batches = [];
for (let i = 0; i < untranslated.length; i += BATCH_SIZE) {
batches.push({
index: Math.floor(i / BATCH_SIZE),
total: Math.ceil(untranslated.length / BATCH_SIZE),
entries: untranslated.slice(i, i + BATCH_SIZE),
});
}
let errorCount = 0;
const allTranslations = [];
const batchResults = await pMap(
batches,
async (batch) => {
const batchStrings = batch.entries.map((e) => unescapePo(e.msgid));
try {
const translatedStrings = await translateBatch(batchStrings, locale);
console.log(`[${locale}] Batch ${batch.index + 1}/${batch.total} complete`);
return batch.entries.map((entry, j) => ({
msgid: entry.msgid,
msgstr: translatedStrings[j],
}));
} catch (error) {
console.error(`[${locale}] Batch ${batch.index + 1}/${batch.total} error: ${error.message}`);
errorCount++;
return [];
}
},
CONCURRENT_BATCHES_PER_LOCALE,
);
for (const translations of batchResults) {
allTranslations.push(...translations);
}
if (allTranslations.length > 0) {
const updatedContent = rebuildPo(content, allTranslations);
writeFileSync(poPath, updatedContent, 'utf-8');
console.log(`[${locale}] Updated ${allTranslations.length} translations`);
}
return {locale, translated: allTranslations.length, errors: errorCount};
}
async function main() {
console.log('Starting i18n translation...');
console.log(`Locales directory: ${LOCALES_DIR}`);
console.log(`Concurrency: ${CONCURRENT_LOCALES} locales, ${CONCURRENT_BATCHES_PER_LOCALE} batches per locale`);
const locales = readdirSync(LOCALES_DIR).filter((d) => d !== SOURCE_LOCALE && LOCALE_NAMES[d]);
console.log(`Found ${locales.length} locales to process\n`);
const startTime = Date.now();
const results = await pMap(locales, processLocale, CONCURRENT_LOCALES);
const totalTranslated = results.reduce((sum, r) => sum + (r?.translated || 0), 0);
const totalErrors = results.reduce((sum, r) => sum + (r?.errors || 0), 0);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\nTranslation complete in ${elapsed}s`);
console.log(`Total: ${totalTranslated} strings translated, ${totalErrors} errors`);
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});