feat: add fluxer upstream source and self-hosting documentation

- Clone of github.com/fluxerapp/fluxer (official upstream)
- SELF_HOSTING.md: full VM rebuild procedure, architecture overview,
  service reference, step-by-step setup, troubleshooting, seattle reference
- dev/.env.example: all env vars with secrets redacted and generation instructions
- dev/livekit.yaml: LiveKit config template with placeholder keys
- fluxer-seattle/: existing seattle deployment setup scripts
This commit is contained in:
Vish
2026-03-13 00:55:14 -07:00
parent 5ceda343b8
commit 3b9d759b4b
5859 changed files with 1923440 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,584 @@
/*
* 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 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 | 44 | 48 | 56 | 80 | 120;
interface StatusConfig {
statusSize: number;
cutoutRadius: number;
cutoutCenter: number;
}
const STATUS_CONFIG: Record<number, StatusConfig> = {
16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13},
20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17},
24: {statusSize: 10, cutoutRadius: 7, cutoutCenter: 20},
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},
120: {statusSize: 24, cutoutRadius: 20, cutoutCenter: 100},
};
const DESIGN_RULES = {
mobileAspectRatio: 0.75,
mobileCornerRadius: 0.12,
mobileScreenWidth: 0.72,
mobileScreenHeight: 0.7,
mobileScreenY: 0.06,
mobileWheelRadius: 0.13,
mobileWheelY: 0.83,
mobilePhoneExtraHeight: 2,
mobileDisplayExtraHeight: 2,
mobileDisplayExtraWidthPerSide: 2,
idle: {
cutoutRadiusRatio: 0.7,
cutoutOffsetRatio: 0.35,
},
dnd: {
barWidthRatio: 1.3,
barHeightRatio: 0.4,
minBarHeight: 2,
},
offline: {
innerRingRatio: 0.6,
},
} as const;
const MOBILE_SCREEN_WIDTH_TRIM_PX = 4;
const MOBILE_SCREEN_HEIGHT_TRIM_PX = 2;
const MOBILE_SCREEN_X_OFFSET_PX = 0;
const MOBILE_SCREEN_Y_OFFSET_PX = 3;
function getStatusConfig(avatarSize: number): StatusConfig {
if (STATUS_CONFIG[avatarSize]) {
return STATUS_CONFIG[avatarSize];
}
const sizes = Object.keys(STATUS_CONFIG)
.map(Number)
.sort((a, b) => a - b);
const closest = sizes.reduce((prev, curr) =>
Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev,
);
return STATUS_CONFIG[closest];
}
interface StatusGeometry {
size: number;
cx: number;
cy: number;
innerRadius: number;
outerRadius: number;
borderWidth: number;
}
interface MobileStatusGeometry extends StatusGeometry {
phoneWidth: number;
phoneHeight: number;
phoneX: number;
phoneY: number;
phoneRx: number;
bezelHeight: number;
}
function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry | MobileStatusGeometry {
const config = getStatusConfig(avatarSize);
const statusSize = config.statusSize;
const cutoutCenter = config.cutoutCenter;
const cutoutRadius = config.cutoutRadius;
const innerRadius = statusSize / 2;
const outerRadius = cutoutRadius;
const borderWidth = cutoutRadius - innerRadius;
const baseGeometry = {
size: statusSize,
cx: cutoutCenter,
cy: cutoutCenter,
innerRadius,
outerRadius,
borderWidth,
};
if (!isMobile) {
return baseGeometry;
}
const phoneWidth = statusSize;
const phoneHeight = Math.round(phoneWidth / DESIGN_RULES.mobileAspectRatio) + DESIGN_RULES.mobilePhoneExtraHeight;
const phoneRx = Math.round(phoneWidth * DESIGN_RULES.mobileCornerRadius);
const bezelHeight = Math.max(1, Math.round(phoneHeight * 0.05));
const phoneX = cutoutCenter - phoneWidth / 2;
const phoneY = cutoutCenter - phoneHeight / 2;
return {
...baseGeometry,
phoneWidth,
phoneHeight,
phoneX,
phoneY,
phoneRx,
bezelHeight,
};
}
function generateAvatarMaskDefault(size: number): string {
const r = size / 2;
return `<circle fill="white" cx="${r}" cy="${r}" r="${r}" />`;
}
function generateAvatarMaskStatusRound(size: number): string {
const r = size / 2;
const status = calculateStatusGeometry(size);
return `(
<>
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
</>
)`;
}
function generateAvatarMaskStatusTyping(size: number): string {
const r = size / 2;
const status = calculateStatusGeometry(size);
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
const typingHeight = status.size;
const typingRx = status.outerRadius;
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 `(
<>
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
<rect fill="black" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
</>
)`;
}
function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
const screenWidth =
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
displayExtraWidthPerSide * 2 -
MOBILE_SCREEN_WIDTH_TRIM_PX;
const screenHeight =
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
const screenX = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidth) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
const screenY =
mobileStatus.phoneY +
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
displayExtraHeight / 2 +
MOBILE_SCREEN_Y_OFFSET_PX;
const screenRx = Math.min(screenWidth, screenHeight) * 0.1;
const wheelRadius = mobileStatus.phoneWidth * DESIGN_RULES.mobileWheelRadius;
const wheelCx = mobileStatus.phoneX + mobileStatus.phoneWidth / 2;
const wheelCy = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileWheelY;
return `(
<>
<rect fill="white" x="${mobileStatus.phoneX}" y="${mobileStatus.phoneY}" width="${mobileStatus.phoneWidth}" height="${mobileStatus.phoneHeight}" rx="${mobileStatus.phoneRx}" ry="${mobileStatus.phoneRx}" />
<rect fill="black" x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}" rx="${screenRx}" ry="${screenRx}" />
<circle fill="black" cx="${wheelCx}" cy="${wheelCy}" r="${wheelRadius}" />
</>
)`;
}
function generateStatusOnline(size: number, isMobile: boolean = false): string {
const status = calculateStatusGeometry(size, isMobile);
if (!isMobile) {
return `<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />`;
}
return generateMobilePhoneMask(status as MobileStatusGeometry);
}
function generateStatusIdle(size: number, isMobile: boolean = false): string {
const status = calculateStatusGeometry(size, isMobile);
if (!isMobile) {
const cutoutRadius = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio);
const cutoutOffsetDistance = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio);
const cutoutCx = status.cx - cutoutOffsetDistance;
const cutoutCy = status.cy - cutoutOffsetDistance;
return `(
<>
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
<circle fill="black" cx="${cutoutCx}" cy="${cutoutCy}" r="${cutoutRadius}" />
</>
)`;
}
return generateMobilePhoneMask(status as MobileStatusGeometry);
}
function generateStatusDnd(size: number, isMobile: boolean = false): string {
const status = calculateStatusGeometry(size, isMobile);
if (!isMobile) {
const barWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio);
const rawBarHeight = status.outerRadius * DESIGN_RULES.dnd.barHeightRatio;
const barHeight = Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(rawBarHeight));
const barX = status.cx - barWidth / 2;
const barY = status.cy - barHeight / 2;
const barRx = barHeight / 2;
return `(
<>
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
<rect fill="black" x="${barX}" y="${barY}" width="${barWidth}" height="${barHeight}" rx="${barRx}" ry="${barRx}" />
</>
)`;
}
return generateMobilePhoneMask(status as MobileStatusGeometry);
}
function generateStatusOffline(size: number): string {
const status = calculateStatusGeometry(size);
const innerRadius = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio);
return `(
<>
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${innerRadius}" />
</>
)`;
}
function generateStatusTyping(size: number): string {
const status = calculateStatusGeometry(size);
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
const typingHeight = status.size;
const rx = status.outerRadius;
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, 44, 48, 56, 80, 120];
let output = `// @generated - DO NOT EDIT MANUALLY
// Run: pnpm generate:masks
type AvatarSize = ${SIZES.join(' | ')};
interface MaskDefinition {
viewBox: string;
content: React.ReactElement;
}
interface MaskSet {
avatarDefault: MaskDefinition;
avatarStatusRound: MaskDefinition;
avatarStatusTyping: MaskDefinition;
statusOnline: MaskDefinition;
statusOnlineMobile: MaskDefinition;
statusIdle: MaskDefinition;
statusIdleMobile: MaskDefinition;
statusDnd: MaskDefinition;
statusDndMobile: MaskDefinition;
statusOffline: MaskDefinition;
statusTyping: MaskDefinition;
}
export const AVATAR_MASKS: Record<AvatarSize, MaskSet> = {
`;
for (const size of SIZES) {
output += ` ${size}: {
avatarDefault: {
viewBox: '0 0 ${size} ${size}',
content: ${generateAvatarMaskDefault(size)},
},
avatarStatusRound: {
viewBox: '0 0 ${size} ${size}',
content: ${generateAvatarMaskStatusRound(size)},
},
avatarStatusTyping: {
viewBox: '0 0 ${size} ${size}',
content: ${generateAvatarMaskStatusTyping(size)},
},
statusOnline: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusOnline(size, false)},
},
statusOnlineMobile: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusOnline(size, true)},
},
statusIdle: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusIdle(size, false)},
},
statusIdleMobile: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusIdle(size, true)},
},
statusDnd: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusDnd(size, false)},
},
statusDndMobile: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusDnd(size, true)},
},
statusOffline: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusOffline(size)},
},
statusTyping: {
viewBox: '0 0 ${size} ${size}',
content: ${generateStatusTyping(size)},
},
},
`;
}
output += `} as const;
export const SVGMasks = () => (
<svg
viewBox="0 0 1 1"
aria-hidden={true}
style={{
position: 'absolute',
pointerEvents: 'none',
top: '-1px',
left: '-1px',
width: 1,
height: 1,
}}
>
<defs>
`;
for (const size of SIZES) {
const status = calculateStatusGeometry(size, false);
const mobileStatus = calculateStatusGeometry(size, true) as MobileStatusGeometry;
const cx = status.cx / size;
const cy = status.cy / size;
const r = status.outerRadius / size;
const idleCutoutR = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio) / size;
const idleCutoutOffset = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio) / size;
const idleCutoutCx = cx - idleCutoutOffset;
const idleCutoutCy = cy - idleCutoutOffset;
const dndBarWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio) / size;
const dndBarHeight =
Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(status.outerRadius * DESIGN_RULES.dnd.barHeightRatio)) / size;
const dndBarX = cx - dndBarWidth / 2;
const dndBarY = cy - dndBarHeight / 2;
const dndBarRx = dndBarHeight / 2;
const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / 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 + typingBridgeShift;
const typingY = cy - typingHeight / 2;
const typingRx = status.outerRadius / size;
const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size;
const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size;
const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size;
const cutoutPhoneY = (mobileStatus.phoneY - mobileStatus.borderWidth) / size;
const cutoutPhoneRx = (mobileStatus.phoneRx + mobileStatus.borderWidth) / size;
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
const screenWidthPx =
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
displayExtraWidthPerSide * 2 -
MOBILE_SCREEN_WIDTH_TRIM_PX;
const screenHeightPx =
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
const screenXpx = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidthPx) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
const screenYpx =
mobileStatus.phoneY +
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
displayExtraHeight / 2 +
MOBILE_SCREEN_Y_OFFSET_PX;
const screenRxPx = Math.min(screenWidthPx, screenHeightPx) * 0.1;
const mobileScreenX = ((screenXpx - mobileStatus.phoneX) / mobileStatus.phoneWidth).toFixed(4);
const mobileScreenY = ((screenYpx - mobileStatus.phoneY) / mobileStatus.phoneHeight).toFixed(4);
const mobileScreenWidth = (screenWidthPx / mobileStatus.phoneWidth).toFixed(4);
const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4);
const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
output += ` <mask id="svg-mask-avatar-default-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask>
<mask id="svg-mask-avatar-status-round-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="${cx}" cy="${cy}" r="${r}" />
</mask>
<mask id="svg-mask-avatar-status-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="${cutoutPhoneX}" y="${cutoutPhoneY}" width="${cutoutPhoneWidth}" height="${cutoutPhoneHeight}" rx="${cutoutPhoneRx}" ry="${cutoutPhoneRx}" />
</mask>
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
</mask>
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
</mask>
<mask id="svg-mask-status-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
<rect fill="black" x="${mobileScreenX}" y="${mobileScreenY}" width="${mobileScreenWidth}" height="${mobileScreenHeight}" rx="${mobileScreenRx}" ry="${mobileScreenRy}" />
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
</mask>
<mask id="svg-mask-status-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
<circle fill="black" cx="${idleCutoutCx}" cy="${idleCutoutCy}" r="${idleCutoutR}" />
</mask>
<mask id="svg-mask-status-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
<rect fill="black" x="${dndBarX}" y="${dndBarY}" width="${dndBarWidth}" height="${dndBarHeight}" rx="${dndBarRx}" ry="${dndBarRx}" />
</mask>
<mask id="svg-mask-status-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
<circle fill="black" cx="${cx}" cy="${cy}" r="${offlineInnerR}" />
</mask>
<mask id="svg-mask-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
</mask>
`;
}
output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask>
<mask id="svg-mask-status-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="0.25" cy="0.25" r="0.375" />
</mask>
<mask id="svg-mask-status-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<rect fill="black" x="0.125" y="0.375" width="0.75" height="0.25" rx="0.125" ry="0.125" />
</mask>
<mask id="svg-mask-status-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
<circle fill="black" cx="0.5" cy="0.5" r="0.25" />
</mask>
<mask id="svg-mask-status-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="0.5" ry="0.5" />
</mask>
<mask id="svg-mask-status-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
<rect fill="black" x="${((1 - DESIGN_RULES.mobileScreenWidth) / 2).toFixed(4)}" y="${DESIGN_RULES.mobileScreenY}" width="${DESIGN_RULES.mobileScreenWidth}" height="${(DESIGN_RULES.mobileScreenHeight * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" rx="0.04" ry="${(0.04 * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(3)}" />
</mask>
<mask id="svg-mask-avatar-default" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
</mask>
</defs>
</svg>
);
`;
const outputPath = path.join(__dirname, '../src/components/uikit/SVGMasks.tsx');
fs.writeFileSync(outputPath, output);
console.log(`Generated ${outputPath}`);
const layoutOutput = `// @generated - DO NOT EDIT MANUALLY
// Run: pnpm generate:masks
export interface StatusGeometry {
size: number;
cx: number;
cy: number;
radius: number;
borderWidth: number;
isMobile?: boolean;
phoneWidth?: number;
phoneHeight?: number;
}
const STATUS_GEOMETRY: Record<number, StatusGeometry> = {
${SIZES.map((size) => {
const geom = calculateStatusGeometry(size, false);
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: false}`;
}).join(',\n')},
};
const STATUS_GEOMETRY_MOBILE: Record<number, StatusGeometry> = {
${SIZES.map((size) => {
const geom = calculateStatusGeometry(size, true) as MobileStatusGeometry;
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: true, phoneWidth: ${geom.phoneWidth}, phoneHeight: ${geom.phoneHeight}}`;
}).join(',\n')},
};
export function getStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry {
const map = isMobile ? STATUS_GEOMETRY_MOBILE : STATUS_GEOMETRY;
if (map[avatarSize]) {
return map[avatarSize];
}
const closestSize = Object.keys(map)
.map(Number)
.reduce((prev, curr) => (Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev));
return map[closestSize];
}
`;
const layoutPath = path.join(__dirname, '../src/components/uikit/AvatarStatusGeometry.ts');
fs.writeFileSync(layoutPath, layoutOutput);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
/*
* 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 {buildServiceWorker} from './build/utils/ServiceWorker';
const isProduction = process.env.NODE_ENV === 'production';
buildServiceWorker(isProduction).catch((error) => {
console.error('Service worker build failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,88 @@
/*
* 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 * as path from 'node:path';
export const ROOT_DIR = path.resolve(import.meta.dirname, '..', '..');
export const SRC_DIR = path.join(ROOT_DIR, 'src');
export const DIST_DIR = path.join(ROOT_DIR, 'dist');
export const ASSETS_DIR = path.join(DIST_DIR, 'assets');
export const PKGS_DIR = path.join(ROOT_DIR, 'pkgs');
export const PUBLIC_DIR = path.join(ROOT_DIR, 'assets');
export const CDN_ENDPOINT = 'https://fluxerstatic.com';
export const DEV_PORT = 3000;
export const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.json', '.mjs', '.cjs'];
export const LOCALES = [
'ar',
'bg',
'cs',
'da',
'de',
'el',
'en-GB',
'en-US',
'es-419',
'es-ES',
'fi',
'fr',
'he',
'hi',
'hr',
'hu',
'id',
'it',
'ja',
'ko',
'lt',
'nl',
'no',
'pl',
'pt-BR',
'ro',
'ru',
'sv-SE',
'th',
'tr',
'uk',
'vi',
'zh-CN',
'zh-TW',
];
export const FILE_LOADERS: Record<string, 'file'> = {
'.woff': 'file',
'.woff2': 'file',
'.ttf': 'file',
'.eot': 'file',
'.png': 'file',
'.jpg': 'file',
'.jpeg': 'file',
'.gif': 'file',
'.webp': 'file',
'.ico': 'file',
'.mp3': 'file',
'.wav': 'file',
'.ogg': 'file',
'.mp4': 'file',
'.webm': 'file',
};

View File

@@ -0,0 +1,65 @@
/*
* 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/>.
*/
const EXTERNAL_MODULES = [
'@lingui/cli',
'@lingui/conf',
'cosmiconfig',
'jiti',
'node:*',
'crypto',
'path',
'fs',
'os',
'vm',
'perf_hooks',
'util',
'events',
'stream',
'buffer',
'child_process',
'cluster',
'dgram',
'dns',
'http',
'https',
'module',
'net',
'repl',
'tls',
'url',
'worker_threads',
'readline',
'zlib',
'resolve',
];
const EXTERNAL_PATTERNS = [/^node:.*/];
export class ExternalsPlugin {
apply(compiler) {
const existingExternals = compiler.options.externals || [];
const externalsArray = Array.isArray(existingExternals) ? existingExternals : [existingExternals];
compiler.options.externals = [...externalsArray, ...EXTERNAL_MODULES, ...EXTERNAL_PATTERNS];
}
}
export function externalsPlugin() {
return new ExternalsPlugin();
}

