workspace: reconcile latest Fluxer into the open source release and continue development there

This commit is contained in:
Hampus Kraft
2026-06-16 06:41:37 +02:00
parent ee1f27fe1a
commit fa3e852575
19032 changed files with 10716201 additions and 1865276 deletions
+425
View File
@@ -0,0 +1,425 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {mkdir, readFile, writeFile} from 'node:fs/promises';
import os from 'node:os';
import {performance} from 'node:perf_hooks';
import {isMainThread, parentPort, Worker, workerData} from 'node:worker_threads';
import {encode as encodePng} from 'fast-png';
import {encode as encodeJpeg} from 'jpeg-js';
import {
assemble_apng_frames,
assemble_gif_frame_chunks,
crop_and_rotate_apng,
crop_and_rotate_gif,
crop_and_rotate_image,
crop_rotate_rgba,
decode_apng_frames,
decode_gif_frames,
encode_apng_frame_payload,
encode_apng_frames,
encode_gif_frame_chunk,
encode_gif_frames,
initSync,
is_animated_image,
} from '../pkgs/libfluxcore/libfluxcore.js';
const wasmUrl = new URL('../pkgs/libfluxcore/libfluxcore_bg.wasm', import.meta.url);
const mediaCacheDir = new URL('../.cache/libfluxcore-bench-media/', import.meta.url);
const workerCount = Math.max(1, Math.min(4, os.availableParallelism?.() ?? os.cpus().length ?? 1));
const profile = process.argv.find((arg) => arg.startsWith('--profile='))?.slice('--profile='.length) ?? 'standard';
const downloadRealMediaOnly = process.argv.includes('--download-realmedia');
const offlineRealMedia = process.argv.includes('--offline') || process.env.FLUXCORE_BENCH_OFFLINE === '1';
const userAgent = 'Fluxer libfluxcore benchmark (https://fluxer.app)';
const realMediaAssets = [
{
id: 'jpeg-fronalpstock',
format: 'jpeg',
fileName: 'fronalpstock_big.jpg',
expectedBytes: 14_679_474,
url: 'https://upload.wikimedia.org/wikipedia/commons/3/3f/Fronalpstock_big.jpg',
source: 'https://commons.wikimedia.org/wiki/File:Fronalpstock_big.jpg',
},
{
id: 'png-snr-demo',
format: 'png',
fileName: 'snr_image_demonstration.png',
expectedBytes: 1_146_718,
url: 'https://upload.wikimedia.org/wikipedia/commons/f/f9/SNR_image_demonstration.png',
source: 'https://commons.wikimedia.org/wiki/File:SNR_image_demonstration.png',
},
{
id: 'gif-gerridae',
format: 'gif',
fileName: 'gerridae_1200x675.gif',
expectedBytes: 6_318_395,
url: 'https://upload.wikimedia.org/wikipedia/commons/f/f0/%22%2Barya%2B%22_Gerridae_-_Anggang_kayak_-_laba-laba_air_-_Lengkongwetan_2020_1.gif',
source:
'https://commons.wikimedia.org/wiki/File:%22%2Barya%2B%22_Gerridae_-_Anggang_kayak_-_laba-laba_air_-_Lengkongwetan_2020_1.gif',
},
{
id: 'apng-human-male',
format: 'apng',
fileName: '201803_human_male_anim.png',
expectedBytes: 8_150_411,
url: 'https://upload.wikimedia.org/wikipedia/commons/c/c7/201803_Human_Male_anim.png',
source: 'https://commons.wikimedia.org/wiki/File:201803_Human_Male_anim.png',
},
{
id: 'webp-samsung-note',
format: 'webp',
fileName: 'samsung_galaxy_note.webp',
expectedBytes: 7_326_306,
url: 'https://upload.wikimedia.org/wikipedia/commons/5/59/Samsung_Galaxy_Note.WebP',
source: 'https://commons.wikimedia.org/wiki/File:Samsung_Galaxy_Note.WebP',
},
{
id: 'avif-hato',
format: 'avif',
fileName: 'hato.profile0.8bpc.yuv420.avif',
expectedBytes: 259_104,
url: 'https://raw.githubusercontent.com/link-u/avif-sample-images/master/hato.profile0.8bpc.yuv420.avif',
source: 'https://github.com/link-u/avif-sample-images',
},
];
function initWasm(bytes) {
initSync({module: bytes});
}
function makeFrame(width, height, seed) {
const rgba = new Uint8Array(width * height * 4);
for (let index = 0, pixel = 0; index < rgba.length; index += 4, pixel += 1) {
const x = pixel % width;
const y = Math.floor(pixel / width);
rgba[index] = (x * 3 + seed * 17) & 0xff;
rgba[index + 1] = (y * 5 + seed * 29) & 0xff;
rgba[index + 2] = ((x ^ y) + seed * 11) & 0xff;
rgba[index + 3] = 255;
}
return {rgba, width, height, delayMs: 40};
}
function batches(frames, count) {
const out = Array.from({length: count}, () => []);
for (let index = 0; index < frames.length; index += 1) out[index % count].push({frame: frames[index], index});
return out.filter((batch) => batch.length > 0);
}
async function encodeInWorkers(kind, frames, wasmBytes, options = {}) {
const jobs = batches(frames, Math.min(workerCount, frames.length));
const workers = jobs.map(() => new Worker(new URL(import.meta.url), {workerData: {wasmBytes}}));
try {
const results = await Promise.all(
jobs.map(
(batch, index) =>
new Promise((resolve, reject) => {
const worker = workers[index];
const payload = batch.map(({frame, index: frameIndex}) => ({
frame: {
rgba: options.transferFrames ? frame.rgba : frame.rgba.slice(),
width: frame.width,
height: frame.height,
delayMs: frame.delayMs,
},
index: frameIndex,
}));
worker.once('message', (message) => {
if (message.ok) resolve(message.frames);
else reject(new Error(message.error || 'worker encode failed'));
});
worker.once('error', reject);
worker.postMessage(
{kind, frames: payload},
payload.map(({frame}) => frame.rgba.buffer),
);
}),
),
);
const ordered = new Array(frames.length);
for (const result of results) {
for (const item of result) ordered[item.index] = item.frame;
}
if (kind === 'apng') return assemble_apng_frames(ordered);
return assemble_gif_frame_chunks(ordered);
} finally {
await Promise.allSettled(workers.map((worker) => worker.terminate()));
}
}
async function transformInWorkers(frames, cropParams, wasmBytes) {
const jobs = batches(frames, Math.min(workerCount, frames.length));
const workers = jobs.map(() => new Worker(new URL(import.meta.url), {workerData: {wasmBytes}}));
try {
const results = await Promise.all(
jobs.map(
(batch, index) =>
new Promise((resolve, reject) => {
const worker = workers[index];
const payload = batch.map(({frame, index: frameIndex}) => ({
frame: {
rgba: frame.rgba,
width: frame.width,
height: frame.height,
delayMs: frame.delayMs,
},
index: frameIndex,
}));
worker.once('message', (message) => {
if (message.ok) resolve(message.frames);
else reject(new Error(message.error || 'worker transform failed'));
});
worker.once('error', reject);
worker.postMessage(
{kind: 'transform', frames: payload, cropParams},
payload.map(({frame}) => frame.rgba.buffer),
);
}),
),
);
const ordered = new Array(frames.length);
for (const result of results) {
for (const item of result) ordered[item.index] = item.frame;
}
return ordered;
} finally {
await Promise.allSettled(workers.map((worker) => worker.terminate()));
}
}
function benchSync(label, iterations, fn) {
fn();
const start = performance.now();
let result;
for (let index = 0; index < iterations; index += 1) result = fn();
const elapsed = performance.now() - start;
const bytes = result?.byteLength ?? result?.rgba?.byteLength ?? 0;
console.log(`${label}: ${(elapsed / iterations).toFixed(2)} ms/op (${iterations} iters, ${bytes} bytes last result)`);
}
async function benchAsync(label, iterations, fn) {
await fn();
const start = performance.now();
let result;
for (let index = 0; index < iterations; index += 1) result = await fn();
const elapsed = performance.now() - start;
const bytes = result?.byteLength ?? 0;
console.log(`${label}: ${(elapsed / iterations).toFixed(2)} ms/op (${iterations} iters, ${bytes} bytes last result)`);
}
function benchOnce(label, fn) {
const start = performance.now();
const result = fn();
const elapsed = performance.now() - start;
const bytes = result?.byteLength ?? result?.rgba?.byteLength ?? 0;
console.log(`${label}: ${elapsed.toFixed(2)} ms (${bytes} bytes result)`);
return result;
}
async function benchOnceAsync(label, fn) {
const start = performance.now();
const result = await fn();
const elapsed = performance.now() - start;
const bytes = result?.byteLength ?? result?.rgba?.byteLength ?? 0;
console.log(`${label}: ${elapsed.toFixed(2)} ms (${bytes} bytes result)`);
return result;
}
function assertExpectedAssetSize(asset, bytes) {
if (asset.expectedBytes != null && bytes.byteLength !== asset.expectedBytes) {
throw new Error(`${asset.id} expected ${asset.expectedBytes} bytes, got ${bytes.byteLength}`);
}
}
async function readOrDownloadAsset(asset, options = {}) {
await mkdir(mediaCacheDir, {recursive: true});
const fileUrl = new URL(asset.fileName, mediaCacheDir);
try {
const data = await readFile(fileUrl);
assertExpectedAssetSize(asset, data);
console.log(`asset ${asset.id}: cache hit (${data.byteLength} bytes)`);
return data;
} catch (error) {
if (options.offline) {
throw new Error(
`missing or invalid local media asset ${asset.fileName}; run pnpm wasm:bench:download-realmedia`,
{cause: error},
);
}
}
const response = await fetch(asset.url, {headers: {'User-Agent': userAgent}});
if (!response.ok) throw new Error(`failed to download ${asset.id}: HTTP ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
assertExpectedAssetSize(asset, bytes);
await writeFile(fileUrl, bytes);
console.log(`asset ${asset.id}: downloaded ${bytes.byteLength} bytes from ${asset.source}`);
return bytes;
}
async function loadRealMediaAssets(options = {}) {
const entries = await Promise.all(
realMediaAssets.map(async (asset) => [asset.id, {...asset, bytes: await readOrDownloadAsset(asset, options)}]),
);
return new Map(entries);
}
async function runStandardProfile(wasmBytes) {
const cropSource = makeFrame(1024, 1024, 1);
benchSync('crop_rotate_rgba 1024x1024 -> 512x512 rotate90', 60, () =>
crop_rotate_rgba(cropSource.rgba, cropSource.width, cropSource.height, 64, 64, 768, 768, 90, 512, 512),
);
const apngFrames = Array.from({length: 24}, (_, index) => makeFrame(320, 320, index));
benchSync('encode_apng_frames serial 24x320', 4, () => encode_apng_frames(apngFrames));
await benchAsync('encode_apng_frames worker payloads 24x320', 4, () =>
encodeInWorkers('apng', apngFrames, wasmBytes),
);
const gifFrames = Array.from({length: 16}, (_, index) => makeFrame(192, 192, index));
benchSync('encode_gif_frames serial 16x192', 3, () => encode_gif_frames(gifFrames));
await benchAsync('encode_gif_frames worker chunks 16x192', 3, () => encodeInWorkers('gif', gifFrames, wasmBytes));
}
async function runHighResProfile(wasmBytes) {
const crop4k = makeFrame(3840, 2160, 11);
benchSync('crop_rotate_rgba 4K -> 1080p rotate90', 10, () =>
crop_rotate_rgba(crop4k.rgba, crop4k.width, crop4k.height, 420, 120, 3000, 1800, 90, 1080, 1920),
);
const staticFrame = makeFrame(1920, 1080, 17);
const pngSource = encodePng({
width: staticFrame.width,
height: staticFrame.height,
data: staticFrame.rgba,
depth: 8,
channels: 4,
});
const jpegSource = encodeJpeg(
{width: staticFrame.width, height: staticFrame.height, data: staticFrame.rgba},
88,
).data;
benchSync('crop_and_rotate_image PNG 1080p -> PNG 720p', 3, () =>
crop_and_rotate_image(pngSource, 'png', 160, 90, 1600, 900, 0, 1280, 720),
);
benchSync('crop_and_rotate_image JPEG 1080p -> JPEG 720p', 3, () =>
crop_and_rotate_image(jpegSource, 'jpeg', 160, 90, 1600, 900, 0, 1280, 720),
);
const apngFrames = Array.from({length: 6}, (_, index) => makeFrame(1920, 1080, index + 31));
benchSync('encode_apng_frames serial 6x1080p', 1, () => encode_apng_frames(apngFrames));
await benchAsync('encode_apng_frames worker payloads 6x1080p', 1, () =>
encodeInWorkers('apng', apngFrames, wasmBytes),
);
const gifFrames = Array.from({length: 6}, (_, index) => makeFrame(1280, 720, index + 51));
benchSync('encode_gif_frames serial 6x720p', 1, () => encode_gif_frames(gifFrames));
await benchAsync('encode_gif_frames worker chunks 6x720p', 1, () => encodeInWorkers('gif', gifFrames, wasmBytes));
}
async function runRealMediaProfile(wasmBytes) {
const assets = await loadRealMediaAssets({offline: offlineRealMedia});
const jpeg = assets.get('jpeg-fronalpstock').bytes;
const png = assets.get('png-snr-demo').bytes;
const gif = assets.get('gif-gerridae').bytes;
const apng = assets.get('apng-human-male').bytes;
const webp = assets.get('webp-samsung-note').bytes;
const avif = assets.get('avif-hato').bytes;
benchOnce('real JPEG crop Fronalpstock 10109x4542 -> JPEG 1920x1080', () =>
crop_and_rotate_image(jpeg, 'jpeg', 1000, 400, 8000, 3600, 0, 1920, 1080),
);
benchOnce('real PNG crop SNR demo 3840x2880 -> PNG 1280x720', () =>
crop_and_rotate_image(png, 'png', 320, 240, 3200, 1800, 0, 1280, 720),
);
benchOnce('real APNG serial crop 1920x1920x22 -> 720x720', () =>
crop_and_rotate_apng(apng, 240, 240, 1440, 1440, 0, 720, 720),
);
await benchOnceAsync('real APNG worker-style crop 1920x1920x22 -> 720x720', async () => {
const frames = decode_apng_frames(apng);
const transformed = await transformInWorkers(
frames,
{x: 240, y: 240, width: 1440, height: 1440, rotation: 0, resizeWidth: 720, resizeHeight: 720},
wasmBytes,
);
return encodeInWorkers('apng', transformed, wasmBytes, {transferFrames: true});
});
benchOnce('real GIF serial crop 1200x675x13 -> 854x480', () =>
crop_and_rotate_gif(gif, 80, 45, 1040, 585, 0, 854, 480),
);
await benchOnceAsync('real GIF worker-style crop 1200x675x13 -> 854x480', async () => {
const frames = decode_gif_frames(gif);
const transformed = await transformInWorkers(
frames,
{x: 80, y: 45, width: 1040, height: 585, rotation: 0, resizeWidth: 854, resizeHeight: 480},
wasmBytes,
);
return encodeInWorkers('gif', transformed, wasmBytes, {transferFrames: true});
});
benchSync('real WebP animated detection 4032x3024', 1000, () => (is_animated_image(webp) ? webp : webp));
benchSync('real AVIF animated detection 3082x2048', 1000, () => (is_animated_image(avif) ? avif : avif));
console.log(
'WebP and AVIF decode/crop are routed through browser ImageDecoder or the native media bridge, not this Node wasm loader.',
);
}
if (!isMainThread) {
initWasm(workerData.wasmBytes);
parentPort.on('message', (message) => {
try {
if (message.kind === 'apng') {
const frames = message.frames.map(({frame, index}) => ({
frame: encode_apng_frame_payload(frame),
index,
}));
parentPort.postMessage(
{ok: true, frames},
frames.map(({frame}) => frame.compressed.buffer),
);
return;
}
if (message.kind === 'transform') {
const frames = message.frames.map(({frame, index}) => {
const transformed = crop_rotate_rgba(
frame.rgba,
frame.width,
frame.height,
message.cropParams.x,
message.cropParams.y,
message.cropParams.width,
message.cropParams.height,
message.cropParams.rotation,
message.cropParams.resizeWidth,
message.cropParams.resizeHeight,
);
return {frame: {...transformed, delayMs: frame.delayMs}, index};
});
parentPort.postMessage(
{ok: true, frames},
frames.map(({frame}) => frame.rgba.buffer),
);
return;
}
const frames = message.frames.map(({frame, index}) => ({
frame: encode_gif_frame_chunk(frame, index === 0),
index,
}));
parentPort.postMessage(
{ok: true, frames},
frames.map(({frame}) => frame.data.buffer),
);
} catch (error) {
parentPort.postMessage({ok: false, error: error instanceof Error ? error.message : String(error)});
}
});
} else {
if (downloadRealMediaOnly) {
await loadRealMediaAssets({offline: false});
console.log(`real media assets are local in ${mediaCacheDir.pathname}`);
process.exit(0);
}
const wasmBytes = await readFile(wasmUrl);
initWasm(wasmBytes);
console.log(
`libfluxcore benchmark (${workerCount} encode workers, profile=${profile}, SIMD=${process.env.FLUXCORE_WASM_SIMD === '1' ? 'on' : 'off'})`,
);
if (profile === 'highres') {
await runHighResProfile(wasmBytes);
} else if (profile === 'realmedia') {
await runRealMediaProfile(wasmBytes);
} else {
await runStandardProfile(wasmBytes);
}
}
-364
View File
@@ -1,364 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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();
@@ -1,26 +1,9 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
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';
import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO, TYPING_WIDTH_MULTIPLIER} from '../src/features/ui/constants/TypingConstants';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -33,6 +16,8 @@ interface StatusConfig {
cutoutCenter: number;
}
const LARGE_STATUS_CUTOUT_GUTTER_RATIO = 0.2;
const STATUS_CONFIG: Record<number, StatusConfig> = {
16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13},
20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17},
@@ -43,10 +28,9 @@ const STATUS_CONFIG: Record<number, StatusConfig> = {
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},
80: {statusSize: 16, cutoutRadius: 16 / 2 + 16 * LARGE_STATUS_CUTOUT_GUTTER_RATIO, cutoutCenter: 68},
120: {statusSize: 24, cutoutRadius: 24 / 2 + 24 * LARGE_STATUS_CUTOUT_GUTTER_RATIO, cutoutCenter: 100},
};
const DESIGN_RULES = {
mobileAspectRatio: 0.75,
mobileCornerRadius: 0.12,
@@ -55,11 +39,9 @@ const DESIGN_RULES = {
mobileScreenY: 0.06,
mobileWheelRadius: 0.13,
mobileWheelY: 0.83,
mobilePhoneExtraHeight: 2,
mobileDisplayExtraHeight: 2,
mobileDisplayExtraWidthPerSide: 2,
idle: {
cutoutRadiusRatio: 0.7,
cutoutOffsetRatio: 0.35,
@@ -73,7 +55,6 @@ const DESIGN_RULES = {
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;
@@ -86,10 +67,44 @@ function getStatusConfig(avatarSize: number): StatusConfig {
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];
const firstSize = sizes[0];
const lastSize = sizes[sizes.length - 1];
if (avatarSize <= firstSize) {
return scaleStatusConfig(STATUS_CONFIG[firstSize], avatarSize / firstSize);
}
if (avatarSize >= lastSize) {
return scaleStatusConfig(STATUS_CONFIG[lastSize], avatarSize / lastSize);
}
const upperSize = sizes.find((size) => size > avatarSize) ?? lastSize;
const lowerSize = sizes[sizes.indexOf(upperSize) - 1] ?? firstSize;
const progress = (avatarSize - lowerSize) / (upperSize - lowerSize);
return interpolateStatusConfig(STATUS_CONFIG[lowerSize], STATUS_CONFIG[upperSize], progress);
}
function interpolateStatusConfig(from: StatusConfig, to: StatusConfig, progress: number): StatusConfig {
return {
statusSize: interpolate(from.statusSize, to.statusSize, progress),
cutoutRadius: interpolate(from.cutoutRadius, to.cutoutRadius, progress),
cutoutCenter: interpolate(from.cutoutCenter, to.cutoutCenter, progress),
};
}
function scaleStatusConfig(config: StatusConfig, scale: number): StatusConfig {
return {
statusSize: config.statusSize * scale,
cutoutRadius: config.cutoutRadius * scale,
cutoutCenter: config.cutoutCenter * scale,
};
}
function interpolate(from: number, to: number, progress: number): number {
return from + (to - from) * progress;
}
function formatNumber(value: number): string {
return Number(value.toFixed(6)).toString();
}
interface StatusGeometry {
@@ -112,15 +127,12 @@ interface MobileStatusGeometry extends StatusGeometry {
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,
@@ -129,19 +141,15 @@ function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false):
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,
@@ -161,7 +169,6 @@ function generateAvatarMaskDefault(size: number): string {
function generateAvatarMaskStatusRound(size: number): string {
const r = size / 2;
const status = calculateStatusGeometry(size);
return `(
<>
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
@@ -173,17 +180,13 @@ function generateAvatarMaskStatusRound(size: number): string {
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}" />
@@ -195,7 +198,6 @@ function generateAvatarMaskStatusTyping(size: number): string {
function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
const screenWidth =
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
displayExtraWidthPerSide * 2 -
@@ -209,11 +211,9 @@ function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
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}" />
@@ -225,23 +225,19 @@ function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
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}" />
@@ -249,13 +245,11 @@ function generateStatusIdle(size: number, isMobile: boolean = false): string {
</>
)`;
}
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;
@@ -263,7 +257,6 @@ function generateStatusDnd(size: number, isMobile: boolean = false): string {
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}" />
@@ -271,14 +264,12 @@ function generateStatusDnd(size: number, isMobile: boolean = false): string {
</>
)`;
}
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}" />
@@ -296,16 +287,13 @@ function generateStatusTyping(size: number): string {
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];
const GENERATED_HEADER = '// SPDX-License-Identifier: AGPL-3.0-or-later\n\n';
let output = `// @generated - DO NOT EDIT MANUALLY
// Run: pnpm generate:masks
type AvatarSize = ${SIZES.join(' | ')};
let output = `${GENERATED_HEADER}type AvatarSize = ${SIZES.join(' | ')};
interface MaskDefinition {
viewBox: string;
@@ -400,25 +388,20 @@ export const SVGMasks = () => (
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;
@@ -427,16 +410,13 @@ for (const size of SIZES) {
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 -
@@ -449,33 +429,30 @@ for (const size of SIZES) {
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}" />
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(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}" />
<rect fill="black" x="${formatNumber(cutoutPhoneX)}" y="${formatNumber(cutoutPhoneY)}" width="${formatNumber(cutoutPhoneWidth)}" height="${formatNumber(cutoutPhoneHeight)}" rx="${formatNumber(cutoutPhoneRx)}" ry="${formatNumber(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}" />
<rect fill="black" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(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}" />
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(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)}" />
@@ -483,19 +460,19 @@ for (const size of SIZES) {
<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}" />
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
<circle fill="black" cx="${formatNumber(idleCutoutCx)}" cy="${formatNumber(idleCutoutCy)}" r="${formatNumber(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}" />
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
<rect fill="black" x="${formatNumber(dndBarX)}" y="${formatNumber(dndBarY)}" width="${formatNumber(dndBarWidth)}" height="${formatNumber(dndBarHeight)}" rx="${formatNumber(dndBarRx)}" ry="${formatNumber(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}" />
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(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}" />
<rect fill="white" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(typingRx)}" />
</mask>
`;
@@ -532,15 +509,13 @@ output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBounding
);
`;
const outputPath = path.join(__dirname, '../src/components/uikit/SVGMasks.tsx');
const outputPath = path.join(__dirname, '../src/features/ui/components/SVGMasks.tsx');
fs.writeFileSync(outputPath, output);
console.log(`Generated ${outputPath}`);
const layoutOutput = `// @generated - DO NOT EDIT MANUALLY
// Run: pnpm generate:masks
export interface StatusGeometry {
const layoutOutput = `${GENERATED_HEADER}export interface StatusGeometry {
size: number;
cx: number;
cy: number;
@@ -554,14 +529,14 @@ export interface StatusGeometry {
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}`;
return ` ${size}: {size: ${formatNumber(geom.size)}, cx: ${formatNumber(geom.cx)}, cy: ${formatNumber(geom.cy)}, radius: ${formatNumber(geom.outerRadius)}, borderWidth: ${formatNumber(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}}`;
return ` ${size}: {size: ${formatNumber(geom.size)}, cx: ${formatNumber(geom.cx)}, cy: ${formatNumber(geom.cy)}, radius: ${formatNumber(geom.outerRadius)}, borderWidth: ${formatNumber(geom.borderWidth)}, isMobile: true, phoneWidth: ${formatNumber(geom.phoneWidth)}, phoneHeight: ${formatNumber(geom.phoneHeight)}}`;
}).join(',\n')},
};
@@ -572,13 +547,59 @@ export function getStatusGeometry(avatarSize: number, isMobile: boolean = false)
return map[avatarSize];
}
const closestSize = Object.keys(map)
const sizes = Object.keys(map)
.map(Number)
.reduce((prev, curr) => (Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev));
.sort((a, b) => a - b);
const firstSize = sizes[0];
const lastSize = sizes[sizes.length - 1];
return map[closestSize];
if (avatarSize <= firstSize) {
return scaleGeometry(map[firstSize], avatarSize / firstSize, isMobile);
}
if (avatarSize >= lastSize) {
return scaleGeometry(map[lastSize], avatarSize / lastSize, isMobile);
}
const upperSize = sizes.find((size) => size > avatarSize) ?? lastSize;
const lowerSize = sizes[sizes.indexOf(upperSize) - 1] ?? firstSize;
const progress = (avatarSize - lowerSize) / (upperSize - lowerSize);
return interpolateGeometry(map[lowerSize], map[upperSize], progress, isMobile);
}
function interpolateGeometry(from: StatusGeometry, to: StatusGeometry, progress: number, isMobile: boolean): StatusGeometry {
return {
size: interpolate(from.size, to.size, progress),
cx: interpolate(from.cx, to.cx, progress),
cy: interpolate(from.cy, to.cy, progress),
radius: interpolate(from.radius, to.radius, progress),
borderWidth: interpolate(from.borderWidth, to.borderWidth, progress),
isMobile,
phoneWidth:
from.phoneWidth != null && to.phoneWidth != null ? interpolate(from.phoneWidth, to.phoneWidth, progress) : undefined,
phoneHeight:
from.phoneHeight != null && to.phoneHeight != null
? interpolate(from.phoneHeight, to.phoneHeight, progress)
: undefined,
};
}
function scaleGeometry(geometry: StatusGeometry, scale: number, isMobile: boolean): StatusGeometry {
return {
size: geometry.size * scale,
cx: geometry.cx * scale,
cy: geometry.cy * scale,
radius: geometry.radius * scale,
borderWidth: geometry.borderWidth * scale,
isMobile,
phoneWidth: geometry.phoneWidth == null ? undefined : geometry.phoneWidth * scale,
phoneHeight: geometry.phoneHeight == null ? undefined : geometry.phoneHeight * scale,
};
}
function interpolate(from: number, to: number, progress: number): number {
return from + (to - from) * progress;
}
`;
const layoutPath = path.join(__dirname, '../src/features/ui/constants/AvatarStatusGeometry.ts');
const layoutPath = path.join(__dirname, '../src/components/uikit/AvatarStatusGeometry.ts');
fs.writeFileSync(layoutPath, layoutOutput);
@@ -1,21 +1,4 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
import {mkdirSync, writeFileSync} from 'node:fs';
import {dirname, join, relative} from 'node:path';
@@ -57,17 +40,271 @@ interface Config {
root: Array<TokenDef>;
light: Array<TokenDef>;
coal: Array<TokenDef>;
darkLegacy: Array<TokenDef>;
};
}
const DARK_CODE_TOKENS: Array<TokenDef> = [
{
name: '--code-text',
value: 'color-mix(in srgb, var(--text-secondary) 82%, hsl(340, calc(50% * var(--saturation-factor)), 90%) 18%)',
},
{name: '--text-code', value: 'var(--code-text)'},
{name: '--code-muted', value: 'color-mix(in srgb, var(--text-code) 42%, var(--text-tertiary) 58%)'},
{
name: '--code-inline-bg',
value: 'color-mix(in srgb, var(--background-secondary-alt) 88%, var(--background-tertiary) 12%)',
},
{name: '--bg-code', value: 'var(--code-inline-bg)'},
{
name: '--code-block-bg',
value: 'color-mix(in srgb, var(--background-secondary-alt) 92%, var(--background-primary) 8%)',
},
{name: '--bg-code-block', value: 'var(--code-block-bg)'},
{name: '--code-block-border', value: 'color-mix(in srgb, var(--border-color) 90%, var(--text-code) 10%)'},
{name: '--code-block-highlight', value: 'color-mix(in srgb, var(--text-primary) 10%, transparent)'},
{name: '--ansi-inverse-text', value: 'var(--code-block-bg)'},
{name: '--ansi-inverse-bg', value: 'var(--text-code)'},
{name: '--ansi-fg-black', value: 'color-mix(in srgb, hsl(0, 0%, 12%) 72%, var(--text-tertiary) 28%)'},
{
name: '--ansi-fg-red',
value: 'color-mix(in srgb, hsl(0, calc(88% * var(--saturation-factor)), 62%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-green',
value: 'color-mix(in srgb, hsl(99, calc(29% * var(--saturation-factor)), 47%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-yellow',
value: 'color-mix(in srgb, hsl(41, calc(53% * var(--saturation-factor)), 67%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-blue',
value: 'color-mix(in srgb, hsl(207, calc(61% * var(--saturation-factor)), 59%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-magenta',
value: 'color-mix(in srgb, hsl(305, calc(35% * var(--saturation-factor)), 65%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-cyan',
value: 'color-mix(in srgb, hsl(168, calc(53% * var(--saturation-factor)), 55%) 92%, var(--text-code) 8%)',
},
{name: '--ansi-fg-white', value: 'color-mix(in srgb, hsl(0, 0%, 83%) 88%, var(--text-secondary) 12%)'},
{name: '--ansi-fg-bright-black', value: 'color-mix(in srgb, hsl(0, 0%, 50%) 86%, var(--text-tertiary) 14%)'},
{
name: '--ansi-fg-bright-red',
value: 'color-mix(in srgb, hsl(0, calc(88% * var(--saturation-factor)), 62%) 94%, var(--text-primary) 6%)',
},
{
name: '--ansi-fg-bright-green',
value: 'color-mix(in srgb, hsl(99, calc(29% * var(--saturation-factor)), 47%) 88%, var(--text-primary) 12%)',
},
{
name: '--ansi-fg-bright-yellow',
value: 'color-mix(in srgb, hsl(41, calc(53% * var(--saturation-factor)), 67%) 94%, var(--text-primary) 6%)',
},
{
name: '--ansi-fg-bright-blue',
value: 'color-mix(in srgb, hsl(207, calc(61% * var(--saturation-factor)), 59%) 94%, var(--text-primary) 6%)',
},
{
name: '--ansi-fg-bright-magenta',
value: 'color-mix(in srgb, hsl(305, calc(35% * var(--saturation-factor)), 65%) 94%, var(--text-primary) 6%)',
},
{
name: '--ansi-fg-bright-cyan',
value: 'color-mix(in srgb, hsl(168, calc(53% * var(--saturation-factor)), 55%) 94%, var(--text-primary) 6%)',
},
{name: '--ansi-fg-bright-white', value: 'var(--text-primary)'},
{name: '--ansi-bg-black', value: 'color-mix(in srgb, hsl(0, 0%, 12%) 88%, var(--code-block-bg) 12%)'},
{
name: '--ansi-bg-red',
value: 'color-mix(in srgb, hsl(0, calc(88% * var(--saturation-factor)), 62%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-green',
value: 'color-mix(in srgb, hsl(99, calc(29% * var(--saturation-factor)), 47%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-yellow',
value: 'color-mix(in srgb, hsl(41, calc(53% * var(--saturation-factor)), 67%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-blue',
value: 'color-mix(in srgb, hsl(207, calc(61% * var(--saturation-factor)), 59%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-magenta',
value: 'color-mix(in srgb, hsl(305, calc(35% * var(--saturation-factor)), 65%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-cyan',
value: 'color-mix(in srgb, hsl(168, calc(53% * var(--saturation-factor)), 55%) 88%, var(--code-block-bg) 12%)',
},
{name: '--ansi-bg-white', value: 'color-mix(in srgb, hsl(0, 0%, 83%) 88%, var(--code-block-bg) 12%)'},
{name: '--ansi-bg-bright-black', value: 'color-mix(in srgb, hsl(0, 0%, 50%) 88%, var(--code-block-bg) 12%)'},
{
name: '--ansi-bg-bright-red',
value: 'color-mix(in srgb, hsl(0, calc(88% * var(--saturation-factor)), 62%) 92%, var(--code-block-bg) 8%)',
},
{
name: '--ansi-bg-bright-green',
value: 'color-mix(in srgb, hsl(99, calc(29% * var(--saturation-factor)), 47%) 92%, var(--code-block-bg) 8%)',
},
{
name: '--ansi-bg-bright-yellow',
value: 'color-mix(in srgb, hsl(41, calc(53% * var(--saturation-factor)), 67%) 92%, var(--code-block-bg) 8%)',
},
{
name: '--ansi-bg-bright-blue',
value: 'color-mix(in srgb, hsl(207, calc(61% * var(--saturation-factor)), 59%) 92%, var(--code-block-bg) 8%)',
},
{
name: '--ansi-bg-bright-magenta',
value: 'color-mix(in srgb, hsl(305, calc(35% * var(--saturation-factor)), 65%) 92%, var(--code-block-bg) 8%)',
},
{
name: '--ansi-bg-bright-cyan',
value: 'color-mix(in srgb, hsl(168, calc(53% * var(--saturation-factor)), 55%) 92%, var(--code-block-bg) 8%)',
},
{name: '--ansi-bg-bright-white', value: 'color-mix(in srgb, hsl(0, 0%, 100%) 92%, var(--code-block-bg) 8%)'},
];
const LIGHT_CODE_TOKENS: Array<TokenDef> = [
{
name: '--code-text',
value: 'color-mix(in srgb, var(--text-secondary) 72%, hsl(340, calc(50% * var(--saturation-factor)), 38%) 28%)',
},
{name: '--text-code', value: 'var(--code-text)'},
{name: '--code-muted', value: 'color-mix(in srgb, var(--text-code) 34%, var(--text-tertiary) 66%)'},
{name: '--code-inline-bg', value: 'color-mix(in srgb, var(--background-secondary-alt) 92%, var(--text-code) 8%)'},
{name: '--bg-code', value: 'var(--code-inline-bg)'},
{
name: '--code-block-bg',
value: 'color-mix(in srgb, var(--background-primary) 88%, var(--background-secondary) 12%)',
},
{name: '--bg-code-block', value: 'var(--code-block-bg)'},
{name: '--code-block-border', value: 'color-mix(in srgb, var(--border-color) 92%, var(--text-code) 8%)'},
{name: '--code-block-highlight', value: 'color-mix(in srgb, hsl(0, 0%, 100%) 82%, transparent)'},
{name: '--ansi-inverse-text', value: 'var(--code-block-bg)'},
{name: '--ansi-inverse-bg', value: 'var(--text-code)'},
{name: '--ansi-fg-black', value: 'var(--text-primary)'},
{
name: '--ansi-fg-red',
value: 'color-mix(in srgb, hsl(0, calc(62% * var(--saturation-factor)), 50%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-green',
value: 'color-mix(in srgb, hsl(120, calc(100% * var(--saturation-factor)), 25%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-yellow',
value: 'color-mix(in srgb, hsl(37, calc(52% * var(--saturation-factor)), 31%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-blue',
value: 'color-mix(in srgb, hsl(211, calc(95% * var(--saturation-factor)), 33%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-magenta',
value: 'color-mix(in srgb, hsl(288, calc(100% * var(--saturation-factor)), 43%) 92%, var(--text-code) 8%)',
},
{
name: '--ansi-fg-cyan',
value: 'color-mix(in srgb, hsl(192, calc(95% * var(--saturation-factor)), 38%) 92%, var(--text-code) 8%)',
},
{name: '--ansi-fg-white', value: 'color-mix(in srgb, hsl(0, 0%, 33%) 88%, var(--text-secondary) 12%)'},
{name: '--ansi-fg-bright-black', value: 'color-mix(in srgb, hsl(0, 0%, 40%) 88%, var(--text-tertiary) 12%)'},
{
name: '--ansi-fg-bright-red',
value: 'color-mix(in srgb, hsl(0, calc(62% * var(--saturation-factor)), 50%) 92%, var(--text-primary) 8%)',
},
{
name: '--ansi-fg-bright-green',
value: 'color-mix(in srgb, hsl(120, calc(82% * var(--saturation-factor)), 44%) 90%, var(--text-primary) 10%)',
},
{
name: '--ansi-fg-bright-yellow',
value: 'color-mix(in srgb, hsl(62, calc(100% * var(--saturation-factor)), 36%) 90%, var(--text-primary) 10%)',
},
{
name: '--ansi-fg-bright-blue',
value: 'color-mix(in srgb, hsl(211, calc(95% * var(--saturation-factor)), 33%) 92%, var(--text-primary) 8%)',
},
{
name: '--ansi-fg-bright-magenta',
value: 'color-mix(in srgb, hsl(300, calc(95% * var(--saturation-factor)), 38%) 92%, var(--text-primary) 8%)',
},
{
name: '--ansi-fg-bright-cyan',
value: 'color-mix(in srgb, hsl(192, calc(95% * var(--saturation-factor)), 38%) 92%, var(--text-primary) 8%)',
},
{name: '--ansi-fg-bright-white', value: 'color-mix(in srgb, hsl(0, 0%, 65%) 86%, var(--text-primary) 14%)'},
{name: '--ansi-bg-black', value: 'color-mix(in srgb, hsl(0, 0%, 0%) 86%, var(--code-block-bg) 14%)'},
{
name: '--ansi-bg-red',
value: 'color-mix(in srgb, hsl(0, calc(62% * var(--saturation-factor)), 50%) 86%, var(--code-block-bg) 14%)',
},
{
name: '--ansi-bg-green',
value: 'color-mix(in srgb, hsl(120, calc(100% * var(--saturation-factor)), 25%) 86%, var(--code-block-bg) 14%)',
},
{
name: '--ansi-bg-yellow',
value: 'color-mix(in srgb, hsl(37, calc(52% * var(--saturation-factor)), 31%) 86%, var(--code-block-bg) 14%)',
},
{
name: '--ansi-bg-blue',
value: 'color-mix(in srgb, hsl(211, calc(95% * var(--saturation-factor)), 33%) 86%, var(--code-block-bg) 14%)',
},
{
name: '--ansi-bg-magenta',
value: 'color-mix(in srgb, hsl(288, calc(100% * var(--saturation-factor)), 43%) 86%, var(--code-block-bg) 14%)',
},
{
name: '--ansi-bg-cyan',
value: 'color-mix(in srgb, hsl(192, calc(95% * var(--saturation-factor)), 38%) 86%, var(--code-block-bg) 14%)',
},
{name: '--ansi-bg-white', value: 'color-mix(in srgb, hsl(0, 0%, 90%) 86%, var(--code-block-bg) 14%)'},
{name: '--ansi-bg-bright-black', value: 'color-mix(in srgb, hsl(0, 0%, 40%) 88%, var(--code-block-bg) 12%)'},
{
name: '--ansi-bg-bright-red',
value: 'color-mix(in srgb, hsl(0, calc(62% * var(--saturation-factor)), 50%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-bright-green',
value: 'color-mix(in srgb, hsl(120, calc(82% * var(--saturation-factor)), 44%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-bright-yellow',
value: 'color-mix(in srgb, hsl(62, calc(100% * var(--saturation-factor)), 36%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-bright-blue',
value: 'color-mix(in srgb, hsl(211, calc(95% * var(--saturation-factor)), 33%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-bright-magenta',
value: 'color-mix(in srgb, hsl(300, calc(95% * var(--saturation-factor)), 38%) 88%, var(--code-block-bg) 12%)',
},
{
name: '--ansi-bg-bright-cyan',
value: 'color-mix(in srgb, hsl(192, calc(95% * var(--saturation-factor)), 38%) 88%, var(--code-block-bg) 12%)',
},
{name: '--ansi-bg-bright-white', value: 'color-mix(in srgb, hsl(0, 0%, 90%) 90%, var(--code-block-bg) 10%)'},
];
const CONFIG: Config = {
families: {
neutralDark: {hue: 220, saturation: 13, useSaturationFactor: true},
neutralDark: {hue: 258, saturation: 10, 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},
link: {hue: 198, saturation: 92, useSaturationFactor: true},
accentPurple: {hue: 278, saturation: 85, useSaturationFactor: true},
statusOnline: {hue: 152, saturation: 72, useSaturationFactor: true},
legacyDark: {hue: 220, saturation: 13, useSaturationFactor: true},
legacyLink: {hue: 210, saturation: 100, useSaturationFactor: true},
legacyAccentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
legacyStatusOnline: {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},
@@ -75,11 +312,10 @@ const CONFIG: Config = {
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
},
scales: {
darkSurface: {
family: 'neutralDark',
range: [5, 26],
range: [5, 24],
curve: 'easeOut',
stops: [
{name: '--background-primary', position: 0},
@@ -114,7 +350,7 @@ const CONFIG: Config = {
},
darkText: {
family: 'neutralDark',
range: [52, 96],
range: [60, 96],
curve: 'easeInOut',
stops: [
{name: '--text-tertiary-secondary', position: 0},
@@ -147,7 +383,7 @@ const CONFIG: Config = {
},
lightText: {
family: 'neutralLight',
range: [15, 60],
range: [15, 54],
curve: 'easeOut',
stops: [
{name: '--text-primary', position: 0},
@@ -160,8 +396,40 @@ const CONFIG: Config = {
{name: '--text-tertiary-muted', position: 0.85},
],
},
legacyDarkSurface: {
family: 'legacyDark',
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},
],
},
legacyDarkText: {
family: 'legacyDark',
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},
],
},
},
tokens: {
root: [
{scale: 'darkSurface'},
@@ -171,7 +439,7 @@ const CONFIG: Config = {
value: `color-mix(
in srgb,
var(--background-secondary-alt) 80%,
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
hsl(258, calc(10% * var(--saturation-factor)), 2%) 20%
)`,
},
{name: '--panel-control-border', family: 'neutralDark', saturation: 30, lightness: 65, alpha: 0.45},
@@ -207,8 +475,8 @@ hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
{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},
...DARK_CODE_TOKENS,
{name: '--text-selection', hue: 198, saturation: 92, 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},
@@ -287,15 +555,13 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
{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: '--border-color-focus', hue: 198, saturation: 92, 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)'},
@@ -324,7 +590,6 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
value: 'color-mix(in srgb, var(--background-modifier-hover) 70%, transparent)',
},
],
light: [
{scale: 'lightSurface'},
{scale: 'lightText'},
@@ -345,7 +610,7 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
{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},
...LIGHT_CODE_TOKENS,
{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)'},
@@ -385,8 +650,6 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
{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)'},
@@ -417,7 +680,6 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
{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)'},
@@ -427,7 +689,7 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
value: `color-mix(
in srgb,
var(--background-primary) 90%,
hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
hsl(258, calc(10% * var(--saturation-factor)), 0%) 10%
)`,
},
{name: '--panel-control-border', family: 'neutralDark', saturation: 20, lightness: 30, alpha: 0.35},
@@ -446,13 +708,19 @@ hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
{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: '--spoiler-overlay-color', value: 'hsla(0, 0%, 100%, 0.08)'},
{name: '--spoiler-overlay-hover-color', value: 'hsla(0, 0%, 100%, 0.14)'},
{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)'},
...DARK_CODE_TOKENS,
{name: '--code-inline-bg', value: 'color-mix(in srgb, var(--background-tertiary) 86%, var(--text-code) 14%)'},
{name: '--bg-code', value: 'var(--code-inline-bg)'},
{name: '--code-block-bg', value: 'color-mix(in srgb, var(--background-secondary-alt) 84%, hsl(0, 0%, 0%) 16%)'},
{name: '--bg-code-block', value: 'var(--code-block-bg)'},
{name: '--code-block-border', value: 'color-mix(in srgb, var(--border-color) 84%, var(--text-code) 16%)'},
{name: '--bg-blockquote', value: 'var(--background-secondary)'},
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
@@ -469,6 +737,48 @@ hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
value: 'color-mix(in srgb, var(--background-modifier-hover) 80%, transparent)',
},
],
darkLegacy: [
{scale: 'legacyDarkSurface'},
{scale: 'legacyDarkText'},
{
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: 'legacyDark', saturation: 30, lightness: 65, alpha: 0.45},
{name: '--panel-control-divider', family: 'legacyDark', saturation: 30, lightness: 55, alpha: 0.35},
{name: '--background-modifier-hover', family: 'legacyDark', lightness: 100, alpha: 0.05},
{name: '--background-modifier-selected', family: 'legacyDark', lightness: 100, alpha: 0.1},
{name: '--background-modifier-accent', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.15},
{name: '--background-modifier-accent-focus', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.22},
{name: '--control-button-hover-bg', family: 'legacyDark', lightness: 22},
{name: '--control-button-active-bg', family: 'legacyDark', lightness: 24},
{name: '--status-online', family: 'legacyStatusOnline', lightness: 40},
{name: '--text-link', family: 'legacyLink', lightness: 70},
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35},
{name: '--markup-mention-border', family: 'legacyLink', lightness: 70, alpha: 0.3},
...DARK_CODE_TOKENS,
{
name: '--code-inline-bg',
value: 'color-mix(in srgb, var(--background-secondary-alt) 82%, var(--text-code) 18%)',
},
{name: '--bg-code', value: 'var(--code-inline-bg)'},
{
name: '--code-block-bg',
value: 'color-mix(in srgb, var(--background-secondary-alt) 88%, var(--background-primary) 12%)',
},
{name: '--bg-code-block', value: 'var(--code-block-bg)'},
{name: '--border-color', family: 'legacyDark', lightness: 50, alpha: 0.2},
{name: '--border-color-hover', family: 'legacyDark', lightness: 50, alpha: 0.3},
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45},
{name: '--accent-purple', family: 'legacyAccentPurple', lightness: 65},
{name: '--alert-note-color', family: 'legacyLink', lightness: 70},
{name: '--alert-tip-color', family: 'legacyStatusOnline', lightness: 45},
{name: '--alert-important-color', family: 'legacyAccentPurple', lightness: 65},
],
},
};
@@ -507,21 +817,17 @@ function applyCurve(curve: Scale['curve'], t: number): number {
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,
@@ -529,13 +835,11 @@ function buildScaleTokens(scale: Scale): Array<OutputToken> {
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];
@@ -546,7 +850,6 @@ function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Arr
tokens.push(...buildScaleTokens(scale));
continue;
}
if (def.value !== undefined) {
tokens.push({
type: 'literal',
@@ -566,7 +869,6 @@ function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Arr
});
}
}
return tokens;
}
@@ -581,45 +883,37 @@ function formatNumber(value: number): string {
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)})`;
}
@@ -643,36 +937,28 @@ function generateCSS(
rootTokens: Array<OutputToken>,
lightTokens: Array<OutputToken>,
coalTokens: Array<OutputToken>,
darkLegacyTokens: 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),
renderBlock('.theme-dark_legacy', darkLegacyTokens, cfg.families),
];
return `${header}\n\n${blocks.join('\n\n')}\n`;
return `/* SPDX-License-Identifier: AGPL-3.0-or-later */\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');
const darkLegacyTokens = expandTokens(CONFIG.tokens.darkLegacy, CONFIG.scales);
const cssPath = join(appDir, 'src', 'features', 'theme', 'styles', 'generated', 'color-system.css');
mkdirSync(dirname(cssPath), {recursive: true});
const css = generateCSS(CONFIG, rootTokens, lightTokens, coalTokens);
const css = generateCSS(CONFIG, rootTokens, lightTokens, coalTokens, darkLegacyTokens);
writeFileSync(cssPath, css);
const relCSS = relative(appDir, cssPath);
console.log(`Wrote ${relCSS}`);
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {run} from 'typed-css-modules';
const watch = process.argv.includes('--watch');
run('src', {pattern: '**/*.module.css', watch}).catch((error: unknown) => {
console.error(error);
process.exit(1);
});
@@ -1,26 +1,9 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
import {mkdirSync, readFileSync, writeFileSync} from 'node:fs';
import {join} from 'node:path';
import {convertToCodePoints} from '@app/utils/EmojiCodepointUtils';
import sharp from 'sharp';
import {convertToCodePoints} from '../src/features/expressions/utils/EmojiCodepointUtils';
const EMOJI_SPRITES = {
nonDiversityPerRow: 42,
@@ -28,14 +11,15 @@ const EMOJI_SPRITES = {
pickerPerRow: 11,
pickerCount: 50,
} as const;
const EMOJI_SIZE = 32;
const TWEMOJI_CDN = 'https://fluxerstatic.com/emoji';
const SPRITE_SCALES = [1, 2] as const;
const TWEMOJI_LOCAL_DIR = join(import.meta.dirname, '..', '..', 'fluxer_static', 'emoji');
interface EmojiObject {
surrogates: string;
skins?: Array<{surrogates: string}>;
skins?: Array<{
surrogates: string;
}>;
}
interface EmojiEntry {
@@ -44,27 +28,16 @@ interface EmojiEntry {
const svgCache = new Map<string, string | null>();
async function fetchTwemojiSVG(codepoint: string): Promise<string | null> {
function loadLocalTwemojiSVG(codepoint: string): string | null {
if (svgCache.has(codepoint)) {
return svgCache.get(codepoint) ?? null;
}
const url = `${TWEMOJI_CDN}/${codepoint}.svg`;
const path = join(TWEMOJI_LOCAL_DIR, `${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();
const body = readFileSync(path, 'utf-8');
svgCache.set(codepoint, body);
return body;
} catch (err) {
console.error(`Failed to fetch Twemoji ${codepoint}:`, err);
} catch {
svgCache.set(codepoint, null);
return null;
}
@@ -82,15 +55,12 @@ async function renderSVGToBuffer(svgContent: string, size: number): Promise<Buff
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;
@@ -99,12 +69,10 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
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),
@@ -115,22 +83,18 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
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);
const svg = loadLocalTwemojiSVG(codepoint);
if (svg) {
try {
return await renderSVGToBuffer(svg, size);
@@ -138,10 +102,9 @@ async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer>
console.error(`Failed to render SVG for ${codepoint}:`, error);
}
}
if (codepoint.includes('-200d-')) {
const basePart = codepoint.split('-200d-')[0];
const baseSvg = await fetchTwemojiSVG(basePart);
const baseSvg = loadLocalTwemojiSVG(basePart);
if (baseSvg) {
try {
return await renderSVGToBuffer(baseSvg, size);
@@ -150,7 +113,6 @@ async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer>
}
}
}
console.error(`Missing SVG for ${codepoint} (${surrogate}), using placeholder`);
return createPlaceholder(size);
}
@@ -164,32 +126,25 @@ async function renderSpriteSheet(
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,
@@ -201,7 +156,6 @@ async function renderSpriteSheet(
.composite(compositeOps)
.png()
.toBuffer();
const suffix = scale !== 1 ? `@${scale}x` : '';
const outPath = join(outputDir, `${fileNameBase}${suffix}.png`);
writeFileSync(outPath, sheet);
@@ -227,11 +181,9 @@ async function generateDiversitySpriteSheets(
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) {
@@ -240,11 +192,9 @@ async function generateDiversitySpriteSheets(
}
}
}
if (skinEntries.length === 0) {
continue;
}
await renderSpriteSheet(skinEntries, EMOJI_SPRITES.diversityPerRow, `spritesheet-${skinCodepoint}`, outputDir);
}
}
@@ -282,7 +232,6 @@ async function generatePickerSpriteSheet(outputDir: string): Promise<void> {
'\u{1F973}',
'\u{1F60F}',
];
const entries: Array<EmojiEntry> = basicEmojis.map((e) => ({surrogates: e}));
await renderSpriteSheet(entries, EMOJI_SPRITES.pickerPerRow, 'spritesheet-picker', outputDir);
}
@@ -290,22 +239,16 @@ async function generatePickerSpriteSheet(outputDir: string): Promise<void> {
async function main(): Promise<void> {
const scriptDir = import.meta.dirname;
const appDir = join(scriptDir, '..');
const outputDir = join(appDir, 'src', 'assets', 'emoji-sprites');
const outputDir = join(appDir, 'src', 'media', 'images', 'emoji-sprites');
mkdirSync(outputDir, {recursive: true});
const emojiDataPath = join(appDir, 'src', 'data', 'emojis.json');
const emojiDataPath = join(appDir, 'src', 'media', '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.');
}
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
import {dirname, join, relative} from 'node:path';
import {renderMessageLayoutCss} from '../src/features/theme/layout/MessageLayoutCss';
function main(): void {
const scriptDir = import.meta.dirname;
const appDir = join(scriptDir, '..');
const cssPath = join(appDir, 'src', 'features', 'theme', 'styles', 'generated', 'message-layout.css');
const css = renderMessageLayoutCss();
if (process.argv.includes('--check')) {
if (!existsSync(cssPath) || readFileSync(cssPath, 'utf8') !== css) {
throw new Error(`${relative(appDir, cssPath)} is stale. Run pnpm generate:message-layout.`);
}
console.log(`Checked ${relative(appDir, cssPath)}`);
return;
}
mkdirSync(dirname(cssPath), {recursive: true});
writeFileSync(cssPath, css);
console.log(`Wrote ${relative(appDir, cssPath)}`);
}
main();
@@ -0,0 +1,395 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {dirname, join, relative, resolve} from 'node:path';
type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
interface CssSource {
file: string;
label: string;
}
interface VariableDefinition {
name: string;
kind: ThemeVariableKind;
groupId: string;
groupLabel: string;
source: string;
}
const PRIORITY_CSS_SOURCES: ReadonlyArray<CssSource> = [
{file: 'src/app/globals.css', label: 'globals'},
{file: 'src/features/theme/styles/generated/color-system.css', label: 'color-system'},
{file: 'src/features/theme/styles/generated/message-layout.css', label: 'message-layout'},
];
const PRIORITY_SOURCE_INDEX = new Map(PRIORITY_CSS_SOURCES.map((source, index) => [source.file, index]));
const IGNORED_SOURCE_PREFIXES = ['src/features/theme_studio/', 'src/theme/'];
const EXTRA_GLOBAL_DEFAULTS: ReadonlyArray<{name: string; value: string; source: string}> = [
{
name: '--font-sans',
value: "'Fluxer Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
source: 'runtime-fonts',
},
{
name: '--font-mono',
value: "'Fluxer Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
source: 'runtime-fonts',
},
{name: '--font-size', value: '1rem', source: 'runtime-accessibility'},
{name: '--chat-horizontal-padding', value: '1rem', source: 'runtime-accessibility'},
{name: '--message-group-spacing', value: '1rem', source: 'runtime-accessibility'},
{name: '--link-decoration', value: 'none', source: 'runtime-accessibility'},
{name: '--markup-strikethrough-color', value: 'currentColor', source: 'runtime-accessibility'},
];
const GROUP_LABELS: Record<string, string> = {
typography: 'Typography',
surfaces: 'Surfaces',
headers: 'Headers',
text: 'Text',
brand: 'Brand & accents',
status: 'Status indicators',
borders: 'Borders & focus',
alerts: 'Alerts & callouts',
markup: 'Markup & mentions',
buttons: 'Buttons',
code: 'Code & terminal',
tables: 'Tables',
scrolling: 'Scrolling',
layout: 'Layout',
messages: 'Messages',
emoji: 'Emoji',
motion: 'Motion',
layering: 'Layering',
media: 'Media',
forms: 'Forms',
other: 'Other',
};
function stripAtRuleBlocks(css: string): string {
let output = '';
let index = 0;
while (index < css.length) {
if (css[index] !== '@') {
output += css[index];
index += 1;
continue;
}
const nextSemicolon = css.indexOf(';', index);
const nextBrace = css.indexOf('{', index);
if (nextBrace === -1 || (nextSemicolon !== -1 && nextSemicolon < nextBrace)) {
index = nextSemicolon === -1 ? css.length : nextSemicolon + 1;
continue;
}
let depth = 0;
let cursor = nextBrace;
for (; cursor < css.length; cursor += 1) {
if (css[cursor] === '{') depth += 1;
if (css[cursor] === '}') {
depth -= 1;
if (depth === 0) {
cursor += 1;
break;
}
}
}
index = cursor;
}
return output;
}
function toPosixPath(path: string): string {
return path.replaceAll('\\', '/');
}
function discoverCssSources(appDir: string): ReadonlyArray<CssSource> {
const srcDir = join(appDir, 'src');
const files: Array<string> = [];
const visit = (directory: string) => {
for (const entry of readdirSync(directory, {withFileTypes: true})) {
const absolutePath = join(directory, entry.name);
if (entry.isDirectory()) {
visit(absolutePath);
continue;
}
if (!entry.isFile() || !entry.name.endsWith('.css')) continue;
const sourceFile = toPosixPath(relative(appDir, absolutePath));
if (IGNORED_SOURCE_PREFIXES.some((prefix) => sourceFile.startsWith(prefix))) continue;
files.push(sourceFile);
}
};
visit(srcDir);
return files
.sort((left, right) => {
const leftPriority = PRIORITY_SOURCE_INDEX.get(left);
const rightPriority = PRIORITY_SOURCE_INDEX.get(right);
if (leftPriority !== undefined || rightPriority !== undefined) {
return (leftPriority ?? Number.MAX_SAFE_INTEGER) - (rightPriority ?? Number.MAX_SAFE_INTEGER);
}
return left.localeCompare(right);
})
.map((file) => ({
file,
label: PRIORITY_CSS_SOURCES.find((source) => source.file === file)?.label ?? file.replace(/^src\//, ''),
}));
}
function selectorHas(selector: string, target: string): boolean {
return selector
.split(',')
.map((part) => part.trim())
.includes(target);
}
function extractDeclarations(block: string): Array<[string, string]> {
const declarations: Array<[string, string]> = [];
const pattern = /(--[a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(block)) !== null) {
const name = match[1] as string;
const value = (match[2] as string).replace(/\s+/g, ' ').trim();
declarations.push([name, value]);
}
return declarations;
}
function readSourceVariables(appDir: string): {
darkDefaults: Map<string, string>;
lightDefaults: Map<string, string>;
sources: Map<string, string>;
} {
const darkDefaults = new Map<string, string>();
const lightOverrides = new Map<string, string>();
const sources = new Map<string, string>();
for (const source of discoverCssSources(appDir)) {
const absolutePath = join(appDir, source.file);
const css = stripAtRuleBlocks(readFileSync(absolutePath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''));
const blockPattern = /([^{}]+)\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = blockPattern.exec(css)) !== null) {
const selector = (match[1] as string).trim();
const block = match[2] as string;
const isRoot = selectorHas(selector, ':root');
const isLight = selectorHas(selector, '.theme-light');
if (!isRoot && !isLight) continue;
for (const [name, value] of extractDeclarations(block)) {
if (isRoot) {
darkDefaults.set(name, value);
sources.set(name, source.label);
}
if (isLight) {
lightOverrides.set(name, value);
sources.set(name, source.label);
}
}
}
}
for (const extra of EXTRA_GLOBAL_DEFAULTS) {
if (!darkDefaults.has(extra.name)) {
darkDefaults.set(extra.name, extra.value);
sources.set(extra.name, extra.source);
}
}
const lightDefaults = new Map(darkDefaults);
for (const [name, value] of lightOverrides) {
lightDefaults.set(name, value);
}
return {darkDefaults, lightDefaults, sources};
}
function resolveVariableValue(name: string, values: ReadonlyMap<string, string>, stack = new Set<string>()): string {
const value = values.get(name);
if (!value) return '';
return value.replace(
/var\(\s*(--[a-zA-Z0-9_-]+)(?:\s*,\s*([^)]+))?\)/g,
(full, dependency: string, fallback?: string) => {
if (dependency === '--saturation-factor') return full;
if (stack.has(dependency)) return fallback?.trim() ?? full;
const dependencyValue = values.get(dependency);
if (!dependencyValue) return fallback?.trim() ?? full;
const nextStack = new Set(stack);
nextStack.add(name);
return resolveVariableValue(dependency, values, nextStack);
},
);
}
function getGroupId(name: string): string {
if (name.startsWith('--font')) return 'typography';
if (name.startsWith('--z-index')) return 'layering';
if (name.startsWith('--transition')) return 'motion';
if (name.startsWith('--shadow')) return 'borders';
if (name.includes('scrollbar')) return 'scrolling';
if (name.startsWith('--message')) return 'messages';
if (name.includes('typing')) return 'messages';
if (name.includes('emoji')) return 'emoji';
if (name.includes('textarea') || name.includes('input') || name.includes('form')) return 'forms';
if (name.includes('button') || name.includes('control-button')) return 'buttons';
if (name.startsWith('--code') || name.startsWith('--ansi') || name === '--text-code') return 'code';
if (name.includes('table')) return 'tables';
if (name.startsWith('--markup') || name.includes('spoiler')) return 'markup';
if (name.startsWith('--alert')) return 'alerts';
if (name.startsWith('--status')) return 'status';
if (name.startsWith('--brand') || name.startsWith('--accent') || name.startsWith('--plutonium')) return 'brand';
if (name.startsWith('--text')) return 'text';
if (name.includes('border') || name.includes('focus') || name.includes('radius')) return 'borders';
if (name.includes('layout') || name.includes('spacing') || name.includes('padding') || name.includes('gap'))
return 'layout';
if (name.includes('width') || name.includes('height') || name.includes('size') || name.includes('gutter'))
return 'layout';
if (name.includes('media') || name.includes('avatar') || name.includes('guild-icon')) return 'media';
if (name.includes('bg') || name.includes('background') || name.includes('surface') || name.includes('guild-list')) {
return 'surfaces';
}
return 'other';
}
function getKind(name: string, value: string): ThemeVariableKind {
const lowerName = name.toLowerCase();
const lowerValue = value.toLowerCase();
if (name === '--font-sans' || name === '--font-mono') return 'font';
if (name.startsWith('--shadow')) return 'shadow';
if (name.startsWith('--transition') || /\b\d+(?:\.\d+)?m?s\b/.test(lowerValue)) return 'transition';
if (/^-?\d+(?:\.\d+)?$/.test(value)) return 'number';
if (lowerName.includes('opacity')) return 'number';
if (lowerValue.includes(' solid ')) return 'other';
if (
lowerValue === 'transparent' ||
lowerValue === 'currentcolor' ||
lowerValue.startsWith('#') ||
lowerValue.startsWith('hsl') ||
lowerValue.startsWith('rgb') ||
lowerValue.startsWith('color-mix')
) {
return 'color';
}
if (
/(?:^|\s)-?\d*\.?\d+(?:px|rem|em|%|vh|vw|dvh|svh|cqi)\b/.test(lowerValue) ||
lowerValue.includes('calc(') ||
lowerValue.includes('clamp(') ||
lowerValue.includes('min(') ||
lowerValue.includes('max(')
) {
return 'dimension';
}
if (
lowerName.startsWith('--ansi') ||
lowerName.includes('color') ||
lowerName.startsWith('--text-') ||
lowerName.endsWith('-text') ||
lowerName.includes('-text-') ||
lowerName.includes('bg') ||
lowerName.includes('background') ||
lowerName.includes('fill') ||
lowerName.includes('accent') ||
lowerName.includes('brand') ||
lowerName.includes('status') ||
lowerName.includes('alert') ||
lowerName.includes('selection')
) {
return 'color';
}
return 'other';
}
function buildDefinitions(
darkDefaults: ReadonlyMap<string, string>,
sources: ReadonlyMap<string, string>,
): Array<VariableDefinition> {
return [...darkDefaults.keys()]
.sort((left, right) => left.localeCompare(right))
.map((name) => {
const value = resolveVariableValue(name, darkDefaults);
const groupId = getGroupId(name);
return {
name,
kind: getKind(name, value),
groupId,
groupLabel: GROUP_LABELS[groupId] ?? GROUP_LABELS.other,
source: sources.get(name) ?? 'unknown',
};
});
}
function renderStringArray(name: string, values: ReadonlyArray<string>): string {
const body = values.map((value) => `\t${JSON.stringify(value)},`).join('\n');
return `export const ${name}: ReadonlyArray<string> = [\n${body}\n];`;
}
function renderValueMap(name: string, values: ReadonlyMap<string, string>): string {
const body = [...values.keys()]
.sort((left, right) => left.localeCompare(right))
.map((key) => `\t${JSON.stringify(key)}: ${JSON.stringify(resolveVariableValue(key, values))},`)
.join('\n');
return `export const ${name}: Readonly<Record<string, string>> = {\n${body}\n};`;
}
function renderDefinitions(definitions: ReadonlyArray<VariableDefinition>): string {
const body = definitions
.map(
(definition) =>
`\t{name: ${JSON.stringify(definition.name)}, kind: ${JSON.stringify(definition.kind)}, groupId: ${JSON.stringify(definition.groupId)}, groupLabel: ${JSON.stringify(definition.groupLabel)}, source: ${JSON.stringify(definition.source)}},`,
)
.join('\n');
return `export const THEME_VARIABLES: ReadonlyArray<ThemeVariableDefinition> = [\n${body}\n];`;
}
function render(appDir: string): string {
const {darkDefaults, lightDefaults, sources} = readSourceVariables(appDir);
const definitions = buildDefinitions(darkDefaults, sources);
const colorVariables = definitions
.filter((definition) => definition.kind === 'color')
.map((definition) => definition.name);
const fontVariables = definitions
.filter((definition) => definition.kind === 'font')
.map((definition) => definition.name);
return `// SPDX-License-Identifier: AGPL-3.0-or-later
// Generated by scripts/GenerateThemeVariables.ts. Do not edit by hand.
export type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
export interface ThemeVariableDefinition {
\tname: string;
\tkind: ThemeVariableKind;
\tgroupId: string;
\tgroupLabel: string;
\tsource: string;
}
${renderDefinitions(definitions)}
${renderStringArray(
'THEME_VARIABLE_NAMES',
definitions.map((definition) => definition.name),
)}
${renderStringArray('THEME_COLOR_VARIABLES', colorVariables)}
${renderStringArray('THEME_FONT_VARIABLES', fontVariables)}
${renderValueMap('THEME_STUDIO_DARK_DEFAULT_VARIABLE_VALUES', darkDefaults)}
${renderValueMap('THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES', lightDefaults)}
`;
}
function main(): void {
const scriptDir = import.meta.dirname;
const appDir = resolve(scriptDir, '..');
const outputPath = join(appDir, 'src', 'features', 'theme', 'variables', 'ThemeVariableManifest.ts');
const contents = render(appDir);
if (process.argv.includes('--check')) {
if (!existsSync(outputPath) || readFileSync(outputPath, 'utf8') !== contents) {
throw new Error(`${relative(appDir, outputPath)} is stale. Run pnpm generate:theme-variables.`);
}
console.log(`Checked ${relative(appDir, outputPath)}`);
return;
}
mkdirSync(dirname(outputPath), {recursive: true});
writeFileSync(outputPath, contents);
console.log(`Wrote ${relative(appDir, outputPath)}`);
}
main();
@@ -0,0 +1,664 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {existsSync, readdirSync, readFileSync, statSync, writeFileSync} from 'node:fs';
import {join, relative, resolve, sep} from 'node:path';
import {fileURLToPath} from 'node:url';
import {parse} from '@babel/parser';
const APP_DIR = fileURLToPath(new URL('..', import.meta.url));
const SOURCE_DIR = join(APP_DIR, 'src');
const JSX_EXTENSIONS = new Set(['.tsx', '.jsx']);
const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.cache', '.swc']);
const NON_THEMEABLE_IDENTIFIERS = new Set([
'Fragment',
'I18nProvider',
'Outlet',
'Profiler',
'Provider',
'React',
'RouterProvider',
'StrictMode',
'Suspense',
'Trans',
]);
const NON_THEMEABLE_MEMBER_PROPERTIES = new Set(['Consumer', 'Fragment', 'Provider']);
const PASS_THROUGH_DOM_MEMBERS = new Set(['motion', 'm']);
const GENERIC_PATH_SEGMENTS = new Set([
'alerts',
'bottomsheets',
'commands',
'components',
'config',
'constants',
'dialogs',
'hooks',
'layout',
'layouts',
'modals',
'models',
'pages',
'panels',
'popouts',
'routes',
'sections',
'shared',
'state',
'tabs',
'types',
'utils',
]);
const GENERIC_SCOPE_NAMES = new Set(['children', 'component', 'props', 'render', 'root-component']);
const EVENT_ATTRIBUTE_NAMES = [
'onClick',
'onPress',
'onSelect',
'onSubmit',
'onChange',
'onInput',
'onKeyDown',
'onPointerDown',
'onMouseDown',
'onContextMenu',
];
function printUsage() {
console.log(`Usage: node scripts/add-data-flx-attributes.mjs [options] [paths...]
Adds stable data-flx attributes to JSX elements for theme selectors.
Options:
--dry-run Report changes without writing files (default)
--write Write changes
--check Exit non-zero when any data-flx attributes are missing
--target <all|dom> all = JSX components and DOM nodes, dom = intrinsic DOM/SVG only (default: all)
--summary-limit <n> Number of changed files to list in the summary (default: 30)
--help Show this message
`);
}
function parseArgs(argv) {
const options = {
check: false,
write: false,
target: 'all',
summaryLimit: 30,
paths: [],
};
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === '--dry-run') {
options.write = false;
} else if (arg === '--write') {
options.write = true;
} else if (arg === '--check') {
options.check = true;
options.write = false;
} else if (arg === '--target') {
options.target = argv[++index];
} else if (arg.startsWith('--target=')) {
options.target = arg.slice('--target='.length);
} else if (arg === '--summary-limit') {
options.summaryLimit = Number(argv[++index]);
} else if (arg.startsWith('--summary-limit=')) {
options.summaryLimit = Number(arg.slice('--summary-limit='.length));
} else if (arg === '--help') {
printUsage();
process.exit(0);
} else if (arg === '--') {
} else if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}`);
} else {
options.paths.push(arg);
}
}
if (!['all', 'dom'].includes(options.target)) {
throw new Error('--target must be "all" or "dom"');
}
if (!Number.isInteger(options.summaryLimit) || options.summaryLimit < 0) {
throw new Error('--summary-limit must be a non-negative integer');
}
return options;
}
function getExtension(path) {
const index = path.lastIndexOf('.');
return index === -1 ? '' : path.slice(index);
}
function walk(dir, out) {
for (const entry of readdirSync(dir, {withFileTypes: true})) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(join(dir, entry.name), out);
continue;
}
if (entry.isFile() && JSX_EXTENSIONS.has(getExtension(entry.name))) {
out.push(join(dir, entry.name));
}
}
}
function collectFiles(paths) {
const files = [];
const roots = paths.length > 0 ? paths : [SOURCE_DIR];
for (const input of roots) {
const absolute = resolve(APP_DIR, input);
if (!existsSync(absolute)) {
throw new Error(`Path does not exist: ${input}`);
}
const stat = statSync(absolute);
if (stat.isDirectory()) {
walk(absolute, files);
} else if (stat.isFile() && JSX_EXTENSIONS.has(getExtension(absolute))) {
files.push(absolute);
}
}
return Array.from(new Set(files)).sort();
}
function parseSource(source, filePath) {
return parse(source, {
sourceFilename: filePath,
sourceType: 'module',
errorRecovery: false,
plugins: [
'jsx',
'typescript',
['decorators', {decoratorsBeforeExport: true}],
'importAttributes',
'explicitResourceManagement',
],
});
}
function kebabCase(value) {
return String(value)
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[\s_.:/\\]+/g, '-')
.replace(/[^A-Za-z0-9-]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function uniqueItems(values) {
const seen = new Set();
const out = [];
for (const value of values) {
if (!value || seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}
function relativeFileScope(filePath) {
const relativePath = relative(SOURCE_DIR, filePath).split(sep).join('/');
const withoutExtension = relativePath.replace(/\.[tj]sx$/, '');
const parts = withoutExtension.split('/').filter(Boolean);
const normalized = [];
for (let index = 0; index < parts.length; index++) {
const part = parts[index];
if (index === 0 && part === 'features') continue;
const token = kebabCase(part);
if (!token) continue;
if (GENERIC_PATH_SEGMENTS.has(token) && index !== parts.length - 1) continue;
normalized.push(token);
}
if (normalized.at(-1) === 'index' && normalized.length > 1) normalized.pop();
return normalized.join('.');
}
function getNodeName(node) {
if (!node) return '';
switch (node.type) {
case 'JSXIdentifier':
return node.name;
case 'JSXMemberExpression':
return `${getNodeName(node.object)}.${getNodeName(node.property)}`;
case 'JSXNamespacedName':
return `${getNodeName(node.namespace)}:${getNodeName(node.name)}`;
default:
return '';
}
}
function getNameTail(name) {
const parts = name.split(/[.:]/).filter(Boolean);
if (parts.length === 0) return '';
if (parts.length >= 2 && PASS_THROUGH_DOM_MEMBERS.has(parts[0]) && /^[a-z]/.test(parts[1])) {
return parts[1];
}
return parts.join('-');
}
function isIntrinsicElementName(name) {
if (!name) return false;
if (/^[a-z]/.test(name)) return true;
const parts = name.split('.');
return parts.length === 2 && PASS_THROUGH_DOM_MEMBERS.has(parts[0]) && /^[a-z]/.test(parts[1]);
}
function isNonThemeableOpeningElement(opening) {
const name = getNodeName(opening.name);
if (!name) return true;
if (NON_THEMEABLE_IDENTIFIERS.has(name)) return true;
const parts = name.split('.');
const property = parts.at(-1);
return property ? NON_THEMEABLE_MEMBER_PROPERTIES.has(property) : false;
}
function shouldTagOpeningElement(opening, target) {
if (opening.type !== 'JSXOpeningElement') return false;
if (isNonThemeableOpeningElement(opening)) return false;
const name = getNodeName(opening.name);
if (target === 'dom') return isIntrinsicElementName(name);
return true;
}
function getAttributeName(attribute) {
if (!attribute || attribute.type !== 'JSXAttribute') return '';
return getNodeName(attribute.name);
}
function hasAttribute(opening, name) {
return opening.attributes.some((attribute) => getAttributeName(attribute) === name);
}
function getAttribute(opening, names) {
const wanted = Array.isArray(names) ? new Set(names) : new Set([names]);
return opening.attributes.find((attribute) => wanted.has(getAttributeName(attribute))) ?? null;
}
function stringLiteralAttribute(opening, names) {
const attribute = getAttribute(opening, names);
if (!attribute?.value) return '';
if (attribute.value.type === 'StringLiteral') return attribute.value.value;
return '';
}
function expressionFromAttribute(attribute) {
if (!attribute?.value) return null;
if (attribute.value.type === 'JSXExpressionContainer') return attribute.value.expression;
return attribute.value;
}
function memberPropertyToken(node) {
if (!node) return '';
if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
const objectName = node.object?.type === 'Identifier' ? node.object.name : '';
if (!/(^|[A-Z])styles?$/.test(objectName)) return '';
if (!node.computed && node.property?.type === 'Identifier') return kebabCase(node.property.name);
if (node.computed && node.property?.type === 'StringLiteral') return kebabCase(node.property.value);
return '';
}
return '';
}
function collectClassTokens(node, out = []) {
if (!node) return out;
const memberToken = memberPropertyToken(node);
if (memberToken) out.push(memberToken);
switch (node.type) {
case 'StringLiteral':
out.push(...node.value.split(/\s+/).map(kebabCase));
break;
case 'TemplateLiteral':
for (const quasi of node.quasis) out.push(...quasi.value.cooked.split(/\s+/).map(kebabCase));
for (const expression of node.expressions) collectClassTokens(expression, out);
break;
case 'ArrayExpression':
for (const element of node.elements) collectClassTokens(element, out);
break;
case 'ObjectExpression':
for (const property of node.properties) {
if (property.type === 'ObjectProperty') collectClassTokens(property.key, out);
}
break;
case 'CallExpression':
case 'OptionalCallExpression':
for (const arg of node.arguments) collectClassTokens(arg, out);
break;
case 'ConditionalExpression':
collectClassTokens(node.consequent, out);
collectClassTokens(node.alternate, out);
break;
case 'LogicalExpression':
collectClassTokens(node.left, out);
collectClassTokens(node.right, out);
break;
case 'SequenceExpression':
for (const expression of node.expressions) collectClassTokens(expression, out);
break;
case 'TSAsExpression':
case 'TSSatisfiesExpression':
case 'TSTypeAssertion':
case 'TSNonNullExpression':
collectClassTokens(node.expression, out);
break;
default:
break;
}
return out;
}
function commonSegmentPrefix(tokens) {
if (tokens.length < 2) return '';
const segments = tokens.map((token) => token.split('-').filter(Boolean));
const prefix = [];
for (let index = 0; index < Math.min(...segments.map((segment) => segment.length)); index++) {
const candidate = segments[0][index];
if (segments.every((segment) => segment[index] === candidate)) {
prefix.push(candidate);
} else {
break;
}
}
return prefix.length >= 2 ? prefix.join('-') : '';
}
function classTokenForOpening(opening) {
const classAttribute = getAttribute(opening, ['className', 'class']);
if (!classAttribute) return '';
const tokens = uniqueItems(collectClassTokens(expressionFromAttribute(classAttribute)).filter(Boolean));
if (tokens.length === 0) return '';
return commonSegmentPrefix(tokens) || tokens[0];
}
function identifierToken(node) {
if (!node) return '';
switch (node.type) {
case 'Identifier':
return node.name;
case 'MemberExpression':
case 'OptionalMemberExpression':
return identifierToken(node.property);
case 'CallExpression':
case 'OptionalCallExpression':
return identifierToken(node.callee);
default:
return '';
}
}
function firstCallTokenFromExpression(node) {
if (!node) return '';
switch (node.type) {
case 'CallExpression':
case 'OptionalCallExpression':
return identifierToken(node.callee);
case 'ArrowFunctionExpression':
case 'FunctionExpression':
return firstCallTokenFromExpression(node.body);
case 'BlockStatement':
for (const statement of node.body) {
const token = firstCallTokenFromExpression(statement);
if (token) return token;
}
return '';
case 'ExpressionStatement':
return firstCallTokenFromExpression(node.expression);
case 'ReturnStatement':
return firstCallTokenFromExpression(node.argument);
case 'ConditionalExpression':
return firstCallTokenFromExpression(node.consequent) || firstCallTokenFromExpression(node.alternate);
default:
return identifierToken(node);
}
}
function normalizeHandlerToken(token) {
const kebab = kebabCase(token)
.replace(/^handle-/, '')
.replace(/^on-/, '')
.replace(/-(handler|callback)$/, '');
return GENERIC_SCOPE_NAMES.has(kebab) ? '' : kebab;
}
function handlerTokenForOpening(opening) {
for (const name of EVENT_ATTRIBUTE_NAMES) {
const attribute = getAttribute(opening, name);
const token = normalizeHandlerToken(firstCallTokenFromExpression(expressionFromAttribute(attribute)));
if (token) return token;
}
return '';
}
function isInteractiveOpening(opening) {
const name = getNodeName(opening.name);
const tail = kebabCase(getNameTail(name));
if (['a', 'button', 'input', 'select', 'textarea', 'summary'].includes(tail)) return true;
if (stringLiteralAttribute(opening, 'role') === 'button') return true;
return EVENT_ATTRIBUTE_NAMES.some((eventName) => getAttribute(opening, eventName));
}
function semanticTokenForOpening(opening) {
const name = getNodeName(opening.name);
const nameToken = kebabCase(getNameTail(name)) || 'element';
const dataRole = kebabCase(stringLiteralAttribute(opening, ['data-role', 'data-testid', 'data-test-id']));
const id = kebabCase(stringLiteralAttribute(opening, 'id'));
const classToken = classTokenForOpening(opening);
const role = kebabCase(stringLiteralAttribute(opening, 'role'));
const ariaLabel = kebabCase(stringLiteralAttribute(opening, 'aria-label'));
const type = kebabCase(stringLiteralAttribute(opening, 'type'));
const handler = isInteractiveOpening(opening) ? handlerTokenForOpening(opening) : '';
const parts = [];
if (dataRole) parts.push(dataRole);
else if (id) parts.push(id);
else if (classToken) parts.push(classToken);
else if (role) parts.push(role);
else if (ariaLabel) parts.push(ariaLabel);
else parts.push(nameToken);
if (handler && !parts.some((part) => part.includes(handler) || handler.includes(part))) parts.push(handler);
if (type && !parts.some((part) => part.includes(type))) parts.push(type);
return uniqueItems(parts).join('.');
}
function inferFunctionScopeName(node, ancestors) {
if (node.type === 'FunctionDeclaration' && node.id?.name) return node.id.name;
if (node.type === 'FunctionExpression' && node.id?.name) return node.id.name;
const parent = ancestors.at(-1);
const grandparent = ancestors.at(-2);
if (parent?.type === 'VariableDeclarator' && parent.id?.type === 'Identifier') return parent.id.name;
if (parent?.type === 'ObjectProperty' && parent.key?.type === 'Identifier') return parent.key.name;
if (parent?.type === 'ObjectMethod' && parent.key?.type === 'Identifier') return parent.key.name;
if (parent?.type === 'AssignmentExpression' && parent.left?.type === 'Identifier') return parent.left.name;
if (
parent?.type === 'CallExpression' &&
grandparent?.type === 'VariableDeclarator' &&
grandparent.id?.type === 'Identifier'
) {
return grandparent.id.name;
}
return '';
}
function isFunctionLike(node) {
return (
node.type === 'FunctionDeclaration' ||
node.type === 'FunctionExpression' ||
node.type === 'ArrowFunctionExpression' ||
node.type === 'ObjectMethod' ||
node.type === 'ClassMethod' ||
node.type === 'ClassPrivateMethod'
);
}
function isTraversableNode(value) {
return value && typeof value === 'object' && typeof value.type === 'string';
}
function visit(node, ancestors, state) {
if (!isTraversableNode(node)) return;
let nextState = state;
if (isFunctionLike(node)) {
const rawScope = inferFunctionScopeName(node, ancestors);
const scope = kebabCase(rawScope);
if (scope && !GENERIC_SCOPE_NAMES.has(scope)) {
nextState = {...state, scopes: [...state.scopes, scope]};
}
}
if (node.type === 'JSXOpeningElement') {
state.onOpening(node, nextState.scopes);
}
const nextAncestors = [...ancestors, node];
for (const [key, value] of Object.entries(node)) {
if (
key === 'comments' ||
key === 'end' ||
key === 'extra' ||
key === 'innerComments' ||
key === 'leadingComments' ||
key === 'loc' ||
key === 'start' ||
key === 'trailingComments'
) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) visit(item, nextAncestors, nextState);
} else {
visit(value, nextAncestors, nextState);
}
}
}
function dataFlxValue(filePath, opening, scopes, usedKeys) {
const scopeParts = [relativeFileScope(filePath)];
const localScope = scopes.at(-1);
const localScopeToken = localScope && !scopeParts[0].split('.').includes(localScope) ? localScope : '';
if (localScopeToken) scopeParts.push(localScopeToken);
scopeParts.push(semanticTokenForOpening(opening));
const base = scopeParts.filter(Boolean).join('.');
const count = usedKeys.get(base) ?? 0;
usedKeys.set(base, count + 1);
return count === 0 ? base : `${base}--${count + 1}`;
}
function lineStartAt(source, position) {
const previousNewline = source.lastIndexOf('\n', position - 1);
return previousNewline === -1 ? 0 : previousNewline + 1;
}
function lineEndAt(source, position) {
const nextNewline = source.indexOf('\n', position);
return nextNewline === -1 ? source.length : nextNewline;
}
function indentationAt(source, position) {
const lineStart = lineStartAt(source, position);
const lineEnd = lineEndAt(source, position);
const linePrefix = source.slice(lineStart, Math.min(position, lineEnd));
const match = linePrefix.match(/^\s*/);
return match ? match[0] : '';
}
function attributeIndent(source, opening) {
const multiline = source.slice(opening.start, opening.end).includes('\n');
if (!multiline) return '';
for (const attribute of opening.attributes) {
const start = attribute.start;
const lineStart = lineStartAt(source, start);
if (/^\s*$/.test(source.slice(lineStart, start))) return indentationAt(source, start);
}
return `${indentationAt(source, opening.start)}\t`;
}
function openingCloseTokenStart(source, opening) {
const close = source.lastIndexOf('>', opening.end - 1);
return opening.selfClosing ? close - 1 : close;
}
function buildInsertion(source, opening, value) {
const attrText = `data-flx=${JSON.stringify(value)}`;
const multiline = source.slice(opening.start, opening.end).includes('\n');
const firstSpread = opening.attributes.find((attribute) => attribute.type === 'JSXSpreadAttribute');
if (firstSpread) {
if (!multiline) return {position: firstSpread.start, text: `${attrText} `};
const start = lineStartAt(source, firstSpread.start);
return {position: start, text: `${attributeIndent(source, opening)}${attrText}\n`};
}
const closeStart = openingCloseTokenStart(source, opening);
if (!multiline) {
const prefix = /\s/.test(source[closeStart - 1] ?? '') ? '' : ' ';
const suffix = opening.selfClosing ? ' ' : '';
return {position: closeStart, text: `${prefix}${attrText}${suffix}`};
}
return {position: lineStartAt(source, closeStart), text: `${attributeIndent(source, opening)}${attrText}\n`};
}
function processFile(filePath, options) {
const source = readFileSync(filePath, 'utf8');
const ast = parseSource(source, filePath);
const usedKeys = new Map();
const insertions = [];
const stats = {
added: 0,
existing: 0,
skipped: 0,
};
visit(ast, [], {
scopes: [],
onOpening(opening, scopes) {
if (!shouldTagOpeningElement(opening, options.target)) {
stats.skipped++;
return;
}
if (hasAttribute(opening, 'data-flx')) {
stats.existing++;
return;
}
const value = dataFlxValue(filePath, opening, scopes, usedKeys);
insertions.push(buildInsertion(source, opening, value));
stats.added++;
},
});
if (insertions.length > 0 && options.write) {
let output = source;
for (const insertion of insertions.sort((a, b) => b.position - a.position)) {
output = `${output.slice(0, insertion.position)}${insertion.text}${output.slice(insertion.position)}`;
}
writeFileSync(filePath, output);
}
return stats;
}
function main() {
const options = parseArgs(process.argv.slice(2));
const files = collectFiles(options.paths);
const totals = {
filesScanned: 0,
filesChanged: 0,
added: 0,
existing: 0,
skipped: 0,
};
const changedFiles = [];
for (const file of files) {
const result = processFile(file, options);
totals.filesScanned++;
totals.added += result.added;
totals.existing += result.existing;
totals.skipped += result.skipped;
if (result.added > 0) {
totals.filesChanged++;
changedFiles.push({file, added: result.added});
}
}
const mode = options.write ? 'write' : options.check ? 'check' : 'dry-run';
console.log(
`data-flx ${mode}: filesScanned=${totals.filesScanned} filesChanged=${totals.filesChanged} added=${totals.added} existing=${totals.existing} skipped=${totals.skipped} target=${options.target}`,
);
for (const item of changedFiles.slice(0, options.summaryLimit)) {
console.log(` ${relative(APP_DIR, item.file)} +${item.added}`);
}
if (changedFiles.length > options.summaryLimit) {
console.log(` ...and ${changedFiles.length - options.summaryLimit} more files`);
}
if (options.check && totals.added > 0) {
process.exitCode = 1;
}
}
main();
-86
View File
@@ -1,86 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {spawnSync} from 'node:child_process';
import {readFileSync} from 'node:fs';
import {homedir} from 'node:os';
import {join} from 'node:path';
const envOverrides = loadEnvFromFiles(['FLUXER_AUTO_I18N', 'OPENROUTER_API_KEY']);
const FLUXER_AUTO_I18N = process.env.FLUXER_AUTO_I18N ?? envOverrides.FLUXER_AUTO_I18N ?? '';
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? envOverrides.OPENROUTER_API_KEY ?? '';
const shouldRun = FLUXER_AUTO_I18N === '1' && Boolean(OPENROUTER_API_KEY);
if (!shouldRun) {
process.exit(0);
}
const childEnv = {...process.env, FLUXER_AUTO_I18N, OPENROUTER_API_KEY};
const scriptPath = new URL('./translate-i18n.mjs', import.meta.url).pathname;
const result = spawnSync(process.execPath, [scriptPath], {stdio: 'inherit', env: childEnv});
process.exit(result.status ?? 1);
function loadEnvFromFiles(keys) {
const homeDir = homedir();
const targetKeys = new Set(keys);
const env = Object.create(null);
const candidates = ['.bash_profile', '.bashrc', '.profile'];
for (const candidate of candidates) {
const filePath = join(homeDir, candidate);
try {
const content = readFileSync(filePath, 'utf8');
for (const line of content.split(/\r?\n/)) {
const parsed = parseExportLine(line);
if (!parsed || !targetKeys.has(parsed.key) || env[parsed.key]) {
continue;
}
env[parsed.key] = parsed.value;
}
} catch {}
}
return env;
}
function parseExportLine(line) {
const trimmed = line.trim();
if (!trimmed.startsWith('export ')) {
return null;
}
const match = trimmed.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) {
return null;
}
return {key: match[1], value: stripQuotes(match[2])};
}
function stripQuotes(value) {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
+1 -18
View File
@@ -1,21 +1,4 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
import {buildServiceWorker} from './build/utils/ServiceWorker';
+1 -18
View File
@@ -1,21 +1,4 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as path from 'node:path';
@@ -1,65 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
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();
}
+1 -18
View File
@@ -1,21 +1,4 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
module.exports = function localArboriumLoader(source) {
return source
.replaceAll('"jsdelivr"', '"bundled"')
.replaceAll("'jsdelivr'", "'bundled'")
.replaceAll('"unpkg"', '"bundled"')
.replaceAll("'unpkg'", "'bundled'")
.replaceAll('"https://cdn.jsdelivr.net/npm"', '""')
.replaceAll("'https://cdn.jsdelivr.net/npm'", "''")
.replaceAll('"https://unpkg.com"', '""')
.replaceAll("'https://unpkg.com'", "''");
};
+1 -18
View File
@@ -1,21 +1,4 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs/promises';
@@ -1,32 +1,16 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
import {sources} from '@rspack/core';
function normalizeEndpoint(staticCdnEndpoint) {
if (!staticCdnEndpoint) return '';
return staticCdnEndpoint.endsWith('/') ? staticCdnEndpoint.slice(0, -1) : staticCdnEndpoint;
const STATIC_CDN_ENDPOINT_PLACEHOLDER = '{{STATIC_CDN_ENDPOINT}}';
function resolveStaticCdnEndpoint(staticCdnEndpoint) {
const value = staticCdnEndpoint?.trim().replace(/\/+$/, '');
return value || STATIC_CDN_ENDPOINT_PLACEHOLDER;
}
function generateManifest(staticCdnEndpointRaw) {
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
function generateManifest(staticCdnEndpoint) {
const cdn = resolveStaticCdnEndpoint(staticCdnEndpoint);
const manifest = {
name: 'Fluxer',
short_name: 'Fluxer',
@@ -42,29 +26,29 @@ function generateManifest(staticCdnEndpointRaw) {
scope: '/',
icons: [
{
src: `${staticCdnEndpoint}/web/android-chrome-192x192.png`,
src: `${cdn}/web/android-chrome-192x192.png`,
sizes: '192x192',
type: 'image/png',
purpose: 'maskable any',
},
{
src: `${staticCdnEndpoint}/web/android-chrome-512x512.png`,
src: `${cdn}/web/android-chrome-512x512.png`,
sizes: '512x512',
type: 'image/png',
purpose: 'maskable any',
},
{
src: `${staticCdnEndpoint}/web/apple-touch-icon.png`,
src: `${cdn}/web/apple-touch-icon.png`,
sizes: '180x180',
type: 'image/png',
},
{
src: `${staticCdnEndpoint}/web/favicon-32x32.png`,
src: `${cdn}/web/favicon-32x32.png`,
sizes: '32x32',
type: 'image/png',
},
{
src: `${staticCdnEndpoint}/web/favicon-16x16.png`,
src: `${cdn}/web/favicon-16x16.png`,
sizes: '16x16',
type: 'image/png',
},
@@ -74,14 +58,13 @@ function generateManifest(staticCdnEndpointRaw) {
return JSON.stringify(manifest, null, 2);
}
function generateBrowserConfig(staticCdnEndpointRaw) {
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
function generateBrowserConfig(staticCdnEndpoint) {
const cdn = resolveStaticCdnEndpoint(staticCdnEndpoint);
return `<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="${staticCdnEndpoint}/web/mstile-150x150.png"/>
<square150x150logo src="${cdn}/web/mstile-150x150.png"/>
<TileColor>#4641D9</TileColor>
</tile>
</msapplication>
@@ -93,8 +76,8 @@ function generateRobotsTxt() {
}
export class StaticFilesPlugin {
constructor(options) {
this.staticCdnEndpoint = options?.staticCdnEndpoint ?? '';
constructor(options = {}) {
this.staticCdnEndpoint = options.staticCdnEndpoint;
}
apply(compiler) {
-63
View File
@@ -1,63 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * 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'),
},
],
};
}
+1 -18
View File
@@ -1,21 +1,4 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
declare module 'postcss' {
interface ProcessOptions {
-75
View File
@@ -1,75 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * 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});
}
}
-147
View File
@@ -1,147 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * 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.`);
}
-94
View File
@@ -1,94 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * 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;
}
@@ -1,48 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * 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;
}
@@ -1,29 +1,67 @@
/*
* 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/>.
*/
// SPDX-License-Identifier: AGPL-3.0-or-later
import {promises as fs} from 'node:fs';
import * as path from 'node:path';
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
import * as esbuild from 'esbuild';
interface PrecacheEntry {
url: string;
revision: string;
}
const PRECACHE_ROOT_FILES = ['index.html', 'manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
async function fileRevision(filePath: string): Promise<string> {
const stat = await fs.stat(filePath);
return `${stat.size}:${Math.trunc(stat.mtimeMs)}`;
}
function isLocalAssetUrl(value: string): boolean {
if (!value.startsWith('/')) {
return false;
}
if (value.startsWith('//')) {
return false;
}
return value.startsWith('/assets/') || PRECACHE_ROOT_FILES.some((file) => value === `/${file}`);
}
async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
const entries = new Map<string, string>();
for (const file of PRECACHE_ROOT_FILES) {
const filePath = path.join(DIST_DIR, file);
try {
const revision = await fileRevision(filePath);
entries.set(`/${file}`, revision);
if (file === 'index.html') {
entries.set('/', revision);
}
} catch {}
}
try {
const html = await fs.readFile(path.join(DIST_DIR, 'index.html'), 'utf8');
const attributePattern = /\b(?:href|src)=["']([^"']+)["']/g;
for (const match of html.matchAll(attributePattern)) {
const url = match[1];
if (!isLocalAssetUrl(url)) {
continue;
}
const pathname = new URL(url, 'https://local.invalid').pathname;
const filePath = path.join(DIST_DIR, pathname.slice(1));
try {
entries.set(pathname, await fileRevision(filePath));
} catch {}
}
} catch {}
return Array.from(entries, ([url, revision]) => ({url, revision}));
}
export async function buildServiceWorker(production: boolean): Promise<void> {
const precacheManifest = await collectPrecacheManifest();
const buildVersion = process.env.PUBLIC_BUILD_SHA || process.env.BUILD_SHA || String(Date.now());
await esbuild.build({
entryPoints: [path.join(SRC_DIR, 'service_worker', 'Worker.tsx')],
entryPoints: [path.join(SRC_DIR, 'features', 'platform', 'service_worker', 'Worker.ts')],
bundle: true,
format: 'iife',
outfile: path.join(DIST_DIR, 'sw.js'),
@@ -32,6 +70,8 @@ export async function buildServiceWorker(production: boolean): Promise<void> {
target: 'esnext',
define: {
__WB_MANIFEST: '[]',
__FLUXER_PRECACHE_MANIFEST__: JSON.stringify(precacheManifest),
__FLUXER_SW_VERSION__: JSON.stringify(buildVersion),
},
});
}
@@ -1,86 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * 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);
}
});
}
-363
View File
@@ -1,363 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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);
});