refactor progress
This commit is contained in:
364
fluxer_app/scripts/DevServer.tsx
Normal file
364
fluxer_app/scripts/DevServer.tsx
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
680
fluxer_app/scripts/GenerateColorSystem.tsx
Normal file
680
fluxer_app/scripts/GenerateColorSystem.tsx
Normal 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();
|
||||
315
fluxer_app/scripts/GenerateEmojiSprites.tsx
Normal file
315
fluxer_app/scripts/GenerateEmojiSprites.tsx
Normal 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);
|
||||
});
|
||||
86
fluxer_app/scripts/auto-i18n.mjs
Normal file
86
fluxer_app/scripts/auto-i18n.mjs
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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()));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
@@ -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',
|
||||
@@ -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;
|
||||
@@ -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)) {
|
||||
@@ -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'),
|
||||
@@ -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';
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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)" }
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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}'`);
|
||||
363
fluxer_app/scripts/translate-i18n.mjs
Normal file
363
fluxer_app/scripts/translate-i18n.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user