View File

@@ -0,0 +1,48 @@
/*
* 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 path from 'node:path';
import {fileURLToPath} from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function getLinguiSwcPluginConfig() {
return [
'@lingui/swc-plugin',
{
localeDir: 'src/locales/{locale}/messages',
runtimeModules: {
i18n: ['@lingui/core', 'i18n'],
trans: ['@lingui/react', 'Trans'],
},
stripNonEssentialFields: false,
},
];
}
export function createPoFileRule() {
return {
test: /\.po$/,
type: 'javascript/auto',
use: {
loader: path.join(__dirname, 'po-loader.mjs'),
},
};
}

View File

@@ -0,0 +1,164 @@
/*
* 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 fs from 'node:fs/promises';
export default function poLoader(source) {
const callback = this.async();
(async () => {
try {
this.cacheable?.();
const poPath = this.resourcePath;
const compiledPath = `${poPath}.mjs`;
this.addDependency?.(poPath);
try {
await fs.access(compiledPath);
this.addDependency?.(compiledPath);
const compiledSource = await fs.readFile(compiledPath, 'utf8');
callback(null, compiledSource);
return;
} catch {}
const content = Buffer.isBuffer(source) ? source.toString('utf8') : String(source);
const messages = parsePoFile(content);
const code = `export const messages = ${JSON.stringify(messages, null, 2)};\nexport default messages;\n`;
callback(null, code);
} catch (err) {
callback(err);
}
})();
}
function parsePoFile(content) {
const messages = {};
const entries = splitEntries(content);
for (const entry of entries) {
const parsed = parseEntry(entry);
if (!parsed) continue;
messages[parsed.key] = parsed.value;
}
return messages;
}
function splitEntries(content) {
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return normalized
.split(/\n{2,}/g)
.map((s) => s.trim())
.filter(Boolean);
}
function parseEntry(entry) {
const lines = entry.split('\n');
let msgctxt = null;
let msgid = null;
let msgidPlural = null;
const msgstrMap = new Map();
let active = null;
let activeMsgstrIndex = 0;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
if (line.startsWith('msgctxt ')) {
active = 'msgctxt';
activeMsgstrIndex = 0;
msgctxt = extractPoString(line.slice('msgctxt '.length));
continue;
}
if (line.startsWith('msgid_plural ')) {
active = 'msgidPlural';
activeMsgstrIndex = 0;
msgidPlural = extractPoString(line.slice('msgid_plural '.length));
continue;
}
if (line.startsWith('msgid ')) {
active = 'msgid';
activeMsgstrIndex = 0;
msgid = extractPoString(line.slice('msgid '.length));
continue;
}
const msgstrIndexed = line.match(/^msgstr\[(\d+)\]\s+/);
if (msgstrIndexed) {
active = 'msgstr';
activeMsgstrIndex = Number(msgstrIndexed[1]);
const rest = line.slice(msgstrIndexed[0].length);
msgstrMap.set(activeMsgstrIndex, extractPoString(rest));
continue;
}
if (line.startsWith('msgstr ')) {
active = 'msgstr';
activeMsgstrIndex = 0;
msgstrMap.set(0, extractPoString(line.slice('msgstr '.length)));
continue;
}
if (line.startsWith('"') && line.endsWith('"')) {
const part = extractPoString(line);
if (active === 'msgctxt') msgctxt = (msgctxt ?? '') + part;
else if (active === 'msgid') msgid = (msgid ?? '') + part;
else if (active === 'msgidPlural') msgidPlural = (msgidPlural ?? '') + part;
else if (active === 'msgstr') msgstrMap.set(activeMsgstrIndex, (msgstrMap.get(activeMsgstrIndex) ?? '') + part);
}
}
if (msgid == null) return null;
if (msgid === '') return null;
const key = msgctxt ? `${msgctxt}\u0004${msgid}` : msgid;
if (msgidPlural != null) {
const keys = Array.from(msgstrMap.keys());
const maxIndex = keys.length ? Math.max(...keys.map((n) => Number(n))) : 0;
const arr = [];
for (let i = 0; i <= maxIndex; i++) {
arr[i] = msgstrMap.get(i) ?? '';
}
return {key, value: arr};
}
return {key, value: msgstrMap.get(0) ?? ''};
}
function extractPoString(str) {
const trimmed = str.trim();
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return trimmed;
return trimmed.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}

View File

@@ -0,0 +1,122 @@
/*
* 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 {sources} from '@rspack/core';
function normalizeEndpoint(staticCdnEndpoint) {
if (!staticCdnEndpoint) return '';
return staticCdnEndpoint.endsWith('/') ? staticCdnEndpoint.slice(0, -1) : staticCdnEndpoint;
}
function generateManifest(staticCdnEndpointRaw) {
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
const manifest = {
name: 'Fluxer',
short_name: 'Fluxer',
description:
'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',
theme_color: '#4641D9',
background_color: '#2b2d31',
categories: ['social', 'communication'],
lang: 'en',
scope: '/',
icons: [
{
src: `${staticCdnEndpoint}/web/android-chrome-192x192.png`,
sizes: '192x192',
type: 'image/png',
purpose: 'maskable any',
},
{
src: `${staticCdnEndpoint}/web/android-chrome-512x512.png`,
sizes: '512x512',
type: 'image/png',
purpose: 'maskable any',
},
{
src: `${staticCdnEndpoint}/web/apple-touch-icon.png`,
sizes: '180x180',
type: 'image/png',
},
{
src: `${staticCdnEndpoint}/web/favicon-32x32.png`,
sizes: '32x32',
type: 'image/png',
},
{
src: `${staticCdnEndpoint}/web/favicon-16x16.png`,
sizes: '16x16',
type: 'image/png',
},
],
};
return JSON.stringify(manifest, null, 2);
}
function generateBrowserConfig(staticCdnEndpointRaw) {
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
return `<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="${staticCdnEndpoint}/web/mstile-150x150.png"/>
<TileColor>#4641D9</TileColor>
</tile>
</msapplication>
</browserconfig>`;
}
function generateRobotsTxt() {
return 'User-agent: *\nAllow: /\n';
}
export class StaticFilesPlugin {
constructor(options) {
this.staticCdnEndpoint = options?.staticCdnEndpoint ?? '';
}
apply(compiler) {
compiler.hooks.thisCompilation.tap('StaticFilesPlugin', (compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'StaticFilesPlugin',
stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
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()));
},
);
});
}
}
export function staticFilesPlugin(options) {
return new StaticFilesPlugin(options);
}

View File

@@ -0,0 +1,63 @@
/*
* 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 * as fs from 'node:fs';
import * as path from 'node:path';
import {fileURLToPath} from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default function wasmLoader(_source) {
const callback = this.async();
if (!callback) {
throw new Error('Async loader not supported');
}
const wasmPath = this.resourcePath;
fs.promises
.readFile(wasmPath)
.then((wasmContent) => {
const base64 = wasmContent.toString('base64');
const code = `
const wasmBase64 = "${base64}";
const wasmBinary = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
export default wasmBinary;
`;
callback(null, code);
})
.catch((err) => {
callback(err);
});
}
export function wasmModuleRule() {
return {
test: /\.wasm$/,
exclude: [/node_modules/],
type: 'javascript/auto',
use: [
{
loader: path.join(__dirname, 'wasm.mjs'),
},
],
};
}

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"lib": ["ES2023"],
"noEmit": true,
"moduleResolution": "Bundler",
"moduleDetection": "force",
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"jsx": "preserve",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@app_scripts/*": ["./../*"]
}
},
"include": ["./**/*.ts", "./**/*.tsx"]
}

View File

@@ -0,0 +1,82 @@
/*
* 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/>.
*/
declare module 'postcss' {
interface ProcessOptions {
from?: string;
to?: string;
map?: boolean | {inline?: boolean; prev?: boolean | string | object; annotation?: boolean | string};
parser?: unknown;
stringifier?: unknown;
syntax?: unknown;
}
interface Result {
css: string;
map?: unknown;
root: unknown;
processor: unknown;
messages: Array<unknown>;
opts: ProcessOptions;
}
interface Plugin {
postcssPlugin: string;
Once?(root: unknown, helpers: unknown): void | Promise<void>;
Root?(root: unknown, helpers: unknown): void | Promise<void>;
RootExit?(root: unknown, helpers: unknown): void | Promise<void>;
AtRule?(atRule: unknown, helpers: unknown): void | Promise<void>;
AtRuleExit?(atRule: unknown, helpers: unknown): void | Promise<void>;
Rule?(rule: unknown, helpers: unknown): void | Promise<void>;
RuleExit?(rule: unknown, helpers: unknown): void | Promise<void>;
Declaration?(declaration: unknown, helpers: unknown): void | Promise<void>;
DeclarationExit?(declaration: unknown, helpers: unknown): void | Promise<void>;
Comment?(comment: unknown, helpers: unknown): void | Promise<void>;
CommentExit?(comment: unknown, helpers: unknown): void | Promise<void>;
}
interface Processor {
process(css: string, options?: ProcessOptions): Promise<Result>;
}
function postcss(plugins?: Array<Plugin | ((options?: unknown) => Plugin)>): Processor;
export default postcss;
}
declare module 'postcss-modules' {
interface PostcssModulesOptions {
localsConvention?:
| 'camelCase'
| 'camelCaseOnly'
| 'dashes'
| 'dashesOnly'
| ((originalClassName: string, generatedClassName: string, inputFile: string) => string);
generateScopedName?: string | ((name: string, filename: string, css: string) => string);
getJSON?(cssFileName: string, json: Record<string, string>, outputFileName?: string): void;
hashPrefix?: string;
scopeBehaviour?: 'global' | 'local';
globalModulePaths?: Array<RegExp>;
root?: string;
}
function postcssModules(options?: PostcssModulesOptions): import('postcss').Plugin;
export default postcssModules;
}

View File

@@ -0,0 +1,75 @@
/*
* 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 * as fs from 'node:fs';
import * as path from 'node:path';
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)) {
return;
}
const files = await fs.promises.readdir(PUBLIC_DIR, {recursive: true});
for (const file of files) {
const srcPath = path.join(PUBLIC_DIR, file.toString());
const destPath = path.join(DIST_DIR, file.toString());
const stat = await fs.promises.stat(srcPath);
if (stat.isFile()) {
await fs.promises.mkdir(path.dirname(destPath), {recursive: true});
await fs.promises.copyFile(srcPath, destPath);
}
}
}
export async function copyWasmFiles(): Promise<void> {
const libfluxcoreDir = path.join(PKGS_DIR, 'libfluxcore');
const wasmFile = path.join(libfluxcoreDir, 'libfluxcore_bg.wasm');
if (fs.existsSync(wasmFile)) {
await fs.promises.copyFile(wasmFile, path.join(ASSETS_DIR, 'libfluxcore_bg.wasm'));
}
}
export async function removeUnusedCssAssets(assetsDir: string, keepFiles: Array<string>): Promise<void> {
if (!fs.existsSync(assetsDir)) {
return;
}
const keepNames = new Set<string>();
for (const file of keepFiles) {
const base = path.basename(file);
keepNames.add(base);
if (base.endsWith('.css')) {
keepNames.add(`${base}.map`);
}
}
const entries = await fs.promises.readdir(assetsDir);
for (const entry of entries) {
if (!entry.endsWith('.css') && !entry.endsWith('.css.map')) {
continue;
}
if (keepNames.has(entry)) {
continue;
}
await fs.promises.rm(path.join(assetsDir, entry), {force: true});
}
}

View File

@@ -0,0 +1,147 @@
/*
* 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 * 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';
const RESERVED_KEYWORDS = new Set([
'break',
'case',
'catch',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'export',
'extends',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'new',
'return',
'super',
'switch',
'this',
'throw',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
'enum',
'implements',
'interface',
'let',
'package',
'private',
'protected',
'public',
'static',
'await',
'class',
'const',
]);
function isValidIdentifier(name: string): boolean {
if (RESERVED_KEYWORDS.has(name)) {
return false;
}
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
}
function generateDtsContent(classNames: Record<string, string>): string {
const validClassNames = Object.keys(classNames).filter(isValidIdentifier);
const typeMembers = validClassNames.map((name) => `\treadonly ${name}: string;`).join('\n');
const defaultExportType =
validClassNames.length > 0 ? `{\n${typeMembers}\n\treadonly [key: string]: string;\n}` : 'Record<string, string>';
return `declare const styles: ${defaultExportType};\nexport default styles;\n`;
}
async function findCssModuleFiles(dir: string): Promise<Array<string>> {
const files: Array<string> = [];
async function walk(currentDir: string): Promise<void> {
const entries = await fs.promises.readdir(currentDir, {withFileTypes: true});
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git') {
await walk(fullPath);
}
} else if (entry.name.endsWith('.module.css')) {
files.push(fullPath);
}
}
}
await walk(dir);
return files;
}
async function generateDtsForFile(cssPath: string): Promise<void> {
const cssContent = await fs.promises.readFile(cssPath, 'utf-8');
let exportedClassNames: Record<string, string> = {};
await postcss([
postcssModules({
localsConvention: 'camelCaseOnly',
generateScopedName: '[name]__[local]___[hash:base64:5]',
getJSON(_cssFileName: string, json: Record<string, string>) {
exportedClassNames = json;
},
}),
]).process(cssContent, {from: cssPath});
const dtsPath = `${cssPath}.d.ts`;
const dtsContent = generateDtsContent(exportedClassNames);
await fs.promises.writeFile(dtsPath, dtsContent);
}
export async function generateCssDtsForFile(cssPath: string): Promise<void> {
if (!cssPath.endsWith('.module.css')) {
return;
}
await generateDtsForFile(cssPath);
}
export async function generateAllCssDts(): Promise<void> {
const srcFiles = await findCssModuleFiles(SRC_DIR);
const pkgsFiles = await findCssModuleFiles(PKGS_DIR);
const allFiles = [...srcFiles, ...pkgsFiles];
console.log(`Generating .d.ts files for ${allFiles.length} CSS modules...`);
await Promise.all(allFiles.map(generateDtsForFile));
console.log(`Generated ${allFiles.length} CSS module type definitions.`);
}

View File

@@ -0,0 +1,94 @@
/*
* 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 * as fs from 'node:fs';
import * as path from 'node:path';
import {ASSETS_DIR, CDN_ENDPOINT, ROOT_DIR} from '@app_scripts/build/Config';
interface BuildOutput {
mainScript: string | null;
cssFiles: Array<string>;
jsFiles: Array<string>;
cssBundleFile: string | null;
vendorScripts: Array<string>;
}
interface GenerateHtmlOptions {
buildOutput: BuildOutput;
production: boolean;
}
async function findCssModulesFile(): Promise<string | null> {
if (!fs.existsSync(ASSETS_DIR)) {
return null;
}
const files = await fs.promises.readdir(ASSETS_DIR);
const stylesFiles = files.filter((name) => name.startsWith('styles.') && name.endsWith('.css'));
if (stylesFiles.length === 0) {
return null;
}
let latestFile: string | null = null;
let latestMtime = 0;
for (const fileName of stylesFiles) {
const filePath = path.join(ASSETS_DIR, fileName);
const stats = await fs.promises.stat(filePath);
if (latestFile === null || stats.mtimeMs > latestMtime) {
latestFile = fileName;
latestMtime = stats.mtimeMs;
}
}
return latestFile ? `assets/${latestFile}` : null;
}
export async function generateHtml(options: GenerateHtmlOptions): Promise<string> {
const {buildOutput, production} = options;
const indexHtmlPath = path.join(ROOT_DIR, 'index.html');
let html = await fs.promises.readFile(indexHtmlPath, 'utf-8');
const baseUrl = production ? `${CDN_ENDPOINT}/` : '/';
const cssModulesFile = buildOutput.cssBundleFile ?? (await findCssModulesFile());
const cssFiles = cssModulesFile ? [cssModulesFile] : buildOutput.cssFiles;
const cssLinks = cssFiles.map((file) => `<link rel="stylesheet" href="${baseUrl}${file}">`).join('\n');
const crossOriginAttr = production && baseUrl.startsWith('http') ? ' crossorigin="anonymous"' : '';
const jsScripts = buildOutput.mainScript
? `<script type="module" src="${baseUrl}${buildOutput.mainScript}"${crossOriginAttr}></script>`
: '';
const buildScriptPreload = (file: string): string =>
`<link rel="preload" as="script" href="${baseUrl}${file}"${crossOriginAttr}>`;
const preloadScripts = [
...(buildOutput.vendorScripts ?? []).map(buildScriptPreload),
...buildOutput.jsFiles.filter((file) => !file.includes('messages')).map(buildScriptPreload),
].join('\n');
html = html.replace(/<script type="module" src="\/src\/index\.tsx"><\/script>/, jsScripts);
const headInsert = [cssLinks, preloadScripts].filter(Boolean).join('\n');
html = html.replace('</head>', `${headInsert}\n</head>`);
return html;
}

View File

@@ -0,0 +1,48 @@
/*
* 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 * as fs from 'node:fs';
import * as path from 'node:path';
import {RESOLVE_EXTENSIONS} from '@app_scripts/build/Config';
export function tryResolveWithExtensions(basePath: string): string | null {
if (fs.existsSync(basePath)) {
const stat = fs.statSync(basePath);
if (stat.isFile()) {
return basePath;
}
if (stat.isDirectory()) {
for (const ext of RESOLVE_EXTENSIONS) {
const indexPath = path.join(basePath, `index${ext}`);
if (fs.existsSync(indexPath)) {
return indexPath;
}
}
}
}
for (const ext of RESOLVE_EXTENSIONS) {
const withExt = `${basePath}${ext}`;
if (fs.existsSync(withExt)) {
return withExt;
}
}
return null;
}

View File

@@ -0,0 +1,37 @@
/*
* 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 * as path from 'node:path';
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
import * as esbuild from 'esbuild';
export async function buildServiceWorker(production: boolean): Promise<void> {
await esbuild.build({
entryPoints: [path.join(SRC_DIR, 'service_worker', 'Worker.tsx')],
bundle: true,
format: 'iife',
outfile: path.join(DIST_DIR, 'sw.js'),
minify: production,
sourcemap: true,
target: 'esnext',
define: {
__WB_MANIFEST: '[]',
},
});
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath);
return true;
} catch {
return false;
}
}
async function traverseDir(dir: string, callback: (filePath: string) => Promise<void>): Promise<void> {
const entries = await fs.promises.readdir(dir, {withFileTypes: true});
await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await traverseDir(entryPath, callback);
return;
}
await callback(entryPath);
}),
);
}
export async function cleanEmptySourceMaps(dir: string): Promise<void> {
if (!(await fileExists(dir))) {
return;
}
await traverseDir(dir, async (filePath) => {
if (!filePath.endsWith('.js.map')) {
return;
}
let parsed: unknown;
try {
const raw = await fs.promises.readFile(filePath, 'utf-8');
parsed = JSON.parse(raw);
} catch {
return;
}
if (typeof parsed !== 'object' || parsed === null) {
return;
}
const sources = (parsed as {sources?: Array<unknown>}).sources ?? [];
if (Array.isArray(sources) && sources.length > 0) {
return;
}
await fs.promises.rm(filePath, {force: true});
const jsPath = filePath.slice(0, -4);
if (!(await fileExists(jsPath))) {
return;
}
const jsContent = await fs.promises.readFile(jsPath, 'utf-8');
const cleaned = jsContent.replace(/(?:\r?\n)?\/\/# sourceMappingURL=.*$/, '');
if (cleaned !== jsContent) {
await fs.promises.writeFile(jsPath, cleaned);
}
});
}

View File

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